diff --git a/.cursor/rules/safe-monorepo.mdc b/.cursor/rules/safe-monorepo.mdc new file mode 100644 index 0000000000..0e1bd08d18 --- /dev/null +++ b/.cursor/rules/safe-monorepo.mdc @@ -0,0 +1,102 @@ +--- +description: +globs: +alwaysApply: true +--- +You are an expert developer proficient in TypeScript, Web3/Blockchain(ethers.js, Safe Ecosystem (formally known as Gnosis Safe)), React and Next.js, Expo (React Native), Tamagui, Zod, Yarn v4 (Monorepo Management), Redux, RTK. + +Project Structure and Environment + +- Follow the established project structure +- Use the `apps` directory for Next.js and Expo applications. +- Utilize the `packages` directory for shared code and components. +- use `expo-plugins` directory for custom expo config plugins +- Use `dotenv` for environment variable management. +- Follow patterns for environment-specific configurations in `eas.json` and `next.config.js`. + +Code Style and Structure + +- Depending on the part of the code you work on, follow the code style document in the respective docs folder. +- Write concise, technical TypeScript code with accurate examples. +- Use functional and declarative programming patterns; avoid classes. +- Prefer iteration and modularization over code duplication. +- Use descriptive variable names with auxiliary verbs (e.g., `isLoading`, `hasError`). +- Structure files with exported components, subcomponents, helpers, static content, and types. +- Favor named exports for components and functions. + +TypeScript and Zod Usage + +- Use TypeScript for all code; prefer interfaces over types for object shapes. +- Utilize Zod for schema validation and type inference. +- Avoid enums; use literal types or maps instead. +- Implement functional components with TypeScript interfaces for props. +- follow the lint rules and don't use ts "any" type + +Syntax and Formatting + +- Use the `const` instead of `function` for pure functions. +- Write declarative JSX with clear and readable structure. + +UI and Styling +- in the mobile project utilise Tamagui for UI compoentns and styling +- in the nextjs project use Mui +- Implement responsive design with a mobile-first approach. +- Ensure styling consistency between web and native applications. + +State Management and Data Fetching + +- Use Redux for state management. +- Use Redux RTK for data fetching, caching, and synchronization. +- Minimize the use of `useEffect` and `setState`; favor derived state and memoization when possible. + +Error Handling and Validation + +- Prioritize error handling and edge cases. +- Handle errors and edge cases at the beginning of functions. +- Use early returns for error conditions to avoid deep nesting. +- Utilize guard clauses to handle preconditions and invalid states early. +- Implement proper error logging and user-friendly error messages. +- Use custom error types or factories for consistent error handling. + +Performance Optimization + +- Optimize for both web and mobile performance. +- Use dynamic imports for code splitting in Next.js. +- Implement lazy loading for non-critical components. +- Optimize images use appropriate formats, include size data, and implement lazy loading. + +Monorepo Management + +- Follow best practices using Yarn v4 for monorepo setups. +- Ensure packages are properly isolated and dependencies are correctly managed. +- Use shared configurations and scripts where appropriate. +- Utilize the workspace structure as defined in the root `package.json`. + + +Testing and Quality Assurance +- use faker to create test data +- Write unit and integration tests for critical components. +- Use jest and the testing libraries specified in each package +- Ensure code coverage and quality metrics meet the project's requirements. +- when testing Redux code prefer actual state changes test over mock calls +- when testing functions that call network endpoints prefer Mock Service Worker (MSW) over mocking function calls + + +Key Conventions + +- Use descriptive and meaningful commit messages. +- Ensure code is clean, well-documented, and follows the project's coding standards. +- Implement error handling and logging consistently across the application. + +Follow Official Documentation + +- Adhere to the official documentation for each technology used. +- For Next.js, focus on data fetching methods and routing conventions. +- Stay updated with the latest best practices and updates, especially for Expo, React-Native and Tamagui. + +Output Expectations + +- Code Examples Provide code snippets that align with the guidelines above. +- Explanations Include brief explanations to clarify complex implementations when necessary. +- Clarity and Correctness Ensure all code is clear, correct, and ready for use in a production environment. +- Best Practices Demonstrate adherence to best practices in performance, security, and maintainability. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md index e2eae5680b..fabdd3a253 100644 --- a/.github/ISSUE_TEMPLATE/task.md +++ b/.github/ISSUE_TEMPLATE/task.md @@ -6,13 +6,10 @@ type: 'task' ## Links -Epic on Notion: +Epic on Notion: ## What must be done - - ## Designs/sketches - ## How to test it diff --git a/.github/ISSUE_TEMPLATE/tech-debt.md b/.github/ISSUE_TEMPLATE/tech-debt.md index 3017618f68..1985d6c0b9 100644 --- a/.github/ISSUE_TEMPLATE/tech-debt.md +++ b/.github/ISSUE_TEMPLATE/tech-debt.md @@ -7,8 +7,6 @@ labels: 'tech debt' ## Problem - ## Proposed solution - ## Dependencies & risks diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a456260f54..b7e3dad00c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,3 +13,9 @@ Resolves # - [ ] I've tested the branch on mobile 📱 - [ ] I've documented how it affects the analytics (if at all) 📊 - [ ] I've written a unit/e2e test for it (if applicable) 🧑‍💻 + +--- + +## CLA signature + +With the submission of this Pull Request, I confirm that I have read and agree to the terms of the [Contributor License Agreement](https://safe.global/cla). diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index 52289b4af3..60441e031a 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -11,10 +11,6 @@ inputs: description: 'Production build flag' required: false - e2e_mnemonic: - description: 'Mnemonic for the E2E tests' - required: false - runs: using: 'composite' @@ -41,16 +37,15 @@ runs: run: yarn workspace @safe-global/web build env: NEXT_PUBLIC_IS_PRODUCTION: ${{ inputs.prod }} - NEXT_PUBLIC_CYPRESS_MNEMONIC: ${{ inputs.e2e_mnemonic }} 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 }} - NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LATEST_AUTH: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LATEST_AUTH }} - NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LIVE_AUTH: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LIVE_AUTH }} + NEXT_PUBLIC_PROD_GA_TRACKING_ID: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_PROD_GA_TRACKING_ID }} + NEXT_PUBLIC_TEST_GA_TRACKING_ID: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_TEST_GA_TRACKING_ID }} + NEXT_PUBLIC_SAFE_APPS_GA_TRACKING_ID: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_SAFE_APPS_GA_TRACKING_ID }} NEXT_PUBLIC_SENTRY_DSN: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_SENTRY_DSN }} + NEXT_PUBLIC_DATADOG_CLIENT_TOKEN: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_DATADOG_CLIENT_TOKEN }} NEXT_PUBLIC_TENDERLY_ORG_NAME: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_TENDERLY_ORG_NAME }} 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 }} @@ -61,14 +56,13 @@ runs: NEXT_PUBLIC_BRAND_LOGO: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_BRAND_LOGO }} NEXT_PUBLIC_BRAND_NAME: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_BRAND_NAME }} NEXT_PUBLIC_BLOCKAID_CLIENT_ID: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_BLOCKAID_CLIENT_ID }} - NEXT_PUBLIC_SOCIAL_WALLET_OPTIONS_STAGING: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_SOCIAL_WALLET_OPTIONS_STAGING }} - NEXT_PUBLIC_SOCIAL_WALLET_OPTIONS_PRODUCTION: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_SOCIAL_WALLET_OPTIONS_PRODUCTION }} NEXT_PUBLIC_FIREBASE_OPTIONS_PRODUCTION: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_FIREBASE_OPTIONS_PRODUCTION }} NEXT_PUBLIC_FIREBASE_OPTIONS_STAGING: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_FIREBASE_OPTIONS_STAGING }} NEXT_PUBLIC_FIREBASE_VAPID_KEY_PRODUCTION: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_FIREBASE_VAPID_KEY_PRODUCTION }} NEXT_PUBLIC_FIREBASE_VAPID_KEY_STAGING: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_FIREBASE_VAPID_KEY_STAGING }} NEXT_PUBLIC_SPINDL_SDK_KEY: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_SPINDL_SDK_KEY }} NEXT_PUBLIC_ECOSYSTEM_ID_ADDRESS: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_ECOSYSTEM_ID_ADDRESS }} + NEXT_PUBLIC_MIXPANEL_TOKEN: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_MIXPANEL_TOKEN }} - name: Save Next.js Build Cache & Cypress cache if: steps.restore-nc.outputs.cache-hit-nc != 'true' diff --git a/.github/actions/cache-deps/action.yml b/.github/actions/cache-deps/action.yml index 4a6c28b5d6..378a5b4581 100644 --- a/.github/actions/cache-deps/action.yml +++ b/.github/actions/cache-deps/action.yml @@ -34,7 +34,7 @@ runs: **/node_modules /home/runner/.cache/Cypress ${{ github.workspace }}/.yarn/install-state.gz - ${{ github.workspace }}/apps/web/src/types + ${{ github.workspace }}/packages/utils/src/types key: ${{ runner.os }}-web-core-modules-${{ hashFiles('**/package.json','**/yarn.lock') }} restore-keys: | ${{ runner.os }}-web-core-modules- @@ -54,7 +54,7 @@ runs: **/node_modules /home/runner/.cache/Cypress ${{ github.workspace }}/.yarn/install-state.gz - ${{ github.workspace }}/apps/web/src/types + ${{ github.workspace }}/packages/utils/src/types key: ${{inputs.key}} - name: Restore Next.js diff --git a/.github/actions/cypress/action.yml b/.github/actions/cypress/action.yml index 6dec02fbdc..7956c1febf 100644 --- a/.github/actions/cypress/action.yml +++ b/.github/actions/cypress/action.yml @@ -41,7 +41,6 @@ runs: - uses: ./.github/actions/build with: secrets: ${{ inputs.secrets }} - e2e_mnemonic: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_CYPRESS_MNEMONIC }} - uses: cypress-io/github-action@v6 with: @@ -54,6 +53,8 @@ runs: config: baseUrl=http://localhost:8080 install: false start: yarn workspace @safe-global/web serve + wait-on: 'http://localhost:8080' + wait-on-timeout: 120 working-directory: apps/web env: CYPRESS_RECORD_KEY: ${{ inputs.record_key || fromJSON(inputs.secrets).CYPRESS_RECORD_KEY }} diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml deleted file mode 100644 index f578a76532..0000000000 --- a/.github/workflows/cla.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: 'CLA Assistant' - -on: - issue_comment: - types: [created] - pull_request_target: - types: [opened, closed, synchronize] - -jobs: - CLAAssistant: - runs-on: ubuntu-latest - steps: - - name: 'CLA Assistant' - if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' - # Beta Release - uses: contributor-assistant/github-action@v2.6.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # the below token should have repo scope and must be manually added by you in the repository's secret - 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 - # branch should not be protected - branch: 'main' - # user names of users allowed to contribute without CLA - allowlist: clovisdasilvaneto,lukasschor,rmeissner,germartinez,Uxio0,dasanra,francovenica,tschubotz,luarx,DaniSomoza,iamacook,yagopv,usame-algan,schmanu,DiogoSoaress,JagoFigueroa,fmrsabino,mike10ca,jmealy,compojoom,TanyaEfremova,bot* - - # the followings are the optional inputs - If the optional inputs are not given, then default values will be taken - # enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository) - remote-organization-name: 'safe-global' - # enter the remote repository name where the signatures should be stored (Default is storing the signatures in the same repository) - remote-repository-name: 'cla-signatures' - #create-file-commit-message: 'For example: Creating file for storing CLA Signatures' - #signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo' - #custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign' - #custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA' - #custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.' - #lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true) - #use-dco-flag: true - If you are using DCO instead of CLA diff --git a/.github/workflows/mobile-dev-release.yml b/.github/workflows/mobile-dev-release.yml new file mode 100644 index 0000000000..ded50cdb92 --- /dev/null +++ b/.github/workflows/mobile-dev-release.yml @@ -0,0 +1,52 @@ +name: EAS Dev Build + +on: + push: + branches: + - dev + paths: + - apps/mobile/** + - packages/** + pull_request: + paths: + - apps/mobile/** + - packages/** + +jobs: + build: + if: > + github.event_name == 'push' || + (github.event_name == 'pull_request' && + contains(github.event.pull_request.labels.*.name, 'mobile-dev-release')) + name: Install and build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Enable Corepack + run: corepack enable + + # Set up Node.js + - uses: actions/setup-node@v4 + with: + node-version: '22.11.0' + cache: 'yarn' + + # Install dependencies + - name: Install dependencies + run: yarn install --immutable + + - name: Setup Expo and EAS + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Build & deploy iOS on EAS + working-directory: apps/mobile + run: eas build --profile development --non-interactive --no-wait --platform ios --auto-submit-with-profile=development + + - name: Build & deploy Android on EAS + working-directory: apps/mobile + run: eas build --profile development --non-interactive --no-wait --platform android --auto-submit-with-profile=development diff --git a/.github/workflows/mobile-e2e.yml b/.github/workflows/mobile-e2e.yml new file mode 100644 index 0000000000..a44e084c49 --- /dev/null +++ b/.github/workflows/mobile-e2e.yml @@ -0,0 +1,42 @@ +name: EAS Mobile E2E tests + +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * *' + pull_request: + paths: + - apps/mobile/** + - packages/** + +jobs: + build: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'mobile-e2e-test') + name: Install and build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Enable Corepack + run: corepack enable + + # Set up Node.js + - uses: actions/setup-node@v4 + with: + node-version: '22.11.0' # jod + cache: 'yarn' + + # Install dependencies + - name: Install dependencies + run: yarn install --immutable + + - name: Setup Expo and EAS + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Build on EAS + working-directory: apps/mobile + run: eas build --profile build-and-maestro-test --non-interactive --no-wait --platform ios diff --git a/.github/workflows/mobile-lint.yml b/.github/workflows/mobile-lint.yml index 8b63f19f8b..10d06c88ed 100644 --- a/.github/workflows/mobile-lint.yml +++ b/.github/workflows/mobile-lint.yml @@ -6,11 +6,11 @@ on: - main paths: - apps/mobile/** - - packages/store/** + - packages/** pull_request: paths: - apps/mobile/** - - packages/store/** + - packages/** jobs: lint: diff --git a/.github/workflows/mobile-unit-tests.yml b/.github/workflows/mobile-unit-tests.yml index d4ca3e5075..d31d0258b5 100644 --- a/.github/workflows/mobile-unit-tests.yml +++ b/.github/workflows/mobile-unit-tests.yml @@ -6,10 +6,11 @@ on: - main paths: - apps/mobile/** + - packages/** pull_request: paths: - apps/mobile/** - + - packages/** jobs: test-and-coverage: runs-on: ubuntu-latest diff --git a/.github/workflows/package-utils-unit-tests.yml b/.github/workflows/package-utils-unit-tests.yml new file mode 100644 index 0000000000..ac0003aaeb --- /dev/null +++ b/.github/workflows/package-utils-unit-tests.yml @@ -0,0 +1,40 @@ +name: Package @safe-global/utils unit tests +on: + pull_request: + paths: + - packages/utils/** + - packages/store/** + push: + branches: + - main + paths: + - packages/** +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + eslint: + permissions: + checks: write + pull-requests: read + statuses: write + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/yarn + + # Run tests with coverage + - name: Run Jest tests with coverage + run: | + yarn workspace @safe-global/utils test:coverage --coverageReporters=text --coverageReporters=json-summary | tee ./coverage.txt && exit ${PIPESTATUS[0]} + + - name: Jest Coverage Comment + uses: MishaKav/jest-coverage-comment@v1 + with: + title: Package @safe-global/utils coverage + coverage-summary-path: ./coverage/coverage-summary.json + coverage-title: Coverage + coverage-path: ./coverage.txt diff --git a/.github/workflows/web-deploy-dev.yml b/.github/workflows/web-deploy-dev.yml index 7f50959c77..c11caaaa10 100644 --- a/.github/workflows/web-deploy-dev.yml +++ b/.github/workflows/web-deploy-dev.yml @@ -9,7 +9,7 @@ on: - main paths: - apps/web/** - - packages/store/** + - packages/** concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -48,6 +48,10 @@ jobs: #- uses: ./.github/workflows/build-storybook + - name: Add SRI to scripts + run: node ./scripts/integrity-hashes.cjs + working-directory: apps/web + - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: diff --git a/.github/workflows/web-deploy-dockerhub.yml b/.github/workflows/web-deploy-dockerhub.yml index 934bc60dda..b4de2d04c9 100644 --- a/.github/workflows/web-deploy-dockerhub.yml +++ b/.github/workflows/web-deploy-dockerhub.yml @@ -8,7 +8,7 @@ on: - docker paths: - apps/web/** - - packages/store/** + - packages/** push: branches: @@ -16,7 +16,7 @@ on: - dev paths: - apps/web/** - - packages/store/** + - packages/** release: types: [released] diff --git a/.github/workflows/web-deploy-production.yml b/.github/workflows/web-deploy-production.yml index 7aa82c08d5..91908add54 100644 --- a/.github/workflows/web-deploy-production.yml +++ b/.github/workflows/web-deploy-production.yml @@ -28,6 +28,10 @@ jobs: secrets: ${{ toJSON(secrets) }} prod: ${{ true }} + - name: Add SRI to scripts + run: node ./scripts/integrity-hashes.cjs + working-directory: apps/web + - name: Create archive run: tar -czf "$ARCHIVE_NAME".tar.gz out working-directory: apps/web diff --git a/.github/workflows/web-e2e-full-ondemand.yml b/.github/workflows/web-e2e-full-ondemand.yml index bf7785d210..6dddaff680 100644 --- a/.github/workflows/web-e2e-full-ondemand.yml +++ b/.github/workflows/web-e2e-full-ondemand.yml @@ -3,7 +3,7 @@ name: Web Full Regression on demand tests on: workflow_dispatch: schedule: - - cron: '0 4 * * 1-5' + - cron: '0 3 * * 1-5' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -11,7 +11,7 @@ concurrency: jobs: e2e: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest timeout-minutes: 40 name: Cypress Full Regression on demand tests strategy: diff --git a/.github/workflows/web-e2e-hp-ondemand.yml b/.github/workflows/web-e2e-hp-ondemand.yml index 04a549dc62..db84261be0 100644 --- a/.github/workflows/web-e2e-hp-ondemand.yml +++ b/.github/workflows/web-e2e-hp-ondemand.yml @@ -11,7 +11,7 @@ concurrency: jobs: e2e: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest timeout-minutes: 60 name: Cypress Happy path on demand tests strategy: diff --git a/.github/workflows/web-e2e-prod-ondemand.yml b/.github/workflows/web-e2e-prod-ondemand.yml index 48ea3641e7..1a8ba2b48c 100644 --- a/.github/workflows/web-e2e-prod-ondemand.yml +++ b/.github/workflows/web-e2e-prod-ondemand.yml @@ -3,7 +3,7 @@ name: Web Production health check tests on: workflow_dispatch: schedule: - - cron: '0 4 * * 1-5' + - cron: '30 4 * * 1-5' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -11,7 +11,7 @@ concurrency: jobs: e2e: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest name: Cypress production health check tests strategy: fail-fast: false diff --git a/.github/workflows/web-e2e-smoke.yml b/.github/workflows/web-e2e-smoke.yml index a5f603c198..dd8f775de8 100644 --- a/.github/workflows/web-e2e-smoke.yml +++ b/.github/workflows/web-e2e-smoke.yml @@ -4,14 +4,14 @@ on: pull_request: paths: - apps/web/** - - packages/store/** + - packages/** concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: e2e: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest timeout-minutes: 30 name: Cypress Smoke tests diff --git a/.github/workflows/web-lint.yml b/.github/workflows/web-lint.yml index 27ba8b4d69..5c75b1a1f0 100644 --- a/.github/workflows/web-lint.yml +++ b/.github/workflows/web-lint.yml @@ -3,7 +3,7 @@ on: pull_request: paths: - apps/web/** - - packages/store/** + - packages/** concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -22,7 +22,7 @@ jobs: - uses: ./.github/actions/yarn - - uses: CatChen/eslint-suggestion-action@v4.1.8 + - uses: CatChen/eslint-suggestion-action@v4.1.18 with: request-changes: true # optional fail-check: true # optional diff --git a/.github/workflows/web-nextjs-bundle-analysis.yml b/.github/workflows/web-nextjs-bundle-analysis.yml index b363393140..f20366c7a2 100644 --- a/.github/workflows/web-nextjs-bundle-analysis.yml +++ b/.github/workflows/web-nextjs-bundle-analysis.yml @@ -12,7 +12,7 @@ on: - dev paths: - apps/web/** - - packages/store/** + - packages/** permissions: contents: read # for checkout repository actions: read # for fetching base branch bundle stats @@ -43,13 +43,26 @@ jobs: name: bundle path: apps/web/.next/analyze/__bundle_analysis.json - - name: Download base branch bundle stats - if: success() && github.event.number + - name: List artifacts from default branch + id: list_artifacts run: | - cd apps/web - bash ./scripts/github/download_bundle_analyser_artifact.sh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + artifacts=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + "https://api.github.com/repos/${{ github.repository }}/actions/artifacts") + + # Find the most recent artifact (adjust filtering as needed) + artifact_id=$(echo "$artifacts" | jq '.artifacts[] | select(.expired==false) | .id' | head -n 1) + + echo "artifact_id=$artifact_id" >> $GITHUB_OUTPUT + + - name: Download artifact zip + run: | + curl -L \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -o artifact.zip \ + "https://api.github.com/repos/${{ github.repository }}/actions/artifacts/${{ steps.list_artifacts.outputs.artifact_id }}/zip" + + - name: Unzip artifact + run: unzip artifact.zip -d apps/web/.next/analyze/base && mkdir -p apps/web/.next/analyze/base/bundle && mv apps/web/.next/analyze/base/__bundle_analysis.json apps/web/.next/analyze/base/bundle/ - name: Compare with base branch bundle if: success() && github.event.number diff --git a/.github/workflows/web-tag-release.yml b/.github/workflows/web-tag-release.yml index a38b4a9029..89e1bc20f3 100644 --- a/.github/workflows/web-tag-release.yml +++ b/.github/workflows/web-tag-release.yml @@ -7,7 +7,7 @@ on: types: [closed] paths: - apps/web/** - - packages/store/** + - packages/** jobs: tag-release: @@ -38,6 +38,10 @@ jobs: prerelease: false name: ${{ steps.version.outputs.version }} tag_name: ${{ steps.version.outputs.version }} - body: ${{ github.event.pull_request.body }} + body: | + ${{ github.event.pull_request.body }} + + [🔗 IPFS release]( + https://github.com/5afe/safe-wallet-ipfs/releases/tag/${{ steps.version.outputs.version }}) env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/web-unit-tests.yml b/.github/workflows/web-unit-tests.yml index 2327e05099..8861aff788 100644 --- a/.github/workflows/web-unit-tests.yml +++ b/.github/workflows/web-unit-tests.yml @@ -9,7 +9,7 @@ on: - main paths: - apps/web/** - - packages/store/** + - packages/** concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.gitignore b/.gitignore index 56c8693b3d..05132c6fef 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ # testing /coverage +/packages/utils/coverage + +# generated types +/packages/utils/src/types/contracts # next.js **/.next/ @@ -80,4 +84,7 @@ apps/web/.env apps/web/node_modules/* apps/web/public/fallback-development.js apps/web/.env -apps/web/src/types/contracts/* \ No newline at end of file +apps/web/src/types/contracts/* + +# expo-plugins/notifications-service-ios +/expo-plugins/notification-service-ios/dist/* diff --git a/.yarn/patches/@safe-global-protocol-kit-npm-4.1.5-15df80b7bc.patch b/.yarn/patches/@safe-global-protocol-kit-npm-4.1.5-15df80b7bc.patch deleted file mode 100644 index eefbc6ebff..0000000000 --- a/.yarn/patches/@safe-global-protocol-kit-npm-4.1.5-15df80b7bc.patch +++ /dev/null @@ -1,32 +0,0 @@ -diff --git a/dist/src/utils/eip-3770/config.js b/dist/src/utils/eip-3770/config.js -index 4937a8edcfb3981d3974d2a0fa9bafdb92cde131..50f1d080dd30c5a94a996cd70d7be3083f3a6e2a 100644 ---- a/dist/src/utils/eip-3770/config.js -+++ b/dist/src/utils/eip-3770/config.js -@@ -68,7 +68,7 @@ exports.networks = [ - { chainId: 274n, shortName: 'lachain' }, - { chainId: 280n, shortName: 'zksync-goerli' }, - { chainId: 282n, shortName: 'zkTCRO' }, -- { chainId: 288n, shortName: 'boba' }, -+ { chainId: 288n, shortName: 'Boba' }, - { chainId: 291n, shortName: 'orderly' }, - { chainId: 300n, shortName: 'zksync-sepolia' }, - { chainId: 314n, shortName: 'filecoin' }, -@@ -204,7 +204,8 @@ exports.networks = [ - { chainId: 9000n, shortName: 'evmos-testnet' }, - { chainId: 9001n, shortName: 'evmos' }, - { chainId: 9700n, shortName: 'MainnetDev' }, -- { chainId: 9728n, shortName: 'boba-testnet' }, -+ { chainId: 9728n, shortName: 'BobaBnbTestnet' }, -+ { chainId: 28882n, shortName: 'BobaSepolia' }, - { chainId: 10000n, shortName: 'smartbch' }, - { chainId: 10001n, shortName: 'smartbchtest' }, - { chainId: 10081n, shortName: 'joct' }, -@@ -266,7 +267,7 @@ exports.networks = [ - { chainId: 48900n, shortName: 'zircuit-mainnet' }, - { chainId: 53457n, shortName: 'dodochain' }, - { chainId: 54211n, shortName: 'islmt' }, -- { chainId: 56288n, shortName: 'boba-bnb' }, -+ { chainId: 56288n, shortName: 'BobaBnb' }, - { chainId: 57000n, shortName: 'tsys-rollux' }, - { chainId: 57054n, shortName: 'blaze' }, - { chainId: 57073n, shortName: 'ink' }, diff --git a/.yarn/patches/@safe-global-safe-deployments-npm-1.37.28-060d6063bb.patch b/.yarn/patches/@safe-global-safe-deployments-npm-1.37.28-060d6063bb.patch deleted file mode 100644 index ba8a6d348b..0000000000 --- a/.yarn/patches/@safe-global-safe-deployments-npm-1.37.28-060d6063bb.patch +++ /dev/null @@ -1,534 +0,0 @@ -diff --git a/dist/assets/v1.3.0/compatibility_fallback_handler.json b/dist/assets/v1.3.0/compatibility_fallback_handler.json -index 41bec5974bd409c7836da62923a1b3c0c1feb7c8..6ba2b40ab46589ba5fb1b5926a151c11cfca8a92 100644 ---- a/dist/assets/v1.3.0/compatibility_fallback_handler.json -+++ b/dist/assets/v1.3.0/compatibility_fallback_handler.json -@@ -25,6 +25,7 @@ - "11": "canonical", - "12": "canonical", - "14": "canonical", -+ "28882": "eip155", - "16": "eip155", - "18": "eip155", - "19": "eip155", -diff --git a/dist/assets/v1.3.0/create_call.json b/dist/assets/v1.3.0/create_call.json -index e8b99c76bdc2e6e5f805df1e3804515686043e85..a3027d71508b9f94345976355a6c2c24ca460c29 100644 ---- a/dist/assets/v1.3.0/create_call.json -+++ b/dist/assets/v1.3.0/create_call.json -@@ -26,6 +26,7 @@ - "12": "canonical", - "14": "canonical", - "16": "eip155", -+ "28882": "eip155", - "18": "eip155", - "19": "eip155", - "25": ["eip155", "canonical"], -diff --git a/dist/assets/v1.3.0/gnosis_safe.json b/dist/assets/v1.3.0/gnosis_safe.json -index a339faf6d0ae5cfa18c9766a7382850066fbae9d..e33bff7dc0ea7df8a6e07d81364817837fc7b860 100644 ---- a/dist/assets/v1.3.0/gnosis_safe.json -+++ b/dist/assets/v1.3.0/gnosis_safe.json -@@ -27,6 +27,7 @@ - "14": "canonical", - "16": "eip155", - "18": "eip155", -+ "28882": "eip155", - "19": "eip155", - "25": ["eip155", "canonical"], - "28": "eip155", -diff --git a/dist/assets/v1.3.0/gnosis_safe_l2.json b/dist/assets/v1.3.0/gnosis_safe_l2.json -index ef975187519c15091f1408232e0253dfe1aaee8e..4cb83b86f4c96af0a810b800a6e84529ddad433a 100644 ---- a/dist/assets/v1.3.0/gnosis_safe_l2.json -+++ b/dist/assets/v1.3.0/gnosis_safe_l2.json -@@ -27,6 +27,7 @@ - "14": "canonical", - "16": "eip155", - "18": "eip155", -+ "28882": "eip155", - "19": "eip155", - "25": ["eip155", "canonical"], - "28": "eip155", -diff --git a/dist/assets/v1.3.0/multi_send.json b/dist/assets/v1.3.0/multi_send.json -index 017ae9a9572309a1b2d4f14c01f1851dc9d2db62..eabf3caa3e93233d2aa609b0edbb4a742b71ec69 100644 ---- a/dist/assets/v1.3.0/multi_send.json -+++ b/dist/assets/v1.3.0/multi_send.json -@@ -25,6 +25,7 @@ - "11": "canonical", - "12": "canonical", - "14": "canonical", -+ "28882": "eip155", - "16": "eip155", - "18": "eip155", - "19": "eip155", -diff --git a/dist/assets/v1.3.0/multi_send_call_only.json b/dist/assets/v1.3.0/multi_send_call_only.json -index d767f6b3d6139d5e301a535575accca94193d105..1125dc43021167794c95cd5ea3932f1e91051d55 100644 ---- a/dist/assets/v1.3.0/multi_send_call_only.json -+++ b/dist/assets/v1.3.0/multi_send_call_only.json -@@ -27,6 +27,7 @@ - "14": "canonical", - "16": "eip155", - "18": "eip155", -+ "28882": "eip155", - "19": "eip155", - "25": ["eip155", "canonical"], - "28": "eip155", -diff --git a/dist/assets/v1.3.0/proxy_factory.json b/dist/assets/v1.3.0/proxy_factory.json -index 9280f4df3b13280531ed79a83e4bf101b619262c..6ab0d39a5ac19bb0123f3226d1c30c0706b53831 100644 ---- a/dist/assets/v1.3.0/proxy_factory.json -+++ b/dist/assets/v1.3.0/proxy_factory.json -@@ -26,6 +26,7 @@ - "12": "canonical", - "14": "canonical", - "16": "eip155", -+ "28882": "eip155", - "18": "eip155", - "19": "eip155", - "25": ["eip155", "canonical"], -diff --git a/dist/assets/v1.3.0/sign_message_lib.json b/dist/assets/v1.3.0/sign_message_lib.json -index 9865e7d6de11a289da28636a24c115329819f0d8..f04e0c1592c11488f1c8af0902ca910ef15b1b90 100644 ---- a/dist/assets/v1.3.0/sign_message_lib.json -+++ b/dist/assets/v1.3.0/sign_message_lib.json -@@ -27,6 +27,7 @@ - "14": "canonical", - "16": "eip155", - "18": "eip155", -+ "28882": "eip155", - "19": "eip155", - "25": ["eip155", "canonical"], - "28": "eip155", -diff --git a/dist/assets/v1.3.0/simulate_tx_accessor.json b/dist/assets/v1.3.0/simulate_tx_accessor.json -index 0df16564b8713e796af65db460833635e937a9ef..ef71149c153ac636e5361ff6d871533f7aa9ae30 100644 ---- a/dist/assets/v1.3.0/simulate_tx_accessor.json -+++ b/dist/assets/v1.3.0/simulate_tx_accessor.json -@@ -25,6 +25,7 @@ - "11": "canonical", - "12": "canonical", - "14": "canonical", -+ "28882": "eip155", - "16": "eip155", - "18": "eip155", - "19": "eip155", -diff --git a/dist/assets/v1.4.1/compatibility_fallback_handler.json b/dist/assets/v1.4.1/compatibility_fallback_handler.json -index ba25416e31f5b5f83be357f18caad16e2475e590..51931a64cac5c7da3e7522e25750be7213b2b488 100644 ---- a/dist/assets/v1.4.1/compatibility_fallback_handler.json -+++ b/dist/assets/v1.4.1/compatibility_fallback_handler.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/dist/assets/v1.4.1/create_call.json b/dist/assets/v1.4.1/create_call.json -index 5678cd559d4cac9682e1e2eddc943d6aca88f727..81f0b0b9859a973bfeb8d493762c619483fc37d1 100644 ---- a/dist/assets/v1.4.1/create_call.json -+++ b/dist/assets/v1.4.1/create_call.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/dist/assets/v1.4.1/multi_send.json b/dist/assets/v1.4.1/multi_send.json -index 131ce32ff3c34e01ebdb2088fa248daf0018b515..aa409bf58c42e98b0debd477bc36a034e5497f28 100644 ---- a/dist/assets/v1.4.1/multi_send.json -+++ b/dist/assets/v1.4.1/multi_send.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/dist/assets/v1.4.1/multi_send_call_only.json b/dist/assets/v1.4.1/multi_send_call_only.json -index 55b358153ed9b7d3171829122dd10d0ce7ae667c..5f6b16554b3d5f0d6dc2fafeab49e6f42c5b8547 100644 ---- a/dist/assets/v1.4.1/multi_send_call_only.json -+++ b/dist/assets/v1.4.1/multi_send_call_only.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/dist/assets/v1.4.1/safe.json b/dist/assets/v1.4.1/safe.json -index 461716616d653c6ef7d67b0f30178dc2711d3d79..aceb8319743aa7332685ed89629d73c72a1e29c6 100644 ---- a/dist/assets/v1.4.1/safe.json -+++ b/dist/assets/v1.4.1/safe.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/dist/assets/v1.4.1/safe_l2.json b/dist/assets/v1.4.1/safe_l2.json -index 3a5e2305dbb93553bc958edc25b58d4772ff6988..664bc184b0e457613b5aaed92e1eedf162bee511 100644 ---- a/dist/assets/v1.4.1/safe_l2.json -+++ b/dist/assets/v1.4.1/safe_l2.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/dist/assets/v1.4.1/safe_migration.json b/dist/assets/v1.4.1/safe_migration.json -index afc803db9daca76bd10f6367b866591a2eb47b6b..7cfb1bb55837cd8392f7fa49043991dea29a765a 100644 ---- a/dist/assets/v1.4.1/safe_migration.json -+++ b/dist/assets/v1.4.1/safe_migration.json -@@ -14,9 +14,9 @@ - "56": "canonical", - "81": "canonical", - "88": "canonical", -- "100": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "288": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "177": "canonical", -diff --git a/dist/assets/v1.4.1/safe_proxy_factory.json b/dist/assets/v1.4.1/safe_proxy_factory.json -index 01c5f3647e95decf7f635d02005e5f2de12fa981..f98b8764159fb0ec987e1f2a6a37c5dc637aafe7 100644 ---- a/dist/assets/v1.4.1/safe_proxy_factory.json -+++ b/dist/assets/v1.4.1/safe_proxy_factory.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/dist/assets/v1.4.1/safe_to_l2_migration.json b/dist/assets/v1.4.1/safe_to_l2_migration.json -index 16e005a56912c8f8902cada08eb9424a29308b9d..6a14df70dd6d22b4ba5b84f4fe3dacd5ba29fe42 100644 ---- a/dist/assets/v1.4.1/safe_to_l2_migration.json -+++ b/dist/assets/v1.4.1/safe_to_l2_migration.json -@@ -14,9 +14,9 @@ - "56": "canonical", - "81": "canonical", - "88": "canonical", -- "100": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "288": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "177": "canonical", -diff --git a/dist/assets/v1.4.1/safe_to_l2_setup.json b/dist/assets/v1.4.1/safe_to_l2_setup.json -index 822cf1f56eb0c14c9d86bfb83ae05cdf1431a9ab..dd613bc84e6a0c6465a0115790b6b9ea864edeb8 100644 ---- a/dist/assets/v1.4.1/safe_to_l2_setup.json -+++ b/dist/assets/v1.4.1/safe_to_l2_setup.json -@@ -14,9 +14,9 @@ - "56": "canonical", - "81": "canonical", - "88": "canonical", -- "100": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "288": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "177": "canonical", -diff --git a/dist/assets/v1.4.1/sign_message_lib.json b/dist/assets/v1.4.1/sign_message_lib.json -index 04967be57ec7b535d15ca17a992d6a14c54a404f..a0c000d795bab77ca2b3667ece0a77ea5b726f4b 100644 ---- a/dist/assets/v1.4.1/sign_message_lib.json -+++ b/dist/assets/v1.4.1/sign_message_lib.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/dist/assets/v1.4.1/simulate_tx_accessor.json b/dist/assets/v1.4.1/simulate_tx_accessor.json -index b2d8d2d1eb648b98fe72cc853acd8b9e7ddb6638..4a5a74886ecbf5744be14a76e86435d280bef260 100644 ---- a/dist/assets/v1.4.1/simulate_tx_accessor.json -+++ b/dist/assets/v1.4.1/simulate_tx_accessor.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/src/assets/v1.4.1/compatibility_fallback_handler.json b/src/assets/v1.4.1/compatibility_fallback_handler.json -index 10ed5da6f9f6a8e8d1da12af6d1f57a255d96785..34dc325438cdd4b6436832c5247b029c85cbdcde 100644 ---- a/src/assets/v1.4.1/compatibility_fallback_handler.json -+++ b/src/assets/v1.4.1/compatibility_fallback_handler.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/src/assets/v1.4.1/create_call.json b/src/assets/v1.4.1/create_call.json -index 0751bb8b834707c070bfc90d066f93c04890f865..b082450040d4f8a416670dde4c719fedff85ba3e 100644 ---- a/src/assets/v1.4.1/create_call.json -+++ b/src/assets/v1.4.1/create_call.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/src/assets/v1.4.1/multi_send.json b/src/assets/v1.4.1/multi_send.json -index 4f96d01f1a7823a1102ae7bdb7e9c08f2aabeef9..578548d3b0e1f930a796f458284413f20c576097 100644 ---- a/src/assets/v1.4.1/multi_send.json -+++ b/src/assets/v1.4.1/multi_send.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/src/assets/v1.4.1/multi_send_call_only.json b/src/assets/v1.4.1/multi_send_call_only.json -index 0873ee03ed5de186cc4c87b2d9143a436a4fb465..6e16987aba0cb6b2ebdffdc9bb3f5cac042a6be3 100644 ---- a/src/assets/v1.4.1/multi_send_call_only.json -+++ b/src/assets/v1.4.1/multi_send_call_only.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/src/assets/v1.4.1/safe.json b/src/assets/v1.4.1/safe.json -index e706fcc799897f73fce3abf009225377d1e45905..514bae82f700d72b7a14c94b05f97cc9891d3a37 100644 ---- a/src/assets/v1.4.1/safe.json -+++ b/src/assets/v1.4.1/safe.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/src/assets/v1.4.1/safe_l2.json b/src/assets/v1.4.1/safe_l2.json -index a4e33ec6c7f50f17055bffddecc62a9de5e9d623..dc848bbe7395a023428a22f44852eb3f61d42a85 100644 ---- a/src/assets/v1.4.1/safe_l2.json -+++ b/src/assets/v1.4.1/safe_l2.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/src/assets/v1.4.1/safe_migration.json b/src/assets/v1.4.1/safe_migration.json -index 3ef9c341e9b20fb4ef3b86edc95d1e4af89e5b46..4b8c26a2d0be9a541242eca9f0cdaf5e8c2451a6 100644 ---- a/src/assets/v1.4.1/safe_migration.json -+++ b/src/assets/v1.4.1/safe_migration.json -@@ -14,9 +14,9 @@ - "56": "canonical", - "81": "canonical", - "88": "canonical", -- "100": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "288": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "177": "canonical", -diff --git a/src/assets/v1.4.1/safe_proxy_factory.json b/src/assets/v1.4.1/safe_proxy_factory.json -index 47b8086de6cc7e086e8e81fef41cc221f6d2394f..88b5fd973b9aac266ef1073c3c01afdb337d139d 100644 ---- a/src/assets/v1.4.1/safe_proxy_factory.json -+++ b/src/assets/v1.4.1/safe_proxy_factory.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/src/assets/v1.4.1/safe_to_l2_migration.json b/src/assets/v1.4.1/safe_to_l2_migration.json -index 4cd78710bea0d158f86700c731eac2744a5b5c2f..d5828acd07a5dce99771ae8e2ac5574b90d9691a 100644 ---- a/src/assets/v1.4.1/safe_to_l2_migration.json -+++ b/src/assets/v1.4.1/safe_to_l2_migration.json -@@ -14,9 +14,9 @@ - "56": "canonical", - "81": "canonical", - "88": "canonical", -- "100": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "288": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "177": "canonical", -diff --git a/src/assets/v1.4.1/safe_to_l2_setup.json b/src/assets/v1.4.1/safe_to_l2_setup.json -index baaad9b41e6642364e4aeffec37181ebdce3d588..3a876ec823dc37bf8e1efc0565c68d453e55bdb2 100644 ---- a/src/assets/v1.4.1/safe_to_l2_setup.json -+++ b/src/assets/v1.4.1/safe_to_l2_setup.json -@@ -14,9 +14,9 @@ - "56": "canonical", - "81": "canonical", - "88": "canonical", -- "100": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "288": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "177": "canonical", -diff --git a/src/assets/v1.4.1/sign_message_lib.json b/src/assets/v1.4.1/sign_message_lib.json -index 893459f717add65976c13a05353e3ef6a4ffd963..d9a153ea838fba91ff3f5570b3c66a5817a6fd3d 100644 ---- a/src/assets/v1.4.1/sign_message_lib.json -+++ b/src/assets/v1.4.1/sign_message_lib.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", -diff --git a/src/assets/v1.4.1/simulate_tx_accessor.json b/src/assets/v1.4.1/simulate_tx_accessor.json -index 6dfa430ab1714bfe2cdded1940d1d98417931b31..cb9e39a2835a19b28961c262be5768f858dbb722 100644 ---- a/src/assets/v1.4.1/simulate_tx_accessor.json -+++ b/src/assets/v1.4.1/simulate_tx_accessor.json -@@ -24,10 +24,10 @@ - "81": "canonical", - "88": "canonical", - "97": "canonical", -- "100": "canonical", -+ "288": "canonical", - "114": "canonical", -- "130": "canonical", -- "133": "canonical", -+ "56288": "canonical", -+ "28882": "canonical", - "137": "canonical", - "146": "canonical", - "155": "canonical", diff --git a/.yarn/patches/@safe-global-safe-deployments-npm-1.37.40-a424a8f650.patch b/.yarn/patches/@safe-global-safe-deployments-npm-1.37.40-a424a8f650.patch new file mode 100644 index 0000000000..9e190bba3a --- /dev/null +++ b/.yarn/patches/@safe-global-safe-deployments-npm-1.37.40-a424a8f650.patch @@ -0,0 +1,312 @@ +diff --git a/dist/assets/v1.4.1/compatibility_fallback_handler.json b/dist/assets/v1.4.1/compatibility_fallback_handler.json +index 63d5b2f5793e30c51da2138f47c328a0ad57aa3c..4131e6f8afbe3d915dd399c6800ab32aa6443f8f 100644 +--- a/dist/assets/v1.4.1/compatibility_fallback_handler.json ++++ b/dist/assets/v1.4.1/compatibility_fallback_handler.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/dist/assets/v1.4.1/create_call.json b/dist/assets/v1.4.1/create_call.json +index f11fc9af8dd9308c1116be33dc43407aa0c37330..f9b40a8213274f5fa700133ed8506877e52e2841 100644 +--- a/dist/assets/v1.4.1/create_call.json ++++ b/dist/assets/v1.4.1/create_call.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/dist/assets/v1.4.1/multi_send.json b/dist/assets/v1.4.1/multi_send.json +index 1e94419c20d0e90bd1b6872aaad37fdabfa04fcb..846210cc79dc07b26f5da673c5d0e7e04853dc4d 100644 +--- a/dist/assets/v1.4.1/multi_send.json ++++ b/dist/assets/v1.4.1/multi_send.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/dist/assets/v1.4.1/multi_send_call_only.json b/dist/assets/v1.4.1/multi_send_call_only.json +index 2e14f29ebb1ef7818791be6d618403d46197e9cd..75b8a0d891d38ba73f2cbc7264c51547f437b4c3 100644 +--- a/dist/assets/v1.4.1/multi_send_call_only.json ++++ b/dist/assets/v1.4.1/multi_send_call_only.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/dist/assets/v1.4.1/safe.json b/dist/assets/v1.4.1/safe.json +index 206eaca1f78b0c3e36f25c29e09348b188ecf2e4..7a5fab20982fa6015789a193686bf8618cd9421d 100644 +--- a/dist/assets/v1.4.1/safe.json ++++ b/dist/assets/v1.4.1/safe.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/dist/assets/v1.4.1/safe_l2.json b/dist/assets/v1.4.1/safe_l2.json +index 3c349b476b376fbbfd2cfc6836715e6019c87546..345793529e73985f8dff866a2dde0d71ee0ee6cd 100644 +--- a/dist/assets/v1.4.1/safe_l2.json ++++ b/dist/assets/v1.4.1/safe_l2.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/dist/assets/v1.4.1/safe_migration.json b/dist/assets/v1.4.1/safe_migration.json +index ae7d18e8c98df9aacc15126eda3ff6e43a558dcc..fb0479ea82eb9c106268856f59705993b0fb0d9d 100644 +--- a/dist/assets/v1.4.1/safe_migration.json ++++ b/dist/assets/v1.4.1/safe_migration.json +@@ -14,7 +14,7 @@ + }, + "networkAddresses": { + "1": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "30": "canonical", + "31": "canonical", +diff --git a/dist/assets/v1.4.1/safe_proxy_factory.json b/dist/assets/v1.4.1/safe_proxy_factory.json +index 9e1438982037dfcd617c9660b48482dfbd873a78..014669a118cecba13d80f081cd1f71de2f5ad101 100644 +--- a/dist/assets/v1.4.1/safe_proxy_factory.json ++++ b/dist/assets/v1.4.1/safe_proxy_factory.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/dist/assets/v1.4.1/safe_to_l2_migration.json b/dist/assets/v1.4.1/safe_to_l2_migration.json +index bc9cc48e607ace40d7ad547156821e0aa144d7bf..7f9ec39954a9b8d8b03903a16051ee1a9262297d 100644 +--- a/dist/assets/v1.4.1/safe_to_l2_migration.json ++++ b/dist/assets/v1.4.1/safe_to_l2_migration.json +@@ -14,7 +14,7 @@ + }, + "networkAddresses": { + "1": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "30": "canonical", + "31": "canonical", +diff --git a/dist/assets/v1.4.1/safe_to_l2_setup.json b/dist/assets/v1.4.1/safe_to_l2_setup.json +index 051297ed887ef6d061f7f811fba3a9c18f980c0f..365212abf3c947fe14388395b2c865970f3a50ac 100644 +--- a/dist/assets/v1.4.1/safe_to_l2_setup.json ++++ b/dist/assets/v1.4.1/safe_to_l2_setup.json +@@ -14,7 +14,7 @@ + }, + "networkAddresses": { + "1": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "30": "canonical", + "31": "canonical", +diff --git a/dist/assets/v1.4.1/sign_message_lib.json b/dist/assets/v1.4.1/sign_message_lib.json +index 82e0d0b46d3e4f7b0658bcc6483943d6e31c7698..0400f7cd9c372bcd0d9f9a1f319acd4edd727687 100644 +--- a/dist/assets/v1.4.1/sign_message_lib.json ++++ b/dist/assets/v1.4.1/sign_message_lib.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/dist/assets/v1.4.1/simulate_tx_accessor.json b/dist/assets/v1.4.1/simulate_tx_accessor.json +index 352d93b4693d1488f7cd15e1583489a031551953..0299100e942a2b9e98ed0fad22a6a74d71f26c54 100644 +--- a/dist/assets/v1.4.1/simulate_tx_accessor.json ++++ b/dist/assets/v1.4.1/simulate_tx_accessor.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/src/assets/v1.4.1/compatibility_fallback_handler.json b/src/assets/v1.4.1/compatibility_fallback_handler.json +index 861a72984f571838b63981c82f11e981b5a37ae7..f0d910743f65fdcc923e2babce2baae290307097 100644 +--- a/src/assets/v1.4.1/compatibility_fallback_handler.json ++++ b/src/assets/v1.4.1/compatibility_fallback_handler.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/src/assets/v1.4.1/create_call.json b/src/assets/v1.4.1/create_call.json +index 3bfd9c88cc215d7155a090d0b6106de0486b566d..7f6b22ac330047fe835cf3aad6bc0e6fede285c2 100644 +--- a/src/assets/v1.4.1/create_call.json ++++ b/src/assets/v1.4.1/create_call.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/src/assets/v1.4.1/multi_send.json b/src/assets/v1.4.1/multi_send.json +index 9b1bc7f7ae3da369c8718f88f20ebbe666eaac89..52d5259370c934a219d0e9a3ceb55eecf8a9f11b 100644 +--- a/src/assets/v1.4.1/multi_send.json ++++ b/src/assets/v1.4.1/multi_send.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/src/assets/v1.4.1/multi_send_call_only.json b/src/assets/v1.4.1/multi_send_call_only.json +index 0a63c2a5c2802caa502edacd2e8e8a7b5c9648a7..ae9b0716b62e7957fd92111c52858b6daf5b6d3f 100644 +--- a/src/assets/v1.4.1/multi_send_call_only.json ++++ b/src/assets/v1.4.1/multi_send_call_only.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/src/assets/v1.4.1/safe.json b/src/assets/v1.4.1/safe.json +index d97401b59d9076a08a6681dee9594f2f379ecde0..23d50abe5c7f445fb907e31462ba791c6aca0c28 100644 +--- a/src/assets/v1.4.1/safe.json ++++ b/src/assets/v1.4.1/safe.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/src/assets/v1.4.1/safe_l2.json b/src/assets/v1.4.1/safe_l2.json +index 8ed97ba60898b568858841cc018d8f0cd128d78e..fdcffc159208ea30de268e04b568ddbeb09c6841 100644 +--- a/src/assets/v1.4.1/safe_l2.json ++++ b/src/assets/v1.4.1/safe_l2.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/src/assets/v1.4.1/safe_migration.json b/src/assets/v1.4.1/safe_migration.json +index 0fb624058a6eca21d8f8dc91c3e048423b5d1fba..1a15b52d2d3e3e22b36e96b88d35be26c9fc881e 100644 +--- a/src/assets/v1.4.1/safe_migration.json ++++ b/src/assets/v1.4.1/safe_migration.json +@@ -14,7 +14,7 @@ + }, + "networkAddresses": { + "1": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "30": "canonical", + "31": "canonical", +diff --git a/src/assets/v1.4.1/safe_proxy_factory.json b/src/assets/v1.4.1/safe_proxy_factory.json +index 2d7792094f00319a88e20dca7a2596be27f540f4..5855a2fc1f6fbff5a053bb58fb6ff56e6f4df527 100644 +--- a/src/assets/v1.4.1/safe_proxy_factory.json ++++ b/src/assets/v1.4.1/safe_proxy_factory.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/src/assets/v1.4.1/safe_to_l2_migration.json b/src/assets/v1.4.1/safe_to_l2_migration.json +index d32db2eacab0c16f1251ac881f4caf470856b3b0..b8bce002677bdcc0c58ad7740d946d7faaf8e6e6 100644 +--- a/src/assets/v1.4.1/safe_to_l2_migration.json ++++ b/src/assets/v1.4.1/safe_to_l2_migration.json +@@ -14,7 +14,7 @@ + }, + "networkAddresses": { + "1": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "30": "canonical", + "31": "canonical", +diff --git a/src/assets/v1.4.1/safe_to_l2_setup.json b/src/assets/v1.4.1/safe_to_l2_setup.json +index 0c4d1b92c2e1d1834230d2151e3e7f87c963c76b..7a59ca11484c497e79dc3c510f181c75011aa647 100644 +--- a/src/assets/v1.4.1/safe_to_l2_setup.json ++++ b/src/assets/v1.4.1/safe_to_l2_setup.json +@@ -14,7 +14,7 @@ + }, + "networkAddresses": { + "1": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "30": "canonical", + "31": "canonical", +diff --git a/src/assets/v1.4.1/sign_message_lib.json b/src/assets/v1.4.1/sign_message_lib.json +index 16aaab218f03d2db3735c2757d9f4deb14e6d254..6ad6cc8c66b9119855d1b6a665193d97ecf6d9e8 100644 +--- a/src/assets/v1.4.1/sign_message_lib.json ++++ b/src/assets/v1.4.1/sign_message_lib.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", +diff --git a/src/assets/v1.4.1/simulate_tx_accessor.json b/src/assets/v1.4.1/simulate_tx_accessor.json +index 3b37dda3f36d90fee6f85b692ace34e04dbdcd15..1845593b41877797a07bcd69dd10c7cdc2eddeb3 100644 +--- a/src/assets/v1.4.1/simulate_tx_accessor.json ++++ b/src/assets/v1.4.1/simulate_tx_accessor.json +@@ -15,7 +15,7 @@ + "networkAddresses": { + "1": "canonical", + "5": "canonical", +- "10": "canonical", ++ "56288": "canonical", + "14": "canonical", + "16": "canonical", + "19": "canonical", diff --git a/.yarn/patches/@safe-global-safe-modules-deployments-npm-2.2.12-c30bb15dee.patch b/.yarn/patches/@safe-global-safe-modules-deployments-npm-2.2.12-c30bb15dee.patch new file mode 100644 index 0000000000..2feaef21e8 --- /dev/null +++ b/.yarn/patches/@safe-global-safe-modules-deployments-npm-2.2.12-c30bb15dee.patch @@ -0,0 +1,32 @@ +diff --git a/dist/assets/allowance-module/v0.1.1/allowance-module.json b/dist/assets/allowance-module/v0.1.1/allowance-module.json +index e0b2582658538534fffbda770fa97d714f3e7436..7dfa14d6328f0bddfbd5ea2eadbbf8f37e6d3cd6 100644 +--- a/dist/assets/allowance-module/v0.1.1/allowance-module.json ++++ b/dist/assets/allowance-module/v0.1.1/allowance-module.json +@@ -7,8 +7,11 @@ + "130": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "137": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "143": "0xAA46724893dedD72658219405185Fb0Fc91e091C", ++ "288": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "3338": "0xAA46724893dedD72658219405185Fb0Fc91e091C", ++ "28882": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "43111": "0xAA46724893dedD72658219405185Fb0Fc91e091C", ++ "56288": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "80094": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "81224": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "747474": "0xAA46724893dedD72658219405185Fb0Fc91e091C" +diff --git a/src/assets/allowance-module/v0.1.1/allowance-module.json b/src/assets/allowance-module/v0.1.1/allowance-module.json +index b65e505c1b0407e2288d4f0c8726b70603aa6178..f23dd507dc238feb217358c4b9e6883a7f15ab4b 100644 +--- a/src/assets/allowance-module/v0.1.1/allowance-module.json ++++ b/src/assets/allowance-module/v0.1.1/allowance-module.json +@@ -7,8 +7,11 @@ + "130": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "137": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "143": "0xAA46724893dedD72658219405185Fb0Fc91e091C", ++ "288": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "3338": "0xAA46724893dedD72658219405185Fb0Fc91e091C", ++ "28882": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "43111": "0xAA46724893dedD72658219405185Fb0Fc91e091C", ++ "56288": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "80094": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "81224": "0xAA46724893dedD72658219405185Fb0Fc91e091C", + "747474": "0xAA46724893dedD72658219405185Fb0Fc91e091C" diff --git a/.yarn/patches/next-npm-15.2.4-06a6671f62.patch b/.yarn/patches/next-npm-15.2.4-06a6671f62.patch new file mode 100644 index 0000000000..ecbef7d668 --- /dev/null +++ b/.yarn/patches/next-npm-15.2.4-06a6671f62.patch @@ -0,0 +1,17 @@ +diff --git a/dist/client/route-loader.js b/dist/client/route-loader.js +index 888fc23e628cd87fbbf60742aeaaea37b9703f6e..93cc6e0fd3316445dbb55ac618dd9421aeeae19c 100644 +--- a/dist/client/route-loader.js ++++ b/dist/client/route-loader.js +@@ -124,6 +124,12 @@ function appendScript(src, script) { + // 3. Finally, set the source and inject into the DOM in case the child + // must be appended for fetching to start. + script.src = src; ++ ++ // Set integrity if we have a matching hash ++ const hashManifest = window.__CHUNK_SRI_MANIFEST || {}; ++ if (hashManifest[src]) { ++ script.integrity = hashManifest[src]; ++ } + document.body.appendChild(script); + }); + } diff --git a/.yarn/patches/react-native-device-crypto-npm-0.1.7-dbd2698fc4.patch b/.yarn/patches/react-native-device-crypto-npm-0.1.7-dbd2698fc4.patch new file mode 100644 index 0000000000..309ba323b8 --- /dev/null +++ b/.yarn/patches/react-native-device-crypto-npm-0.1.7-dbd2698fc4.patch @@ -0,0 +1,90 @@ +diff --git a/android/src/main/java/com/reactnativedevicecrypto/Helpers.java b/android/src/main/java/com/reactnativedevicecrypto/Helpers.java +index 03fd79e8a389f33a2841ecf0e177ffc9ea01b78c..681f07efab020e078ee75d697434a1d3ce414f32 100644 +--- a/android/src/main/java/com/reactnativedevicecrypto/Helpers.java ++++ b/android/src/main/java/com/reactnativedevicecrypto/Helpers.java +@@ -39,6 +39,7 @@ public class Helpers { + private static final int AES_IV_SIZE = 128; + public static final String PEM_HEADER = "-----BEGIN PUBLIC KEY-----\n"; + public static final String PEM_FOOTER = "-----END PUBLIC KEY-----"; ++ private static Boolean sIsStrongBoxSupported = null; // Cache for StrongBox support + + public interface KeyType { + @Retention(SOURCE) +@@ -81,6 +82,64 @@ public class Helpers { + } + } + ++ public static boolean isStrongBoxSupported() { ++ // Return cached result if available ++ if (sIsStrongBoxSupported != null) { ++ return sIsStrongBoxSupported; ++ } ++ ++ // Try to create a key with StrongBox requirement and see if it succeeds ++ String testAlias = "test_strongbox_support"; ++ try { ++ // First, clean up any existing test key ++ try { ++ KeyStore keyStore = getKeyStore(); ++ if (keyStore.containsAlias(testAlias)) { ++ keyStore.deleteEntry(testAlias); ++ } ++ } catch (Exception e) { ++ // Ignore errors during cleanup ++ } ++ ++ // Attempt to create a key with StrongBox backing ++ KeyGenParameterSpec testSpec = new KeyGenParameterSpec.Builder(testAlias, KeyProperties.PURPOSE_ENCRYPT) ++ .setIsStrongBoxBacked(true) ++ .setBlockModes(KeyProperties.BLOCK_MODE_GCM) ++ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) ++ .setKeySize(256) ++ .build(); ++ KeyGenerator testKeyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEY_STORE); ++ testKeyGen.init(testSpec); ++ testKeyGen.generateKey(); ++ ++ // If we reach here, StrongBox is supported ++ // Clean up the test key ++ KeyStore keyStore = getKeyStore(); ++ keyStore.deleteEntry(testAlias); ++ ++ // Cache the result ++ sIsStrongBoxSupported = true; ++ Log.i(RN_MODULE, "StrongBox is supported on this device"); ++ return true; ++ } catch (Exception e) { ++ Log.i(RN_MODULE, "StrongBox not supported on this device: " + e.getMessage()); ++ ++ // Clean up any test key that might have been created ++ try { ++ KeyStore keyStore = getKeyStore(); ++ if (keyStore.containsAlias(testAlias)) { ++ keyStore.deleteEntry(testAlias); ++ } ++ } catch (Exception cleanupEx) { ++ // Ignore errors during cleanup ++ } ++ ++ // Cache the result ++ sIsStrongBoxSupported = false; ++ return false; ++ } ++ } ++ + public static boolean isKeyExists(@NonNull String alias, @KeyType.Types int keyType) throws Exception { + KeyStore keyStore = Helpers.getKeyStore(); + if (!keyStore.containsAlias(alias)) { +@@ -143,8 +202,10 @@ public class Helpers { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setInvalidatedByBiometricEnrollment(invalidateOnNewBiometry); + } +- if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) { +- builder.setIsStrongBoxBacked(true); ++ // Only try to enable StrongBox if API level is high enough and the device ++ // actually supports it ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && isStrongBoxSupported()) { ++ builder.setIsStrongBoxBacked(true); + } + break; + } diff --git a/.yarn/patches/react-native-qrcode-styled-npm-0.3.3-b5336fc77c.patch b/.yarn/patches/react-native-qrcode-styled-npm-0.3.3-b5336fc77c.patch new file mode 100644 index 0000000000..9f29407582 --- /dev/null +++ b/.yarn/patches/react-native-qrcode-styled-npm-0.3.3-b5336fc77c.patch @@ -0,0 +1,12 @@ +diff --git a/package.json b/package.json +index 228948278bbbd33e0637fb59397e4f4751efdf1b..37e8f4de7600b39c8fbc409f1095be2228a030dc 100644 +--- a/package.json ++++ b/package.json +@@ -5,6 +5,7 @@ + "main": "lib/commonjs/index.js", + "module": "lib/module/index.js", + "react-native": "src/index.tsx", ++ "types": "lib/typescript/module/src/index.d.ts", + "source": "src/index", + "files": [ + "src", diff --git a/.yarnrc.yml b/.yarnrc.yml index e6ba598dee..ebe9d2d332 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -4,24 +4,27 @@ enableGlobalCache: true nodeLinker: node-modules +enableScripts: false +enableConstraintsChecks: true + packageExtensions: # fixing https://github.com/vercel/next.js/issues/38939 eslint-config-next@*: dependencies: - next: "*" + next: '*' # the following changes are necessary because tsc was loading # the wrong version of @types/react (it was taking 18 from main # instead of 19 from the web package) & linting was failing # START FIX FOR TSC - "@cowprotocol/widget-react@*": + '@cowprotocol/widget-react@*': peerDependencies: - react: "^19.0.0" - "@types/react": "^19.0.0" + react: '^19.0.0' + '@types/react': '^19.0.0' react-papaparse@*: peerDependencies: - react: "^19.0.0" - "@storybook/core@*": + react: '^19.0.0' + '@storybook/core@*': dependencies: - react: "^19.0.0" - "@types/react": "^19.0.0" + react: '^19.0.0' + '@types/react': '^19.0.0' # END FIX FOR TSC diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..63dcd70fb4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# AI Contributor Guidelines + +This repository uses a Yarn-based monorepo structure. Follow these rules when proposing changes via an AI agent. + +## General Principles + +- **Solve the Right Problem** – confirm the purpose of your change with the issue or request before modifying code. +- **Design Before You Code** – think through data flow, APIs and security implications before implementing. +- **Keep It Simple** – prefer straightforward solutions over clever or unnecessary abstractions. +- **Fail Fast, Fail Loud** – surface errors early; avoid silent failures. +- **Write Clean, Readable Code** – meaningful names and concise comments help maintainers understand your work. +- **Test Thoroughly** – add or update unit and integration tests when changing functionality. +- **Minimize Technical Debt** – document trade‑offs and avoid quick hacks. +- **Prioritize Maintainability** – keep modules small and reusable. + +## Workflow + +1. **Install dependencies**: `yarn install` (from the repository root). +2. **Formatting**: run `yarn prettier:fix` before committing. +3. **Linting and tests**: when you change any source code under `apps/` or `packages/`, execute: + ```bash + yarn lint + yarn test + ``` + Skip these steps only if your changes are limited to comments or documentation. +4. **Commit messages**: use [semantic commit messages](https://www.conventionalcommits.org/en/v1.0.0/) as described in `CONTRIBUTING.md`. +5. **Code style**: follow the guidelines in: + - `apps/web/docs/code-style.md` for the web app. + - `apps/mobile/docs/code-style.md` for the mobile app. +6. **Pull requests**: fill out the PR template and ensure all checks pass. + +Use Yarn 4 (managed via `corepack`) for all scripts. Refer to the workspace READMEs for environment details. + +**Environment Variables** – Web apps use `NEXT_PUBLIC_*` prefix, mobile apps use `EXPO_PUBLIC_*` prefix for environment variables. + +## Testing Guidelines + +- When writing Redux tests, verify resulting state changes rather than checking + that specific actions were dispatched. +- Use [Mock Service Worker](https://mswjs.io/) (MSW) for tests involving network + requests instead of mocking `fetch`. Use MSW for mocking blockchain RPC calls instead of mocking ethers.js directly +- Create test data with helpers with faker @https://fakerjs.dev/ +- Ensure shared package tests work for both web and mobile environments + +## Web3/Blockchain Development Guidelines + +- **Safe Ecosystem Focus** – This is a Safe wallet project. Understand Safe's multi-signature concepts and the Safe{Core} SDK when making changes. +- **Chain Support** – The app supports multiple EVM chains (Ethereum, Polygon, Arbitrum, etc.). Always consider multi-chain implications. +- **Ethers.js Usage** – Use ethers v6. Follow existing patterns for provider creation and RPC management. + +## Mobile Development (Expo + Tamagui) + +- **UI Components** – Use Tamagui components for styling and theming. Import from `tamagui` not React Native directly when possible. +- **Theme System** – Follow the custom theme configuration in `src/theme/tamagui.config.ts`. Use theme tokens like `$background`, `$primary`, etc. +- **Component Structure** – Follow container/presentation pattern. See `apps/mobile/docs/code-style.md` for detailed component organization. +- **Font Management** – Use the configured DM Sans font family. Custom icons go through `SafeFontIcon` component. +- **Expo Plugins** – Custom Expo config plugins are in the `expo-plugins/` directory. + +## Shared Packages + +- **Cross-Platform Code** – Shared logic goes in `packages/` directory. Consider both web and mobile when making changes. +- **Environment Handling** – Use dual environment variable patterns (`NEXT_PUBLIC_*` || `EXPO_PUBLIC_*`) in shared packages. +- **Store Management** – Redux store is shared between web and mobile. State changes should work for both platforms. + +## Security & Safe Wallet Patterns + +- **Safe Address Validation** – Always validate Ethereum addresses using established utilities. +- **Transaction Building** – Follow Safe SDK patterns for building multi-signature transactions. +- **Wallet Provider Integration** – Follow established patterns for wallet connection and Web3 provider setup. + +## Environment Configuration + +- **Local Development** – Points to staging backend by default +- **Environment Branches** – PRs get deployed automatically for testing +- **RPC Configuration** – Infura integration for Web3 RPC calls (requires `INFURA_TOKEN`) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc63948e10..9ca670613d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,16 @@ # Contributing ### tl;dr -* Pull requests are very welcome -* Check [**good first issues**](https://github.com/safe-global/safe-wallet-monorepo/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) if you want to contribute but don't know what -* We do NOT accept tiny text edits, typo fixes etc, which is typically bot activity/airdrop farming -* Each pull requests is rewarded with a [GitPOAP](https://www.gitpoap.io/gh/safe-global/safe-wallet-monorepo) but this does NOT guarantee any other perks + +- Pull requests are very welcome +- Check [**good first issues**](https://github.com/safe-global/safe-wallet-monorepo/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) if you want to contribute but don't know what +- We do NOT accept tiny text edits, typo fixes etc, which is typically bot activity/airdrop farming +- Each pull requests is rewarded with a [GitPOAP](https://www.gitpoap.io/gh/safe-global/safe-wallet-monorepo) but this does NOT guarantee any other perks ## Code Style -More information [here](./docs/code-style.md). +The packages inside the repo try to follow the same code style, but there might be small differences. Check the `code-style.md` +file in the package you are working on for specific guidelines. We use [semantic commits](https://www.conventionalcommits.org/en/v1.0.0/) for pull request titles and commit messages. ## CLA @@ -20,7 +22,7 @@ It is a requirement for all contributors to sign the [Contributor License Agreem - When opening a pull request, please make sure to fully fill out the pull request template that will appear in the description text box. - Make sure to cover your changes with unit tests. - Automatic linting and tests should pass once the workflows are approved by a maintainer. -- Please follow our [Code Style Guidelines](docs/code-style.md). +- Please follow our Code Style Guidelines for the package you are working on. ## Code of Conduct diff --git a/apps/mobile/.eas/build/build-and-maestro-test.yml b/apps/mobile/.eas/build/build-and-maestro-test.yml index 0ec7551af4..e7b8231deb 100644 --- a/apps/mobile/.eas/build/build-and-maestro-test.yml +++ b/apps/mobile/.eas/build/build-and-maestro-test.yml @@ -35,4 +35,5 @@ build: - eas/maestro_test: inputs: flow_path: | - e2e/flow.yml + e2e/onboarding.yml + e2e/onboarded-user.yml diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 0000000000..f9df2128f4 --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,12 @@ +# Used on the javascript side +EXPO_PUBLIC_APP_VARIANT=production +EXPO_PUBLIC_INFURA_TOKEN=**** +EXPO_PUBLIC_SECURITY_RASP_ENABLED=false +EXPO_PUBLIC_SECURITY_WATCHER_MAIL= + +# Used during the build process +APP_VARIANT=production +GOOGLE_SERVICES_PLIST_DEV=./GoogleService-Info-Dev.plist +GOOGLE_SERVICES_JSON=./google-services.json +GOOGLE_SERVICES_JSON_DEV=./google-services-dev.json +GOOGLE_SERVICES_PLIST=./GoogleService-Info.plist diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index 5b3c4fb198..ec6367cada 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -19,7 +19,10 @@ coverage /android/* /ios/* google-services.json +google-services-dev.json GoogleService-Info.plist +GoogleService-Info-Dev.plist + # @generated expo-cli sync-8d4afeec25ea8a192358fae2f8e2fc766bdce4ec # The following patterns were generated by expo-cli @@ -58,6 +61,8 @@ yarn-error.* # local env files .env*.local +.env.eas + # typescript *.tsbuildinfo diff --git a/apps/mobile/.storybook/main.ts b/apps/mobile/.storybook/main.ts index 48923c9f74..0675826e3a 100644 --- a/apps/mobile/.storybook/main.ts +++ b/apps/mobile/.storybook/main.ts @@ -1,15 +1,33 @@ import type { StorybookConfig as WebStorybookConfig } from '@storybook/react-webpack5' import type { StorybookConfig as RNStorybookConfig } from '@storybook/react-native' - -const isWeb = process.env.STORYBOOK_WEB import path from 'path' import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin' +import { globSync } from 'glob' let config: WebStorybookConfig | RNStorybookConfig +const isWeb = process.env.STORYBOOK_WEB + +const appDirectory = path.resolve(__dirname, '../') if (isWeb) { + /** + * We have some stories that require native modules, and they don't have + * any equivalents in web. If we have such a story, we need to ignore it + * otherwise webpack will fail to compile. + * + * https://github.com/storybookjs/storybook/issues/11181 + */ + const getStories = () => { + return [ + ...globSync(`${appDirectory}/src/**/*.mdx)`), + ...globSync(`${appDirectory}/src/**/*.stories.@(js|jsx|ts|tsx|mdx)`, { + ignore: `${appDirectory}/src/**/*.native.stories.@(js|jsx|ts|tsx|mdx)`, + }), + ] + } + config = { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + stories: [...getStories()], addons: [ '@storybook/addon-essentials', '@storybook/addon-interactions', @@ -22,10 +40,14 @@ if (isWeb) { }, '@storybook/addon-webpack5-compiler-babel', ], - framework: { - name: '@storybook/react-webpack5', - options: {}, - }, + /** + * In our monorepo setup, if we just specify the name, + * we end up with the wrong path to webpack5 preset. We need to + * resolve the path: + * + * https://github.com/storybookjs/storybook/issues/21216#issuecomment-2187481646 + */ + framework: path.resolve(require.resolve('@storybook/react-webpack5/preset'), '..'), webpackFinal: async (config) => { if (config.resolve) { config.resolve.plugins = [ diff --git a/apps/mobile/.storybook/preview.tsx b/apps/mobile/.storybook/preview.tsx index 86f9a42021..0a85591ad1 100644 --- a/apps/mobile/.storybook/preview.tsx +++ b/apps/mobile/.storybook/preview.tsx @@ -1,10 +1,9 @@ import type { Preview } from '@storybook/react' import { NavigationIndependentTree } from '@react-navigation/native' -import { SafeThemeProvider } from '@/src/theme/provider/safeTheme' -import { View } from 'react-native' +import { StorybookThemeProvider } from '@/src/theme/provider/storybookTheme' import { SafeToastProvider } from '@/src/theme/provider/toastProvider' import { SafeAreaProvider } from 'react-native-safe-area-context' -import { PortalProvider } from 'tamagui' +import { PortalProvider, View } from 'tamagui' const preview: Preview = { parameters: { @@ -22,13 +21,13 @@ const preview: Preview = { - + - + - + diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 972a9ac946..557d05527a 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -1,6 +1,7 @@ # Safe{Wallet} mobile app 📱 -This project is now part of the **@safe-global/safe-wallet** monorepo! The monorepo setup allows centralized management of multiple +This project is now part of the **@safe-global/safe-wallet** monorepo! The monorepo setup allows centralized management +of multiple applications and shared libraries. This workspace (`apps/mobile`) contains the Safe Mobile App. You can run commands for this workspace in two ways: @@ -16,7 +17,8 @@ In the addition to the monorepo prerequisites, the mobile app requires the follo - iOS/Android Development Tools - [Maestro](https://maestro.mobile.dev/) if you want to run E2E tests -You can follow the [expo documentation](https://docs.expo.dev/get-started/set-up-your-environment/) to install the CLI and set up your development environment. +You can follow the [expo documentation](https://docs.expo.dev/get-started/set-up-your-environment/) to install the CLI +and set up your development environment. Follow the [Maestro](https://maestro.mobile.dev/) documentation to install the tool for E2E testing. @@ -30,6 +32,20 @@ yarn install ## Running the app +There is a `.env.example` file in the root of the mobile app. Create a `.env.local` file and paste the contents of the `.env.example` +file into it and set the correct values for the environment variables. + +For local development you need to place the `google-services.json` and `GoogleService-Info.plist` files in the root of +the mobile app. + +If you use EAS to manage your environement variables you can issue the + +```bash +eas env:pull +``` + +command. This will pull the variables from your eas project and place them in the .env.local file. + ### Running on iOS From the root of the monorepo: @@ -46,7 +62,8 @@ yarn start:ios > [!NOTE] > -> From now on for brevity we will only show the command to run from the root of the monorepo. You can always run the command from the `apps/mobile` directory you just need to omit the `workspace @safe-global/mobile`. +> From now on for brevity we will only show the command to run from the root of the monorepo. You can always run the +> command from the `apps/mobile` directory you just need to omit the `workspace @safe-global/mobile`. ### Running on Android diff --git a/apps/mobile/__mocks__/react-native-capture-protection.js b/apps/mobile/__mocks__/react-native-capture-protection.js new file mode 100644 index 0000000000..64ef5738c9 --- /dev/null +++ b/apps/mobile/__mocks__/react-native-capture-protection.js @@ -0,0 +1,17 @@ +/* eslint-disable */ +// Manual mock for react-native-capture-protection +const mockPrevent = jest.fn() +const mockAllow = jest.fn() + +const CaptureProtection = { + prevent: mockPrevent, + allow: mockAllow, +} + +// Export the mock object +module.exports = { + CaptureProtection, + // Also export the mock functions so tests can access them + __mockPrevent: mockPrevent, + __mockAllow: mockAllow, +} diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js index b1441353cf..635aa7d10a 100644 --- a/apps/mobile/app.config.js +++ b/apps/mobile/app.config.js @@ -1,87 +1,163 @@ /* eslint-disable no-undef */ const IS_DEV = process.env.APP_VARIANT === 'development' -export default { - expo: { - name: IS_DEV ? 'Safe{Wallet} MVP - Development' : 'Safe{Wallet} MVP', - slug: 'safe-mobileapp', - owner: 'safeglobal', - version: '1.0.0', - extra: { - storybookEnabled: process.env.STORYBOOK_ENABLED, - eas: { - projectId: '27e9e907-8675-474d-99ee-6c94e7b83a5c', - }, - }, - orientation: 'portrait', - icon: './assets/images/icon.png', - scheme: 'myapp', - userInterfaceStyle: 'automatic', - newArchEnabled: true, - ios: { - config: { - usesNonExemptEncryption: false, - }, - infoPlist: { - NSFaceIDUsageDescription: 'Enabling Face ID allows you to create/access secure keys.', - UIBackgroundModes: ['remote-notification'], - }, - supportsTablet: true, - appleTeamId: 'MXRS32BBL4', - bundleIdentifier: IS_DEV ? 'global.safe.mobileapp.dev' : 'global.safe.mobileapp', - entitlements: { - 'aps-environment': 'production', - }, - googleServicesFile: process.env.GOOGLE_SERVICES_PLIST ?? './GoogleService-Info.plist', +const appleDevTeamId = '86487MHG6V' + +const config = { + name: IS_DEV ? 'Safe Mobile - Development' : 'Safe Mobile', + slug: 'safe-mobileapp', + owner: 'safeglobal', + version: '1.0.1', + extra: { + storybookEnabled: process.env.STORYBOOK_ENABLED, + eas: { + projectId: '27e9e907-8675-474d-99ee-6c94e7b83a5c', }, - android: { - adaptiveIcon: { - foregroundImage: './assets/images/adaptive-icon.png', - backgroundColor: '#000000', - monochromeImage: './assets/images/monochrome-icon.png', - }, - package: IS_DEV ? 'global.safe.mobileapp.dev' : 'global.safe.mobileapp', - googleServicesFile: process.env.GOOGLE_SERVICES_JSON ?? './google-services.json', + }, + orientation: 'portrait', + icon: './assets/images/icon.png', + scheme: 'myapp', + userInterfaceStyle: 'automatic', + newArchEnabled: true, + ios: { + config: { + usesNonExemptEncryption: false, }, - web: { - bundler: 'metro', - output: 'static', - favicon: './assets/images/favicon.png', + infoPlist: { + NSFaceIDUsageDescription: 'Enabling Face ID allows you to create/access secure keys.', + UIBackgroundModes: ['remote-notification'], + AppGroup: IS_DEV ? 'group.global.safe.mobileapp.ios.dev' : 'group.global.safe.mobileapp.ios', }, - plugins: [ - 'expo-router', - [ - 'expo-font', - { - fonts: ['./assets/fonts/safe-icons/safe-icons.ttf'], - }, + supportsTablet: false, + appleTeamId: appleDevTeamId, + bundleIdentifier: IS_DEV ? 'global.safe.mobileapp.ios.dev' : 'global.safe.mobileapp.ios', + entitlements: { + 'aps-environment': IS_DEV ? 'development' : 'production', + 'com.apple.security.application-groups': [ + IS_DEV ? 'group.global.safe.mobileapp.ios.dev' : 'group.global.safe.mobileapp.ios', ], - [ - 'expo-splash-screen', - { + }, + googleServicesFile: IS_DEV ? process.env.GOOGLE_SERVICES_PLIST_DEV : process.env.GOOGLE_SERVICES_PLIST, + }, + android: { + adaptiveIcon: { + foregroundImage: './assets/images/adaptive-icon.png', + backgroundColor: '#000000', + monochromeImage: './assets/images/monochrome-icon.png', + }, + package: IS_DEV ? 'global.safe.mobileapp.dev' : 'global.safe.mobileapp', + googleServicesFile: IS_DEV ? process.env.GOOGLE_SERVICES_JSON_DEV : process.env.GOOGLE_SERVICES_JSON, + permissions: [ + 'android.permission.CAMERA', + 'android.permission.POST_NOTIFICATIONS', + 'android.permission.RECEIVE_BOOT_COMPLETED', + 'android.permission.FOREGROUND_SERVICE', + 'android.permission.WAKE_LOCK', + ], + edgeToEdgeEnabled: true, + }, + web: { + bundler: 'metro', + output: 'static', + favicon: './assets/images/favicon.png', + }, + plugins: [ + ['./expo-plugins/withNotificationIcons.js'], + 'expo-router', + [ + 'expo-font', + { + fonts: ['./assets/fonts/safe-icons/safe-icons.ttf'], + }, + ], + [ + 'expo-splash-screen', + { + image: './assets/images/splash.png', + enableFullScreenImage_legacy: true, + backgroundColor: '#000000', + dark: { image: './assets/images/splash.png', - enableFullScreenImage_legacy: true, backgroundColor: '#000000', - dark: { - image: './assets/images/splash.png', - backgroundColor: '#000000', - }, }, - ], - ['./expo-plugins/withDrawableAssets.js', './assets/android/drawable'], - [ - 'expo-build-properties', - { - ios: { - useFrameworks: 'static', - }, + android: { + image: './assets/images/icon.png', + imageWidth: 124, + imageHeight: 124, + imageResizeMode: 'contain', + backgroundColor: '#000000', }, - ], - '@react-native-firebase/app', - '@react-native-firebase/messaging', + ios: { + image: './assets/images/splash.png', + imageResizeMode: 'contain', + }, + }, + ], + [ + 'react-native-vision-camera', + { + cameraPermissionText: 'Safe{Wallet} needs access to your Camera to scan QR Codes.', + enableCodeScanner: true, + enableLocation: false, + }, + ], + ['./expo-plugins/withDrawableAssets.js', './assets/android/drawable'], + [ + 'expo-build-properties', + { + ios: { + useFrameworks: 'static', + }, + android: { + extraMavenRepos: ['../../../../node_modules/@notifee/react-native/android/libs'], + }, + }, + ], + '@react-native-firebase/app', + '@react-native-firebase/messaging', + '@react-native-firebase/crashlytics', + [ + 'react-native-share', + { + ios: ['fb', 'instagram', 'twitter', 'tiktoksharesdk'], + android: ['com.facebook.katana', 'com.instagram.android', 'com.twitter.android', 'com.zhiliaoapp.musically'], + enableBase64ShareAndroid: true, + }, + ], + 'expo-task-manager', + 'expo-web-browser', + [ + '@safe-global/notification-service-ios', + { + iosDeploymentTarget: '15.1', + apsEnvMode: IS_DEV ? 'development' : 'production', + appleDevTeamId: appleDevTeamId, + appGroupIdentifier: IS_DEV ? 'group.global.safe.mobileapp.ios.dev' : 'group.global.safe.mobileapp.ios', + }, ], - experiments: { - typedRoutes: true, + [ + 'react-native-capture-protection', + { + captureType: 'fullMediaCapture', + }, + ], + ], + experiments: { + typedRoutes: true, + }, + notification: { + icon: './assets/images/ic_notification.png', + color: '#FFFFFF', + androidMode: 'default', + androidCollapsedTitle: 'Updates from Safe Wallet', + iosDisplayInForeground: true, + }, + // Define background tasks + tasks: { + 'app.notifee.notification-event': { + backgroundMode: ['processing', 'remote-notification'], }, }, } + +export default config diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx deleted file mode 100644 index 9810e0b327..0000000000 --- a/apps/mobile/app/(tabs)/_layout.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Tabs } from 'expo-router' -import React from 'react' -import { TabBarIcon } from '@/src/components/navigation/TabBarIcon' -import { Navbar as AssetsNavbar } from '@/src/features/Assets/components/Navbar/Navbar' - -export default function TabLayout() { - return ( - - , - }} - /> - - , - }} - /> - - { - return { - title: 'Settings', - headerShown: false, - tabBarButtonTestID: 'tabSettings', - tabBarIcon: ({ color }) => , - } - }} - /> - - ) -} diff --git a/apps/mobile/app/(tabs)/transactions/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/transactions/(tabs)/_layout.tsx deleted file mode 100644 index 35623ec2b2..0000000000 --- a/apps/mobile/app/(tabs)/transactions/(tabs)/_layout.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react' - -import { - MaterialTopTabNavigationEventMap, - MaterialTopTabNavigationOptions, - createMaterialTopTabNavigator, -} from '@react-navigation/material-top-tabs' -import { withLayoutContext } from 'expo-router' -import { ParamListBase, TabNavigationState } from '@react-navigation/native' -import { useTheme } from 'tamagui' - -const { Navigator } = createMaterialTopTabNavigator() - -export const MaterialTopTabs = withLayoutContext< - MaterialTopTabNavigationOptions, - typeof Navigator, - TabNavigationState, - MaterialTopTabNavigationEventMap ->(Navigator) - -export default function TransactionsLayout() { - const theme = useTheme() - - return ( - - - - - ) -} diff --git a/apps/mobile/app/(tabs)/transactions/(tabs)/index.tsx b/apps/mobile/app/(tabs)/transactions/(tabs)/index.tsx deleted file mode 100644 index a7053dae7c..0000000000 --- a/apps/mobile/app/(tabs)/transactions/(tabs)/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { TxHistoryContainer } from '@/src/features/TxHistory' -import { View } from 'react-native' - -export default function TransactionsScreen() { - return ( - - - - ) -} diff --git a/apps/mobile/app/(tabs)/transactions/(tabs)/messages.tsx b/apps/mobile/app/(tabs)/transactions/(tabs)/messages.tsx deleted file mode 100644 index 1748ff3e15..0000000000 --- a/apps/mobile/app/(tabs)/transactions/(tabs)/messages.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' -import { SafeListItem } from '@/src/components/SafeListItem' -import { formatWithSchema } from '@/src/utils/date' -import React from 'react' -import { ScrollView, Text, View } from 'tamagui' -import { SafeAreaView } from 'react-native-safe-area-context' -import { StyleSheet } from 'react-native' - -function Messages() { - return ( - - - - - - - - } - rightNode={ - - Success - - } - /> - - - - ) -} - -const styles = StyleSheet.create({ - wrapper: { - flex: 1, - flexDirection: 'column', - }, -}) - -export default Messages diff --git a/apps/mobile/app/(tabs)/transactions/_layout.tsx b/apps/mobile/app/(tabs)/transactions/_layout.tsx deleted file mode 100644 index 21fc117898..0000000000 --- a/apps/mobile/app/(tabs)/transactions/_layout.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Stack } from 'expo-router' -import React from 'react' -import type { Route } from '@react-navigation/routers' - -import { getFocusedRouteNameFromRoute } from '@react-navigation/native' - -const getHeaderTitle = (route: Partial>) => { - const routeName = getFocusedRouteNameFromRoute(route) ?? 'index' - const name = { - ['index']: 'Transactions', - ['messages']: 'Messages', - }[routeName] - return name || 'Transactions' -} - -export default function TransactionsLayout() { - return ( - - ({ - headerTitle: getHeaderTitle(route), - })} - /> - - ) -} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx deleted file mode 100644 index 1eb06fa2df..0000000000 --- a/apps/mobile/app/_layout.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { Stack } from 'expo-router' -import 'react-native-reanimated' -import { SafeThemeProvider } from '@/src/theme/provider/safeTheme' -import { Provider } from 'react-redux' -import { persistor, store } from '@/src/store' -import { PersistGate } from 'redux-persist/integration/react' -import { isStorybookEnv } from '@/src/config/constants' -import { apiSliceWithChainsConfig } from '@safe-global/store/gateway/chains' -import { GestureHandlerRootView } from 'react-native-gesture-handler' -import { HeaderBackButton } from '@react-navigation/elements' -import { BottomSheetModalProvider } from '@gorhom/bottom-sheet' -import { PortalProvider } from '@tamagui/portal' -import { NotificationsProvider } from '@/src/context/NotificationsContext' -import { SafeToastProvider } from '@/src/theme/provider/toastProvider' -import { configureReanimatedLogger, ReanimatedLogLevel } from 'react-native-reanimated' -import { OnboardingHeader } from '@/src/features/Onboarding/components/OnboardingHeader' -import { install } from 'react-native-quick-crypto' - -install() - -configureReanimatedLogger({ - level: ReanimatedLogLevel.warn, - strict: false, -}) - -function RootLayout() { - store.dispatch(apiSliceWithChainsConfig.endpoints.getChainsConfig.initiate()) - - return ( - - - - - - - - - ({ - headerBackButtonDisplayMode: 'minimal', - headerShadowVisible: false, - headerLeft: (props) => ( - - ), - })} - > - - - - - - - - - - - - - - - - - - - - ) -} - -let AppEntryPoint = RootLayout - -if (isStorybookEnv) { - AppEntryPoint = require('../.storybook').default -} - -export default AppEntryPoint diff --git a/apps/mobile/app/import-signers.tsx b/apps/mobile/app/import-signers.tsx deleted file mode 100644 index ac3328d983..0000000000 --- a/apps/mobile/app/import-signers.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { SectionTitle } from '@/src/components/Title' -import React from 'react' -import { ScrollView } from 'react-native-gesture-handler' -import Seed from '@/assets/images/seed.png' -import { SafeFontIcon } from '@/src/components/SafeFontIcon' -import { useScrollableHeader } from '@/src/navigation/useScrollableHeader' -import { NavBarTitle } from '@/src/components/Title' -import { SafeAreaView } from 'react-native-safe-area-context' -import { SafeCard } from '@/src/components/SafeCard' - -/** - * TODO: Add the images for each items - * waiting for the design to exprot - figma is crazy - */ -const items = [ - { - name: 'seed', - title: 'Import seed phrase or a private key', - description: 'Enter a private key or a 12-24 word seed phrase.', - icon: , - Image: Seed, - }, - { - name: 'connectSigner', - title: 'Connect signer', - description: 'Connect any signer via one of your installed wallet apps.', - icon: , - Image: Seed, - }, - { - name: 'hardwareSigner', - title: 'Import hardware signer', - description: 'Use your Ledger or Keystone device.', - icon: , - Image: Seed, - }, -] - -function ImportSignersPage() { - const { handleScroll } = useScrollableHeader({ - children: Import Signers, - }) - - return ( - - - - - {items.map((item, index) => ( - - ))} - - - ) -} - -export default ImportSignersPage diff --git a/apps/mobile/app/notifications-opt-in.tsx b/apps/mobile/app/notifications-opt-in.tsx deleted file mode 100644 index 2705831eb8..0000000000 --- a/apps/mobile/app/notifications-opt-in.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { useColorScheme } from 'react-native' -import { OptIn } from '@/src/components/OptIn' -import useNotifications from '@/src/hooks/useNotifications' -import { router, useFocusEffect } from 'expo-router' - -function NotificationsOptIn() { - const { enableNotifications, isAppNotificationEnabled } = useNotifications() - const colorScheme = useColorScheme() - - useFocusEffect(() => { - if (isAppNotificationEnabled) { - router.replace('/(tabs)') - } - }) - - const image = - colorScheme === 'dark' - ? require('@/assets/images/notifications-dark.png') - : require('@/assets/images/notifications-light.png') - - return ( - router.back(), - label: 'Maybe later', - }} - /> - ) -} - -export default NotificationsOptIn diff --git a/apps/mobile/app/notifications.tsx b/apps/mobile/app/notifications.tsx deleted file mode 100644 index 40f5da171e..0000000000 --- a/apps/mobile/app/notifications.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react' -import { NotificationsContainer } from '@/src/features/Notifications' - -function NotificationsScreen() { - return -} - -export default NotificationsScreen diff --git a/apps/mobile/app/signers/[address]/index.tsx b/apps/mobile/app/signers/[address]/index.tsx deleted file mode 100644 index 9d82b0e22c..0000000000 --- a/apps/mobile/app/signers/[address]/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react' -import { Text, View } from 'tamagui' - -function SignerDetailsPage() { - return ( - - Signer Details - - ) -} - -export default SignerDetailsPage diff --git a/apps/mobile/app/signers/index.tsx b/apps/mobile/app/signers/index.tsx deleted file mode 100644 index 6763bcd3d5..0000000000 --- a/apps/mobile/app/signers/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react' -import { SignersContainer } from '@/src/features/Signers' -import { SafeAreaView } from 'react-native-safe-area-context' - -function SignersScreen() { - return ( - - - - ) -} - -export default SignersScreen diff --git a/apps/mobile/assets/android/drawable/ic_notification.xml b/apps/mobile/assets/android/drawable/ic_notification.xml new file mode 100644 index 0000000000..3109d1dad7 --- /dev/null +++ b/apps/mobile/assets/android/drawable/ic_notification.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/assets/fonts/safe-icons/safe-icons.ttf b/apps/mobile/assets/fonts/safe-icons/safe-icons.ttf index 8a4a0963fc..26109faaf8 100644 Binary files a/apps/mobile/assets/fonts/safe-icons/safe-icons.ttf and b/apps/mobile/assets/fonts/safe-icons/safe-icons.ttf differ diff --git a/apps/mobile/assets/fonts/safe-icons/selection.json b/apps/mobile/assets/fonts/safe-icons/selection.json index 21e2ab77d6..386561e1b8 100644 --- a/apps/mobile/assets/fonts/safe-icons/selection.json +++ b/apps/mobile/assets/fonts/safe-icons/selection.json @@ -4,1629 +4,1485 @@ { "icon": { "paths": [ - "M512 853.333c-78.763 0-151.125-27.093-208.939-72.021l478.251-478.251c44.971 57.771 72.021 130.176 72.021 208.939 0 188.203-153.131 341.333-341.333 341.333zM170.667 512c0-188.203 153.131-341.333 341.333-341.333 78.763 0 151.125 27.093 208.939 72.021l-478.251 478.251c-44.971-57.771-72.021-130.176-72.021-208.939zM512 85.333c-235.264 0-426.667 191.403-426.667 426.667s191.403 426.667 426.667 426.667c235.264 0 426.667-191.403 426.667-426.667s-191.403-426.667-426.667-426.667z" + "M555.119 170.667c70.571 0 128 57.429 128 128s-57.429 128-128 128c-70.571 0-128-57.429-128-128s57.429-128 128-128zM979.951 884.309c-48.256-165.376-128.299-351.232-319.403-401.323 64.128-36.821 107.904-105.216 107.904-184.32 0-117.632-95.701-213.333-213.333-213.333s-213.333 95.701-213.333 213.333c0 79.317 44.032 147.883 108.459 184.619-27.989 7.552-54.613 17.877-79.317 31.701-20.608 11.52-27.947 37.504-16.384 58.069 11.435 20.48 37.504 27.947 58.027 16.341 41.515-23.168 88.107-34.475 142.421-34.475 168.192 0 270.805 105.643 343.040 353.28 5.419 18.645 22.443 30.72 40.96 30.72 3.925 0 7.979-0.555 11.947-1.707 22.656-6.571 35.584-30.293 29.013-52.907z", + "M298.619 725.461h85.333c23.552 0 42.667 19.115 42.667 42.667 0 23.595-19.115 42.667-42.667 42.667h-85.333v85.333c0 23.552-19.072 42.667-42.667 42.667-23.552 0-42.667-19.115-42.667-42.667v-85.333h-85.291c-23.552 0-42.667-19.072-42.667-42.667 0-23.552 19.115-42.667 42.667-42.667h85.291v-85.291c0-23.595 19.115-42.667 42.667-42.667 23.595 0 42.667 19.072 42.667 42.667v85.291z" ], - "attrs": [{ "fill": "rgb(255, 95, 114)" }], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["block"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "f": 4 }] } + "tags": ["add-owner"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{ "fill": "rgb(255, 95, 114)" }], - "properties": { "order": 121, "id": 103, "name": "block", "prevSize": 32, "code": 59648 }, + "attrs": [{}, {}], + "properties": { "order": 257, "id": 117, "name": "add-owner", "prevSize": 32, "code": 59648 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 0 }, { "icon": { "paths": [ - "M511.949 341.432c23.552 0 42.667 19.072 42.667 42.667v170.666c0 23.552-19.115 42.667-42.667 42.667-23.595 0-42.667-19.115-42.667-42.667v-170.666c0-23.595 19.072-42.667 42.667-42.667z", - "M511.949 672.098c29.44 0 53.333 23.893 53.333 53.333s-23.893 53.333-53.333 53.333c-29.44 0-53.333-23.893-53.333-53.333s23.893-53.333 53.333-53.333z", - "M511.991 85.349c-21.419 0-42.837 10.667-54.741 32.085l-406.612 728.62c-23.168 41.6 7.040 92.629 54.784 92.629h813.183c47.744 0 77.909-51.029 54.741-92.629l-406.571-728.62c-11.947-21.419-33.365-32.085-54.784-32.085zM511.991 194.32l367.957 659.372h-735.871l367.914-659.372z" + "M170.667 384v256h-42.667c-23.564 0-42.667 19.102-42.667 42.667s19.103 42.667 42.667 42.667h42.667v128c0 47.13 38.205 85.333 85.333 85.333h512c47.13 0 85.333-38.204 85.333-85.333v-682.667c0-47.128-38.204-85.333-85.333-85.333h-512c-47.128 0-85.333 38.205-85.333 85.333v128h-42.667c-23.564 0-42.667 19.103-42.667 42.667s19.103 42.667 42.667 42.667h42.667zM298.667 640h-42.667v-256h42.667c23.564 0 42.667-19.103 42.667-42.667s-19.103-42.667-42.667-42.667h-42.667v-128h512v682.667h-512v-128h42.667c23.564 0 42.667-19.102 42.667-42.667s-19.103-42.667-42.667-42.667z" ], - "attrs": [{ "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["alert-triangle"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "f": 4 }, { "f": 4 }, { "f": 4 }] } + "tags": ["address-book"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{ "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }], - "properties": { "order": 120, "id": 102, "name": "alert-triangle", "prevSize": 32, "code": 59649 }, + "attrs": [{}], + "properties": { "order": 258, "id": 116, "name": "address-book", "prevSize": 32, "code": 59649 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 1 }, { "icon": { "paths": [ - "M512 256c23.564 0 42.667 19.103 42.667 42.667v256c0 23.564-19.103 42.667-42.667 42.667s-42.667-19.103-42.667-42.667v-256c0-23.564 19.103-42.667 42.667-42.667z", - "M512 672c29.457 0 53.333 23.876 53.333 53.333s-23.876 53.333-53.333 53.333c-29.457 0-53.333-23.876-53.333-53.333s23.876-53.333 53.333-53.333z", - "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM170.667 512c0 188.514 152.82 341.333 341.333 341.333s341.333-152.819 341.333-341.333c0-188.513-152.819-341.333-341.333-341.333s-341.333 152.82-341.333 341.333z" + "M427.456 938.112c170.687 0 309.056-138.368 309.056-309.056 0-170.687-138.369-309.056-309.056-309.056s-309.056 138.369-309.056 309.056c0 170.688 138.369 309.056 309.056 309.056z", + "M639.040 804.48c167.366 0 303.040-135.674 303.040-303.040 0-167.364-135.674-303.040-303.040-303.040-167.364 0-303.040 135.676-303.040 303.040 0 167.366 135.676 303.040 303.040 303.040z", + "M282.047 476.739c105.756 0 191.488-85.732 191.488-191.488s-85.732-191.488-191.488-191.488c-105.756 0-191.488 85.732-191.488 191.488s85.732 191.488 191.488 191.488z", + "M282.048 97.92c-103.459 0-187.328 83.869-187.328 187.328s83.869 187.328 187.328 187.328c103.459 0 187.328-83.869 187.328-187.328s-83.869-187.328-187.328-187.328zM86.4 285.248c0-108.053 87.595-195.648 195.648-195.648s195.648 87.595 195.648 195.648c0 108.053-87.595 195.648-195.648 195.648s-195.648-87.595-195.648-195.648z", + "M526.313 512.998c-12.896 17.398-19.597 38.388-21.773 52.319-0.355 2.27-2.483 3.823-4.753 3.468s-3.822-2.483-3.468-4.753c2.336-14.952 9.444-37.284 23.309-55.989 13.941-18.807 34.926-34.175 64.739-34.175 29.901 0 50.486 16.712 63.395 32.957 6.458 8.134 11.085 16.246 14.093 22.317 1.504 3.041 2.611 5.583 3.347 7.379 0.371 0.899 0.646 1.611 0.832 2.107 0.096 0.248 0.166 0.442 0.211 0.578l0.058 0.16 0.019 0.047 0.006 0.015v0.005c0 0.003 0 0.004-3.93 1.364l3.93-1.36c0.749 2.172-0.397 4.541-2.573 5.292-2.17 0.751-4.538-0.399-5.286-2.569v-0.003l-0.013-0.020-0.038-0.109c-0.032-0.101-0.090-0.26-0.173-0.472-0.16-0.424-0.403-1.059-0.742-1.877-0.666-1.637-1.696-3.998-3.104-6.842-2.822-5.697-7.149-13.277-13.152-20.835-12.031-15.14-30.461-29.812-56.88-29.812-26.507 0-45.235 13.514-58.055 30.81z", + "M643.936 421.169c0 32.899-26.668 59.569-59.567 59.569s-59.569-26.67-59.569-59.569c0-32.899 26.67-59.569 59.569-59.569s59.567 26.67 59.567 59.569z", + "M584.369 472.227c-28.198 0-51.057-22.859-51.057-51.057s22.859-51.057 51.057-51.057c28.198 0 51.057 22.859 51.057 51.057s-22.859 51.057-51.057 51.057zM584.369 480.739c32.899 0 59.567-26.67 59.567-59.569s-26.668-59.569-59.567-59.569c-32.899 0-59.569 26.67-59.569 59.569s26.67 59.569 59.569 59.569z", + "M653.082 560.998c-12.896 17.398-19.594 38.388-21.77 52.319-0.355 2.27-2.483 3.823-4.753 3.468s-3.822-2.483-3.468-4.753c2.336-14.952 9.444-37.284 23.309-55.989 13.939-18.807 34.925-34.175 64.742-34.175 29.792 0 51.206 15.347 65.702 34.109 14.426 18.676 22.227 40.986 25.030 55.931 0.422 2.258-1.062 4.432-3.322 4.855s-4.435-1.064-4.858-3.322c-2.611-13.937-9.971-34.95-23.437-52.377-13.395-17.34-32.595-30.876-59.117-30.876-26.509 0-45.235 13.514-58.061 30.81z", + "M770.707 469.169c0 32.899-26.669 59.569-59.565 59.569-32.902 0-59.571-26.67-59.571-59.569s26.669-59.569 59.571-59.569c32.896 0 59.565 26.67 59.565 59.569z", + "M711.142 520.227c-28.198 0-51.059-22.859-51.059-51.057s22.861-51.057 51.059-51.057c28.198 0 51.053 22.859 51.053 51.057s-22.854 51.057-51.053 51.057zM711.142 528.739c32.896 0 59.565-26.67 59.565-59.569s-26.669-59.569-59.565-59.569c-32.902 0-59.571 26.67-59.571 59.569s26.669 59.569 59.571 59.569z" ], - "attrs": [{ "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }], - "isMulticolor": false, + "attrs": [ + { "fill": "rgb(28, 28, 28)" }, + { "fill": "rgb(28, 28, 28)" }, + { "fill": "rgb(28, 28, 28)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(28, 28, 28)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(28, 28, 28)" }, + { "fill": "rgb(161, 163, 167)" } + ], + "width": 1030, + "isMulticolor": true, "isMulticolor2": false, "grid": 0, - "tags": ["alert"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "f": 4 }, { "f": 4 }, { "f": 4 }] } + "tags": ["address-book-empty-list"], + "colorPermutations": { + "11611631671255214012559511412828281": [ + { "f": 1 }, + { "f": 1 }, + { "f": 1 }, + { "f": 3 }, + { "f": 3 }, + { "f": 1 }, + { "f": 3 }, + { "f": 3 }, + { "f": 1 }, + { "f": 3 } + ] + } + }, + "attrs": [ + { "fill": "rgb(28, 28, 28)" }, + { "fill": "rgb(28, 28, 28)" }, + { "fill": "rgb(28, 28, 28)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(28, 28, 28)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(28, 28, 28)" }, + { "fill": "rgb(161, 163, 167)" } + ], + "properties": { + "order": 259, + "id": 115, + "name": "address-book-empty-list", + "prevSize": 32, + "code": 59650, + "codes": [59650, 59651, 59652, 59653, 59654, 59655, 59656, 59657, 59658, 59659] }, - "attrs": [{ "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }], - "properties": { "order": 119, "id": 101, "name": "alert", "prevSize": 32, "code": 59650 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 2 }, { "icon": { "paths": [ - "M512 426.667c23.564 0 42.667 19.103 42.667 42.667v256c0 23.564-19.103 42.667-42.667 42.667s-42.667-19.103-42.667-42.667v-256c0-23.564 19.103-42.667 42.667-42.667z", - "M512 245.333c29.457 0 53.333 23.878 53.333 53.333s-23.876 53.333-53.333 53.333c-29.457 0-53.333-23.878-53.333-53.333s23.876-53.333 53.333-53.333z", + "M512 256c23.564 0 42.667 19.103 42.667 42.667v256c0 23.564-19.103 42.667-42.667 42.667s-42.667-19.103-42.667-42.667v-256c0-23.564 19.103-42.667 42.667-42.667z", + "M512 672c29.457 0 53.333 23.876 53.333 53.333s-23.876 53.333-53.333 53.333c-29.457 0-53.333-23.876-53.333-53.333s23.876-53.333 53.333-53.333z", "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM170.667 512c0 188.514 152.82 341.333 341.333 341.333s341.333-152.819 341.333-341.333c0-188.513-152.819-341.333-341.333-341.333s-341.333 152.82-341.333 341.333z" ], - "attrs": [{}, {}, {}], + "attrs": [{ "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["info"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + "tags": ["alert"], + "colorPermutations": { "11611631671255214012559511412828281": [{ "f": 4 }, { "f": 4 }, { "f": 4 }] } }, - "attrs": [{}, {}, {}], - "properties": { "order": 118, "id": 100, "name": "info", "prevSize": 32, "code": 59651 }, + "attrs": [{ "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }], + "properties": { "order": 260, "id": 114, "name": "alert", "prevSize": 32, "code": 59660 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 3 }, { "icon": { "paths": [ - "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM170.667 512c0 188.514 152.82 341.333 341.333 341.333s341.333-152.819 341.333-341.333c0-188.513-152.819-341.333-341.333-341.333s-341.333 152.82-341.333 341.333z", - "M445.525 389.725c0-32.858 27.814-59.947 62.72-59.947 34.829 0 62.635 27.096 62.635 59.947 0.371 23.981-5.726 32.305-35.209 53.824l-2.014 1.468c-49.997 36.484-70.972 65.579-68.023 123.435l-0.055 8.41c0 23.565 19.102 42.667 42.667 42.667s42.667-19.102 42.667-42.667v-10.581c-1.165-23.983 3.981-31.121 33.045-52.331l2.022-1.476c49.643-36.233 71.13-65.574 70.229-123.413 0.004-79.814-66.492-144.615-147.964-144.615-81.54 0-148.052 64.783-148.052 145.28 0 23.564 19.102 42.667 42.667 42.667s42.666-19.103 42.666-42.667z", - "M512 672c29.457 0 53.333 23.876 53.333 53.333s-23.876 53.333-53.333 53.333c-29.457 0-53.333-23.876-53.333-53.333s23.876-53.333 53.333-53.333z" + "M511.949 341.434c23.552 0 42.667 19.072 42.667 42.667v170.665c0 23.552-19.115 42.667-42.667 42.667-23.595 0-42.667-19.115-42.667-42.667v-170.665c0-23.595 19.072-42.667 42.667-42.667z", + "M511.949 672.098c29.44 0 53.333 23.893 53.333 53.333s-23.893 53.333-53.333 53.333c-29.44 0-53.333-23.893-53.333-53.333s23.893-53.333 53.333-53.333z", + "M511.991 85.348c-21.419 0-42.837 10.667-54.741 32.085l-406.612 728.617c-23.168 41.6 7.040 92.629 54.784 92.629h813.183c47.744 0 77.909-51.029 54.741-92.629l-406.571-728.617c-11.947-21.419-33.365-32.085-54.784-32.085zM511.991 194.319l367.957 659.369h-735.871l367.914-659.369z" ], - "attrs": [{}, {}, {}], + "attrs": [{ "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["question"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + "tags": ["alert-triangle"], + "colorPermutations": { "11611631671255214012559511412828281": [{ "f": 4 }, { "f": 4 }, { "f": 4 }] } }, - "attrs": [{}, {}, {}], - "properties": { "order": 117, "id": 99, "name": "question", "prevSize": 32, "code": 59652 }, + "attrs": [{ "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }, { "fill": "rgb(255, 95, 114)" }], + "properties": { "order": 261, "id": 113, "name": "alert-triangle", "prevSize": 32, "code": 59661 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 4 }, { "icon": { "paths": [ - "M616.64 555.337l32.883-16.546c20.791-10.462 43.58-19.307 67.26-26.79-23.68-7.484-46.468-16.329-67.26-26.79l-32.883-16.546 11.55-34.953c7.305-22.107 17.165-44.481 28.621-66.52-22.042 11.453-44.416 21.316-66.522 28.62l-34.953 11.548-16.546-32.881c-10.466-20.793-19.307-43.582-26.79-67.258-7.484 23.677-16.329 46.466-26.79 67.258l-16.546 32.881-34.953-11.548c-22.107-7.304-44.48-17.167-66.52-28.62 11.453 22.039 21.316 44.413 28.62 66.519l11.548 34.953-32.881 16.546c-20.793 10.462-43.582 19.307-67.259 26.79 23.677 7.484 46.466 16.329 67.259 26.79l32.881 16.546-11.548 34.953c-7.304 22.106-17.167 44.48-28.62 66.522 22.039-11.456 44.413-21.316 66.52-28.621l34.953-11.55 16.546 32.883c10.462 20.791 19.307 43.58 26.79 67.26 7.484-23.68 16.324-46.468 26.79-67.26l16.546-32.883 34.953 11.55c22.106 7.305 44.48 17.165 66.522 28.621-11.456-22.042-21.316-44.416-28.621-66.522l-11.55-34.953zM734.609 703.876c62.31 93.005 139.422 170.155 139.422 170.155s-77.15-77.111-170.155-139.422c-40.994-27.469-85.069-52.062-126.972-65.907-19.84 39.42-33.613 87.974-43.174 136.384-21.7 109.828-21.73 218.914-21.73 218.914s-0.030-109.086-21.73-218.914c-9.562-48.41-23.339-96.964-43.174-136.384-41.902 13.845-85.977 38.438-126.973 65.903-93.002 62.315-170.156 139.426-170.156 139.426s77.113-77.15 139.426-170.155c27.468-40.994 52.061-85.069 65.905-126.972-39.419-19.84-87.973-33.613-136.382-43.174-109.828-21.7-218.916-21.73-218.916-21.73s109.088-0.030 218.916-21.73c48.41-9.562 96.963-23.334 136.382-43.174-13.844-41.902-38.437-85.977-65.905-126.973-62.314-93.002-139.427-170.156-139.427-170.156s77.154 77.113 170.156 139.427c40.995 27.468 85.071 52.061 126.973 65.905 19.836-39.419 33.613-87.972 43.174-136.382 21.7-109.828 21.73-218.916 21.73-218.916s0.030 109.088 21.73 218.916c9.562 48.41 23.334 96.963 43.174 136.382 41.903-13.844 85.978-38.437 126.972-65.905 93.005-62.313 170.155-139.426 170.155-139.426s-77.111 77.154-139.422 170.156c-27.469 40.996-52.062 85.071-65.907 126.973 39.42 19.84 87.974 33.613 136.384 43.174 109.828 21.7 218.914 21.73 218.914 21.73s-109.086 0.030-218.914 21.73c-48.41 9.562-96.964 23.334-136.384 43.174 13.845 41.903 38.438 85.978 65.907 126.972z" + "M515.465 107.052c8.256 14.651 7.138 32.785-2.859 46.31-36.446 49.308-53.986 110.060-49.425 171.206s30.916 118.624 74.27 161.982c43.358 43.358 100.838 69.713 161.984 74.27 61.146 4.561 121.899-12.975 171.204-49.425 13.525-9.997 31.659-11.115 46.31-2.859 14.652 8.26 23.087 24.35 21.538 41.097-7.458 80.7-37.743 157.602-87.313 221.717-49.57 64.111-116.373 112.781-192.593 140.314-76.224 27.529-158.707 32.785-237.807 15.147-79.098-17.634-151.538-57.434-208.843-114.739s-97.104-129.745-114.741-208.845c-17.637-79.095-12.382-161.583 15.149-237.804s76.201-143.025 140.314-192.594c64.113-49.57 141.018-79.855 221.714-87.313 16.747-1.548 32.841 6.886 41.097 21.538zM397.5 189.4c-33.178 12.004-64.46 29.14-92.651 50.936-51.29 39.656-90.226 93.099-112.251 154.076-22.025 60.978-26.229 126.967-12.119 190.246s45.949 121.229 91.793 167.074c45.844 45.841 103.795 77.683 167.076 91.793 63.279 14.11 129.267 9.903 190.242-12.122s114.419-60.962 154.078-112.252c21.794-28.19 38.929-59.473 50.935-92.651-44.937 16.256-93.133 23.027-141.517 19.418-81.527-6.080-158.165-41.22-215.974-99.029s-92.948-134.447-99.028-215.975c-3.608-48.383 3.16-96.58 19.417-141.513z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["points"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["appearance"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 116, "id": 98, "name": "points", "prevSize": 32, "code": 59653 }, + "properties": { "order": 262, "id": 112, "name": "appearance", "prevSize": 32, "code": 59662 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 5 }, { "icon": { "paths": [ - "M307.199 163.142c0-6.317 5.093-11.438 11.377-11.438h79.646c6.284 0 11.378 5.121 11.378 11.438v80.066c0 6.317-5.093 11.438-11.378 11.438h-91.023v-91.504z", - "M204.801 266.083c0-6.317 5.094-11.438 11.378-11.438h91.022v91.504c0 6.317-5.094 11.438-11.378 11.438h-91.022v-91.504z", - "M102.4 369.025c0-6.317 5.094-11.438 11.378-11.438h91.022v91.504c0 6.317-5.094 11.438-11.378 11.438h-91.022v-91.504z", - "M0 471.967c0-6.317 5.094-11.438 11.378-11.438h91.022v102.94h-91.022c-6.284 0-11.378-5.12-11.378-11.435v-80.068z", - "M102.4 563.469h91.022c6.284 0 11.378 5.124 11.378 11.438v91.504h-91.022c-6.284 0-11.378-5.12-11.378-11.438v-91.504z", - "M204.801 666.412h91.022c6.284 0 11.378 5.124 11.378 11.438v91.504h-91.022c-6.284 0-11.378-5.12-11.378-11.438v-91.504z", - "M307.199 769.354h91.023c6.284 0 11.378 5.12 11.378 11.438v80.065c0 6.318-5.093 11.438-11.378 11.438h-79.646c-6.284 0-11.377-5.12-11.377-11.438v-91.504z", - "M716.8 163.142c0-6.317-5.093-11.438-11.378-11.438h-79.644c-6.284 0-11.378 5.121-11.378 11.438v80.066c0 6.317 5.093 11.438 11.378 11.438h91.022v-91.504z", - "M819.2 266.083c0-6.317-5.093-11.438-11.378-11.438h-91.022v91.504c0 6.317 5.093 11.438 11.378 11.438h91.022v-91.504z", - "M921.6 369.025c0-6.317-5.093-11.438-11.378-11.438h-91.022v91.504c0 6.317 5.093 11.438 11.378 11.438h91.022v-91.504z", - "M1024 471.967c0-6.317-5.093-11.438-11.378-11.438h-91.022v102.94h91.022c6.284 0 11.378-5.12 11.378-11.435v-80.068z", - "M921.6 563.469h-91.022c-6.284 0-11.378 5.124-11.378 11.438v91.504h91.022c6.284 0 11.378-5.12 11.378-11.438v-91.504z", - "M819.2 666.412h-91.022c-6.284 0-11.378 5.124-11.378 11.438v91.504h91.022c6.284 0 11.378-5.12 11.378-11.438v-91.504z", - "M716.8 769.354h-91.022c-6.284 0-11.378 5.12-11.378 11.438v80.065c0 6.318 5.093 11.438 11.378 11.438h79.644c6.284 0 11.378-5.12 11.378-11.438v-91.504z" + "M384 85.333h-213.333c-47.128 0-85.333 38.205-85.333 85.333v213.333c0 47.13 38.205 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.128-38.204-85.333-85.333-85.333zM170.667 384v-213.333h213.333v213.333h-213.333z", + "M853.333 554.667h-213.333c-47.13 0-85.333 38.204-85.333 85.333v213.333c0 47.13 38.204 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.13-38.204-85.333-85.333-85.333zM640 853.333v-213.333h213.333v213.333h-213.333z", + "M938.667 277.333c0 106.039-85.961 192-192 192s-192-85.961-192-192c0-106.039 85.961-192 192-192s192 85.961 192 192zM640 277.333c0 58.91 47.757 106.667 106.667 106.667s106.667-47.756 106.667-106.667c0-58.91-47.757-106.667-106.667-106.667s-106.667 47.756-106.667 106.667z", + "M277.388 804.651l-122.236 122.236c-15.872 15.872-41.813 15.872-57.728 0l-0.171-0.213c-15.872-15.872-15.872-41.856 0-57.771l122.197-122.197-122.18-122.197c-15.872-15.872-15.872-41.856 0-57.728l0.171-0.213c15.915-15.872 41.856-15.872 57.728 0l122.219 122.202 122.202-122.202c15.872-15.872 41.857-15.872 57.729 0l0.213 0.213c15.872 15.872 15.872 41.856 0 57.728l-122.199 122.197 122.216 122.197c15.872 15.915 15.872 41.899 0 57.771l-0.213 0.213c-15.872 15.872-41.857 15.872-57.729 0l-122.219-122.236z" ], - "attrs": [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}], + "attrs": [{}, {}, {}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["code-blocks"], - "colorPermutations": { - "11611631671181918125521401255951141": [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}] - } + "tags": ["apps"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}, {}] } }, - "attrs": [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}], - "properties": { "order": 115, "id": 97, "name": "code-blocks", "prevSize": 32, "code": 59654 }, + "attrs": [{}, {}, {}, {}], + "properties": { "order": 263, "id": 111, "name": "apps", "prevSize": 32, "code": 59663 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 6 }, { "icon": { "paths": [ - "M313.544 7.455l-0.002 0.002 2.236 2.012-0.002 0.002 92.821 88.767-95.054-90.783zM313.544 7.455l-0.114-0.096M313.544 7.455l-0.114-0.096M313.43 7.358c-9.616-8.124-21.647-12.158-33.609-12.158M313.43 7.358l-33.609-12.158M443.789 580.65l-191.421-183.062c-36.396-34.806-43.486-87.131-21.158-128.769l-0.106-0.097-0.079-0.073 0.001-0.001-92.82-88.777c-19.335-18.479-19.99-48.173-1.927-67.423l0.089-0.095 0.001 0.001 1.834-1.851 0.001 0.001 105.658-101.033 199.927 571.178zM443.789 580.65l-188.309 180.16c-61.842 62.166-60.854 160.595 2.94 221.603l0.001 0.003c32.357 30.931 74.734 46.384 117.054 46.384s84.707-15.453 117.053-46.387l362.618-346.848c61.84-62.166 60.854-160.589-2.941-221.597l-0.045-0.042-2.832-2.643-0.096-0.083-2.723-2.41c-2.022-2.138-4.115-4.24-6.269-6.301v0l-299.792-286.601c-22.122-19.726-50.352-29.554-78.528-29.554-18.176 0-36.368 4.088-52.922 12.287l34.79 482.028zM279.821-4.8c-12.97 0-25.998 4.735-35.956 14.27l35.956-14.27zM813.021 452.032v0c20.765 19.859 32.147 46.186 32.147 74.176 0 27.987-11.382 54.33-32.147 74.186l-362.298 346.403c-0.010 0.010-0.019 0.019-0.032 0.029-20.448 18.304-46.992 28.365-75.219 28.365-29.466 0-57.112-10.966-77.883-30.829-20.764-19.869-32.146-46.198-32.146-74.198 0-27.987 11.382-54.317 32.146-74.173l362.299-346.397c0.010-0.010 0.019-0.019 0.029-0.029 20.442-18.31 46.995-28.371 75.219-28.371 29.469 0 57.114 10.963 77.885 30.838zM504.246 156.711l0.003 0.002 220.826 211.163c-38.912 2.253-77.2 17.6-106.995 46.093 0 0 0 0 0 0l-134.816 128.915-193.652-185.283c-21.24-22.288-20.61-56.896 1.922-78.454l7.348-7.003c1.403-1.084 2.76-2.249 4.063-3.495h0l107.535-102.936 0.176-0.2 1.533-1.728 7.341-7.033 2.246-2.046c11.005-9.527 25.12-14.76 40.141-14.76 16.038 0 31.050 5.969 42.33 16.766zM181.005 145.102l98.797-94.513 85.788 82.206-98.808 94.493-85.777-82.186zM730.474 460.125c-39.981 0-72.758 31.046-72.758 69.792s32.778 69.792 72.758 69.792c39.981 0 72.771-31.043 72.771-69.792s-32.79-69.792-72.771-69.792zM730.474 513.731c9.68 0 17.152 7.45 17.152 16.182s-7.472 16.182-17.152 16.182c-9.67 0-17.139-7.443-17.139-16.182s7.469-16.182 17.139-16.182z" + "M760.567 390.761c9.911 9.014 9.911 23.629 0 32.643l-230.63 209.838c-9.907 9.011-25.967 9.011-35.874 0l-230.633-209.838c-9.907-9.014-9.907-23.629 0-32.643s25.97-9.014 35.878 0l212.692 193.517 212.693-193.517c9.907-9.014 25.967-9.014 35.874 0z", + "M774.925 374.981c19.213 17.478 19.213 46.724 0 64.204l-230.63 209.835c-18.044 16.418-46.545 16.418-64.589 0l-230.632-209.839c-19.21-17.475-19.21-46.723 0-64.2 18.046-16.419 46.545-16.419 64.591 0l198.335 180.454 198.336-180.454c18.044-16.419 46.545-16.419 64.589 0zM746.658 407.081c-0.017-0.036-0.111-0.236-0.444-0.541-0.683-0.619-1.916-1.207-3.584-1.207s-2.901 0.588-3.58 1.207l-212.693 193.516c-8.141 7.403-20.574 7.403-28.715 0l-212.691-193.516c-0.68-0.619-1.913-1.207-3.582-1.207s-2.902 0.588-3.582 1.207c-0.335 0.305-0.428 0.505-0.444 0.541 0.016 0.036 0.109 0.238 0.444 0.543l230.629 209.836c0.683 0.619 1.916 1.207 3.584 1.207s2.901-0.589 3.584-1.207l230.63-209.836c0.333-0.305 0.427-0.507 0.444-0.543z" ], - "attrs": [{}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["hardware"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["arrow-down"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}], - "properties": { "order": 122, "id": 96, "name": "hardware", "prevSize": 32, "code": 59655 }, + "attrs": [{}, {}], + "properties": { "order": 264, "id": 110, "name": "arrow-down", "prevSize": 32, "code": 59664 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 7 }, { "icon": { "paths": [ - "M436.297 645.107c-62.274 10.381-103.791 108.117-116.765 155.686-18.836 51.895 207.581 155.686 259.476 142.716 41.519-10.381 60.548-73.519 64.87-103.795 25.95 38.925-129.737-207.582-207.582-194.607z", - "M463.159 663.104c10.3 4.791 21.722 12.48 33.822 22.43 24.188 19.895 49.515 47.535 72.013 74.854 22.426 27.23 41.673 53.692 53.705 70.938 3.029 4.343 5.589 8.085 7.616 11.085-2.453 13.982-7.778 33.455-16.516 50.833-9.954 19.81-22.771 33.886-37.935 37.675-3.942 0.99-11.093 1.105-21.717-0.516-10.291-1.57-22.665-4.582-36.395-8.819-27.435-8.474-59.332-21.559-89.135-36.676-29.907-15.172-57.022-32.051-75.281-47.893-9.178-7.966-15.484-15.13-18.953-21.141-3.481-6.029-3.189-9.178-2.654-10.65l0.182-0.503 0.14-0.512c6.235-22.861 19.412-57.958 38.382-88.329 19.374-31.023 42.677-53.751 67.999-57.975 6.148-1.024 14.315 0.358 24.73 5.201zM635.755 804.851c-12.066-16.841-28.339-38.622-46.729-60.958-22.912-27.819-49.476-56.939-75.563-78.4-13.039-10.722-26.351-19.861-39.36-25.916-12.902-6.003-26.624-9.485-39.94-7.266-36.951 6.157-65.543 37.483-85.74 69.82-20.475 32.781-34.496 70.080-41.283 94.797-3.871 11.311-0.641 22.541 4.769 31.915 5.511 9.549 14.209 18.906 24.419 27.767 20.518 17.805 49.72 35.797 80.549 51.435 30.935 15.697 64.198 29.367 93.22 38.332 14.502 4.476 28.186 7.851 40.132 9.677 11.618 1.775 22.899 2.3 31.927 0.038 26.355-6.588 43.81-29.274 54.827-51.196 8.708-17.318 14.374-36.066 17.566-51.166 0.597-0.294 1.165-0.623 1.69-0.981 2.321-3.465 3.494-9.173 3.345-10.859-0.149-0.789-0.474-1.997-0.623-2.428-0.26-0.717-0.529-1.259-0.597-1.399-0.115-0.23-0.218-0.418-0.282-0.533-0.124-0.23-0.247-0.435-0.329-0.572-0.307-0.525-0.755-1.225-1.242-1.988-2.031-3.149-6.353-9.634-12.454-18.377l-8.218-12.326-0.085 0.585z", - "M812.535 593.212c5.696 0 10.257 2.697 13.026 4.71 3.042 2.214 5.841 5.026 8.294 7.829 4.932 5.636 9.822 12.809 14.007 19.507 4.245 6.788 8.034 13.525 10.743 18.534 1.361 2.513 2.466 4.612 3.23 6.093l0.892 1.732 0.239 0.474 0.064 0.132 0.021 0.038 2.91 5.82-2.901 5.803c-11.085 22.165-30.345 55.407-52.335 83.315-10.982 13.935-22.98 26.97-35.311 36.638-12.066 9.463-26.044 16.956-40.725 16.956-9.199 0-18.897-3.081-28.194-7.428-9.476-4.425-19.511-10.628-29.649-17.916-20.288-14.583-41.967-34.214-61.794-54.865-19.836-20.663-38.148-42.697-51.601-62.242-6.716-9.762-12.382-19.136-16.422-27.58-3.861-8.068-6.946-16.717-6.946-24.576v-12.975h272.452zM851.46 658.078l11.593-5.815c0 0 0.009 0.017-11.593 5.815z", - "M488.192 100.203l-298.4 493.009c-25.129 41.515 32.776 155.686 64.87 207.582l337.321-557.877c0-134.929-69.193-151.362-103.791-142.713z", - "M496.329 111.817l-295.437 488.11c-3.936 6.507-5.496 17.719-3.226 34.163 2.208 15.987 7.727 34.748 15.225 54.396 11.748 30.788 27.869 62.515 41.877 87.057l324.223-536.216c-0.602-63.161-17.118-95.872-34.714-112.061-15.842-14.575-34.33-17.4-47.949-15.449zM561.847 108.17c25.318 23.294 43.11 65.333 43.11 134.746v3.617l-350.171 579.13-11.159-18.048c-16.34-26.419-39.261-68.685-54.98-109.879-7.849-20.57-14.1-41.382-16.685-60.1-2.522-18.261-1.897-36.89 6.731-51.145l301.166-497.579 5.188-1.296c20.774-5.195 51.43-2.787 76.8 20.553z", - "M617.933 307.786l-103.795 168.66c-10.377 16.866-4.322 41.626 0 51.895h207.586l25.946-51.895c0-103.791-86.494-155.686-129.737-168.66z", - "M612.109 292.494l9.549 2.865c45.683 13.705 138.987 68.914 138.987 181.087v3.063l-30.903 61.807h-224.218l-3.341-7.94c-2.645-6.285-5.615-16.393-6.49-27.486-0.866-10.961 0.183-24.525 7.398-36.245l109.018-177.151zM623.548 323.414l-98.359 159.833c-3.166 5.146-4.279 12.395-3.631 20.599 0.329 4.139 1.071 8.102 1.946 11.52h190.199l20.966-41.929c-1.532-86.647-69.111-133.952-111.121-150.024z" + "M474.509 764.894v-598.494c0-21.208 16.785-38.4 37.491-38.4s37.491 17.192 37.491 38.4v598.494l154.509-158.259c14.639-14.997 38.379-14.997 53.018 0 14.643 14.997 14.643 39.309 0 54.306l-212.075 217.225c-1.092 1.118-2.231 2.15-3.418 3.102-6.861 8.969-17.536 14.733-29.525 14.733s-22.665-5.764-29.525-14.733c-1.186-0.951-2.325-1.984-3.418-3.102l-212.076-217.225c-14.641-14.997-14.641-39.309 0-54.306s38.378-14.997 53.019 0l154.509 158.259z" ], - "attrs": [{}, {}, {}, {}, {}, {}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["keystone"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}, {}, {}] } + "tags": ["arrow-down-1"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}, {}, {}, {}, {}, {}], - "properties": { "order": 123, "id": 95, "name": "keystone", "prevSize": 32, "code": 59656 }, + "attrs": [{}], + "properties": { "order": 265, "id": 109, "name": "arrow-down-1", "prevSize": 32, "code": 59665 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 8 }, { "icon": { "paths": [ - "M0 383.312h233.335v225.015h-233.335v-225.015z", - "M404.647 772.372h233.336v225.014h-233.336v-225.014z", - "M0 155.611c0-85.942 69.669-155.611 155.611-155.611h77.724v216.677h-233.335v-61.066z", - "M0 841.836c0 85.944 69.669 155.612 155.611 155.612h77.724v-216.678h-233.335v61.066z", - "M1023.949 841.836c0 85.944-69.668 155.612-155.612 155.612h-77.722v-216.678h233.334v61.066z", - "M390.647 0h508.866c68.751 0 124.488 55.735 124.488 124.489v483.87h-633.353v-608.358z" + "M633.237 760.567c-9.011 9.911-23.625 9.911-32.64 0l-209.837-230.63c-9.014-9.907-9.014-25.967 0-35.874l209.837-230.633c9.015-9.907 23.629-9.907 32.644 0 9.011 9.908 9.011 25.97 0 35.878l-193.515 212.692 193.51 212.693c9.015 9.907 9.015 25.967 0 35.874z", + "M649.020 774.925c-17.476 19.213-46.724 19.213-64.201 0l-209.838-230.63c-16.419-18.044-16.419-46.545 0-64.589l209.838-230.632c17.476-19.21 46.724-19.21 64.201 0 16.418 18.046 16.418 46.545 0 64.591l-180.454 198.335 180.454 198.336c16.418 18.044 16.418 46.545 0 64.589zM616.917 746.658c0.038-0.017 0.239-0.111 0.542-0.444 0.619-0.683 1.207-1.916 1.207-3.584s-0.589-2.901-1.207-3.58l-193.514-212.693c-7.405-8.141-7.405-20.574 0-28.715l193.514-212.691c0.619-0.68 1.207-1.913 1.207-3.582s-0.589-2.902-1.207-3.582c-0.303-0.335-0.503-0.428-0.542-0.444-0.034 0.016-0.235 0.109-0.542 0.444l-209.836 230.629c-0.619 0.683-1.207 1.916-1.207 3.584s0.588 2.901 1.207 3.584l209.836 230.63c0.307 0.333 0.508 0.427 0.542 0.444z" ], - "attrs": [{}, {}, {}, {}, {}, {}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["ledger"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}, {}] } + "tags": ["arrow-left"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}, {}, {}, {}, {}, {}], - "properties": { "order": 124, "id": 94, "name": "ledger", "prevSize": 32, "code": 59657 }, + "attrs": [{}, {}], + "properties": { "order": 266, "id": 108, "name": "arrow-left", "prevSize": 32, "code": 59666 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 9 }, { "icon": { "paths": [ - "M992.614 100.156c4.012-23.876 4.003-23.877 3.994-23.878l17.813 2.798 2.895 17.217c0-0.009 0-0.017-24.702 3.863z", - "M6.683 96.311l-0.014 0.085-0.032 0.194-0.106 0.661-0.364 2.385c-0.303 2.051-0.719 5.011-1.192 8.786-0.946 7.549-2.125 18.376-3.098 31.74-1.942 26.684-3.079 63.708 0.178 105.067 6.437 81.744 30.398 185.086 104.557 256.765 55.461 53.606 129.141 79.951 197.454 92.463 55.22 10.114 108.439 11.469 148.772 10.175v288c0 30.32 25.431 54.9 56.803 54.9s56.804-24.581 56.804-54.9v-288.126c39.913 1.517 93.496 0.545 149.397-9.281 69.483-12.214 144.984-38.54 201.537-93.203 74.166-71.687 98.127-175.040 104.569-256.793 3.254-41.364 2.118-78.392 0.177-105.079-0.973-13.366-2.155-24.194-3.1-31.743-0.475-3.776-0.889-6.736-1.192-8.787l-0.363-2.385-0.107-0.661-0.033-0.194-0.014-0.085-24.702 3.863 3.994-23.878-0.968-0.147-2.472-0.352c-2.122-0.293-5.185-0.695-9.090-1.152-7.81-0.915-19.014-2.054-32.842-2.995-27.611-1.878-65.918-2.977-108.712 0.172-84.582 6.222-191.511 29.385-265.677 101.072-28.416 27.462-49.413 59.537-64.875 93.124-15.458-33.584-36.454-65.657-64.865-93.117-74.159-71.679-181.076-94.839-265.647-101.060-42.79-3.148-81.095-2.049-108.702-0.172-13.826 0.94-25.028 2.080-32.838 2.994-3.906 0.457-6.968 0.859-9.090 1.152l-2.468 0.352-0.684 0.102-0.2 0.031-0.088 0.014 3.995 23.878c-4.011-23.855-4.004-23.877-3.995-23.878l-17.813 2.798-2.895 17.217c0.001-0.009 0.022-0.013 24.703 3.863l-24.703-3.863zM851.144 181.295c21.267-1.564 41.365-1.884 59.127-1.55 0.344 17.168 0.014 36.593-1.606 57.149-5.739 72.92-26.382 143.758-71.619 187.481s-118.523 63.677-193.969 69.227c-21.267 1.564-41.365 1.884-59.127 1.55-0.344-17.169-0.014-36.593 1.606-57.149 5.739-72.92 26.387-143.758 71.619-187.481 45.238-43.724 118.528-63.677 193.969-69.227zM115.333 236.894c-1.618-20.549-1.948-39.968-1.603-57.132 17.758-0.334 37.848-0.014 59.109 1.55 75.433 5.549 148.709 25.499 193.938 69.215s65.869 114.542 71.61 187.453c1.618 20.549 1.948 39.968 1.604 57.132-17.758 0.334-37.848 0.014-59.109-1.55-75.433-5.549-148.709-25.499-193.938-69.215s-65.869-114.542-71.61-187.453z" + "M390.761 263.43c9.014-9.907 23.629-9.907 32.643 0l209.834 230.633c9.015 9.907 9.015 25.967 0 35.874l-209.834 230.63c-9.014 9.911-23.629 9.911-32.643 0-9.014-9.907-9.014-25.967 0-35.874l193.513-212.693-193.513-212.692c-9.014-9.908-9.014-25.97 0-35.878z", + "M374.981 249.074c17.477-19.21 46.724-19.21 64.2 0l209.839 230.632c16.418 18.044 16.418 46.545 0 64.589l-209.839 230.63c-17.475 19.213-46.723 19.213-64.2 0-16.419-18.044-16.419-46.545 0-64.589l180.454-198.336-180.454-198.335c-16.419-18.046-16.419-46.545 0-64.591zM407.081 277.343c0 0-0.236 0.109-0.541 0.444-0.619 0.68-1.207 1.912-1.207 3.582s0.588 2.902 1.207 3.582l193.516 212.691c7.403 8.141 7.403 20.574 0 28.715l-193.516 212.693c-0.619 0.678-1.207 1.911-1.207 3.58s0.588 2.901 1.207 3.584c0.305 0.333 0.541 0.444 0.541 0.444s0.238-0.111 0.543-0.444l209.836-230.63c0.619-0.683 1.207-1.916 1.207-3.584s-0.589-2.901-1.207-3.584l-209.836-230.629c-0.305-0.335-0.543-0.444-0.543-0.444z" ], "attrs": [{}, {}], + "grid": 0, + "tags": ["arrow-right"], "isMulticolor": false, "isMulticolor2": false, - "grid": 0, - "tags": ["seed"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, "attrs": [{}, {}], - "properties": { "order": 114, "id": 93, "name": "seed", "prevSize": 32, "code": 59658 }, + "properties": { "order": 375, "id": 107, "name": "arrow-right", "prevSize": 32, "code": 59667 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 10 }, { "icon": { "paths": [ - "M677.197 309.028l142.349-142.351M900.89 85.333v0zM510.033 476.19c21.001 20.719 37.696 45.389 49.122 72.589s17.361 56.393 17.459 85.892c0.098 29.504-5.636 58.735-16.883 86.007-11.243 27.277-27.772 52.058-48.631 72.917-20.864 20.864-45.645 37.393-72.922 48.636-27.273 11.243-56.503 16.981-86.005 16.883s-58.693-6.033-85.893-17.459c-27.199-11.426-51.869-28.122-72.591-49.122-40.748-42.189-63.295-98.692-62.785-157.346s24.035-114.756 65.51-156.233c41.475-41.476 97.58-65.001 156.232-65.51s115.159 22.037 157.348 62.784l0.038-0.038zM510.033 476.19v0zM677.197 309.028l122.014 122.016 142.353-142.352-122.018-122.016-142.349 142.351z" - ], - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } + "M637.966 589.86l166.614-169.279c49.174-49.174 131.092-49.174 180.266 0s49.174 131.092 0 180.266l-379.615 384.968c-24.534 24.532-57.388 38.185-90.134 38.185s-65.491-13.654-90.132-38.185l-387.726-384.968c-54.613-54.612-49.174-147.411 16.426-193.92 51.839-38.185 125.653-27.306 169.278 19.094l144.746 141.971 16.428 2.774v-125.654l-0.108-319.459c0-71.041 57.388-125.654 125.654-125.654 73.814 0 128.428 57.387 128.428 125.654l-0.125 464.206z" ], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["key"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + "tags": ["arrow-sort"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } - ], - "properties": { "order": 141, "id": 92, "name": "key", "prevSize": 32, "code": 59659 }, + "attrs": [{}], + "properties": { "order": 268, "id": 106, "name": "arrow-sort", "prevSize": 32, "code": 59668 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 11 }, { "icon": { "paths": [ - "M814.289 306.612c-166.95-163.458-437.63-163.458-604.58 0l-22.054 21.592c-8.347 8.173-8.347 21.424 0 29.597l68.733 67.296c4.174 4.087 10.941 4.087 15.114 0l29.611-28.992c116.468-114.033 305.303-114.033 421.77 0l27.652 27.072c4.173 4.087 10.94 4.087 15.113 0l68.732-67.296c8.35-8.173 8.35-21.424 0-29.597l-20.092-19.673zM1017.591 505.681l-61.171-59.895c-8.346-8.171-21.879-8.171-30.229 0l-195.767 191.676c-2.086 2.039-5.47 2.039-7.556 0l-195.772-191.68c-8.35-8.171-21.884-8.171-30.229 0l-195.763 191.68c-2.087 2.044-5.47 2.044-7.557 0l-195.772-191.68c-8.348-8.171-21.882-8.171-30.229 0l-61.173 59.895c-8.348 8.171-8.348 21.423 0 29.598l275.84 270.067c8.348 8.175 21.882 8.175 30.229 0l195.765-191.671c2.086-2.044 5.47-2.044 7.556 0l195.767 191.671c8.35 8.175 21.884 8.175 30.229 0l275.831-270.067c8.35-8.171 8.35-21.423 0-29.594z" + "M263.43 633.237c-9.907-9.011-9.907-23.625 0-32.64l230.633-209.837c9.907-9.014 25.967-9.014 35.874 0l230.63 209.837c9.911 9.015 9.911 23.629 0 32.64-9.907 9.015-25.967 9.015-35.874 0l-212.693-193.51-212.692 193.51c-9.908 9.015-25.97 9.015-35.878 0z", + "M479.706 374.981c18.044-16.419 46.545-16.419 64.589 0l230.63 209.838c19.213 17.476 19.213 46.724 0 64.201-18.044 16.418-46.545 16.418-64.589 0l-198.336-180.454-198.335 180.454c-18.046 16.418-46.545 16.418-64.591 0-19.21-17.476-19.21-46.724 0-64.201l230.632-209.838zM512 405.333c-1.668 0-2.901 0.588-3.584 1.207l-230.629 209.836c-0.335 0.307-0.428 0.508-0.444 0.542 0.016 0.034 0.109 0.239 0.444 0.542 0.68 0.619 1.912 1.207 3.582 1.207s2.902-0.589 3.582-1.207l212.691-193.514c8.141-7.405 20.574-7.405 28.715 0l212.693 193.514c0.678 0.619 1.911 1.207 3.58 1.207s2.901-0.589 3.584-1.207c0.333-0.303 0.427-0.503 0.444-0.542-0.017-0.034-0.111-0.235-0.444-0.542l-230.63-209.836c-0.683-0.619-1.916-1.207-3.584-1.207z" ], - "attrs": [{}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["dapp-logo"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["arrow-up"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}], - "properties": { "order": 112, "id": 91, "name": "dapp-logo", "prevSize": 32, "code": 59660 }, + "attrs": [{}, {}], + "properties": { "order": 269, "id": 105, "name": "arrow-up", "prevSize": 32, "code": 59669 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 12 }, { "icon": { "paths": [ - "M285.865 780.378c16.64 16.64 43.52 16.64 60.16 0l165.975-165.12 165.547 165.547c16.64 16.64 43.52 16.64 60.16 0s16.64-43.52 0-60.16l-195.84-195.84c-3.947-3.959-8.636-7.095-13.798-9.237s-10.697-3.243-16.282-3.243c-5.589 0-11.123 1.101-16.286 3.243-5.158 2.142-9.847 5.278-13.794 9.237l-195.841 195.413c-16.64 16.64-16.64 43.52 0 60.16z", - "M285.865 499.2c16.64 16.64 43.52 16.64 60.16 0l165.975-165.121 165.547 165.548c16.64 16.64 43.52 16.64 60.16 0s16.64-43.52 0-60.16l-195.84-195.841c-3.947-3.956-8.636-7.095-13.798-9.236s-10.697-3.244-16.282-3.244c-5.589 0-11.123 1.102-16.286 3.244-5.158 2.141-9.847 5.28-13.794 9.236l-195.841 195.415c-16.64 16.64-16.64 43.52 0 60.16z" + "M764.894 549.491h-598.494c-21.208 0-38.4-16.785-38.4-37.491s17.192-37.491 38.4-37.491h598.494l-158.259-154.509c-14.997-14.641-14.997-38.379 0-53.019s39.309-14.641 54.306 0l217.225 212.076c1.118 1.092 2.15 2.231 3.102 3.418 8.969 6.861 14.733 17.536 14.733 29.525s-5.764 22.665-14.733 29.525c-0.951 1.186-1.984 2.325-3.102 3.418l-217.225 212.075c-14.997 14.643-39.309 14.643-54.306 0-14.997-14.639-14.997-38.379 0-53.018l158.259-154.509z" ], - "attrs": [{}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["double-arrow"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["arrrow-right"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}], - "properties": { "order": 111, "id": 90, "name": "double-arrow", "prevSize": 32, "code": 59661 }, + "attrs": [{}], + "properties": { "order": 270, "id": 104, "name": "arrrow-right", "prevSize": 32, "code": 59670 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 13 }, { "icon": { "paths": [ - "M637.966 589.86l166.614-169.279c49.174-49.174 131.092-49.174 180.266 0s49.174 131.092 0 180.266l-379.615 384.968c-24.534 24.532-57.388 38.185-90.134 38.185s-65.491-13.654-90.132-38.185l-387.726-384.968c-54.613-54.612-49.174-147.411 16.426-193.92 51.839-38.185 125.653-27.306 169.278 19.094l144.746 141.971 16.428 2.774v-125.654l-0.108-319.459c0-71.041 57.388-125.654 125.654-125.654 73.814 0 128.428 57.387 128.428 125.654l-0.125 464.206z" + "M494.656 89.428c10.918-5.46 23.77-5.46 34.688 0l387.874 193.939c13.141 6.57 21.44 20.001 21.44 34.693s-8.299 28.122-21.44 34.693l-387.874 193.939c-10.918 5.461-23.77 5.461-34.688 0l-387.873-193.939c-13.14-6.57-21.441-20.001-21.441-34.693s8.3-28.123 21.441-34.693l387.873-193.939zM210.86 318.061l301.14 150.573 301.141-150.573-301.141-150.573-301.14 150.573zM89.437 494.652c9.58-19.157 32.879-26.927 52.038-17.344l370.525 185.267 370.526-185.267c19.157-9.583 42.458-1.813 52.036 17.344 9.583 19.162 1.813 42.462-17.344 52.041l-387.874 193.941c-10.918 5.457-23.77 5.457-34.688 0l-387.873-193.941c-19.16-9.579-26.926-32.879-17.346-52.041zM89.437 688.593c9.58-19.162 32.879-26.927 52.038-17.348l370.525 185.267 370.526-185.267c19.157-9.579 42.458-1.813 52.036 17.348 9.583 19.162 1.813 42.458-17.344 52.041l-387.874 193.937c-10.918 5.461-23.77 5.461-34.688 0l-387.873-193.937c-19.16-9.583-26.926-32.879-17.346-52.041z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["arrow-sort"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["batch"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 110, "id": 89, "name": "arrow-sort", "prevSize": 32, "code": 59662 }, + "properties": { "order": 271, "id": 103, "name": "batch", "prevSize": 32, "code": 59671 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 14 }, { "icon": { "paths": [ - "M438.907 768.411c40.424 37.378 105.859 37.378 146.186 0l408.412-377.63c65.145-60.235 19-163.226-73.093-163.226l-816.824 0.090c-92.093 0-138.238 102.991-73.093 163.136l408.412 377.63z" + "M512 113.777c67.895 0 133.009 26.971 181.018 74.981s74.982 113.124 74.982 181.019c0 118.159 25.259 190.991 48.124 232.902 11.486 21.065 22.605 34.756 30.182 42.752 3.797 4.011 6.737 6.622 8.422 8.026 0.823 0.687 1.353 1.088 1.536 1.225 15.223 10.526 21.948 29.696 16.567 47.458-5.449 17.997-22.033 30.306-40.832 30.306h-640.002c-18.802 0-35.388-12.309-40.836-30.306-5.378-17.762 1.347-36.932 16.569-47.458 0.186-0.137 0.713-0.538 1.536-1.225 1.687-1.404 4.625-4.015 8.424-8.026 7.575-7.996 18.694-21.687 30.184-42.752 22.862-41.911 48.124-114.743 48.124-232.902 0-67.895 26.971-133.010 74.98-181.019s113.126-74.981 181.021-74.981zM167.731 654.682l0.228-0.154 0.167-0.115 0.098-0.064 0.108-0.073-0.6 0.405zM282.788 643.541c-0.657 1.203-1.315 2.394-1.973 3.571h462.366c-0.657-1.178-1.314-2.368-1.971-3.571-30.473-55.863-58.543-143.031-58.543-273.764 0-45.264-17.984-88.674-49.988-120.68-32.009-32.006-75.418-49.987-120.678-49.987-45.265 0-88.675 17.981-120.681 49.987s-49.987 75.416-49.987 120.68c0 130.732-28.072 217.9-58.543 273.764zM487.398 810.59c-11.823-20.382-37.935-27.319-58.317-15.497s-27.321 37.935-15.497 58.317c10.002 17.242 24.359 31.552 41.63 41.502s36.855 15.185 56.789 15.185c19.93 0 39.514-5.235 56.785-15.185 17.276-9.95 31.629-24.26 41.63-41.502 11.823-20.382 4.885-46.494-15.497-58.317s-46.494-4.885-58.317 15.497c-2.5 4.314-6.089 7.889-10.406 10.377s-9.212 3.797-14.195 3.797c-4.983 0-9.882-1.31-14.199-3.797s-7.906-6.063-10.406-10.377z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["dropdown-arrow-small"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["bell"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 109, "id": 88, "name": "dropdown-arrow-small", "prevSize": 32, "code": 59663 }, + "properties": { "order": 272, "id": 102, "name": "bell", "prevSize": 32, "code": 59672 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 15 }, { "icon": { "paths": [ - "M512 245.68c-49.939 0-90.422-40.483-90.422-90.422s40.483-90.422 90.422-90.422c49.939 0 90.422 40.483 90.422 90.422s-40.484 90.422-90.422 90.422z", - "M512 603.258c-49.939 0-90.422-40.483-90.422-90.422s40.483-90.422 90.422-90.422c49.939 0 90.422 40.483 90.422 90.422s-40.484 90.422-90.422 90.422z", - "M512 960.838c-49.939 0-90.422-40.486-90.422-90.426s40.483-90.419 90.422-90.419c49.939 0 90.422 40.48 90.422 90.419s-40.483 90.426-90.422 90.426z" + "M512 853.333c-78.763 0-151.125-27.093-208.939-72.021l478.251-478.251c44.971 57.771 72.021 130.176 72.021 208.939 0 188.203-153.131 341.333-341.333 341.333zM170.667 512c0-188.203 153.131-341.333 341.333-341.333 78.763 0 151.125 27.093 208.939 72.021l-478.251 478.251c-44.971-57.771-72.021-130.176-72.021-208.939zM512 85.333c-235.264 0-426.667 191.403-426.667 426.667s191.403 426.667 426.667 426.667c235.264 0 426.667-191.403 426.667-426.667s-191.403-426.667-426.667-426.667z" ], - "attrs": [{}, {}, {}], + "attrs": [{ "fill": "rgb(255, 95, 114)" }], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["options-vertical"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + "tags": ["block"], + "colorPermutations": { "11611631671255214012559511412828281": [{ "f": 4 }] } }, - "attrs": [{}, {}, {}], - "properties": { "order": 108, "id": 87, "name": "options-vertical", "prevSize": 32, "code": 59664 }, + "attrs": [{ "fill": "rgb(255, 95, 114)" }], + "properties": { "order": 273, "id": 101, "name": "block", "prevSize": 32, "code": 59673 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 16 }, { "icon": { "paths": [ - "M879.596 448c44.408 0 80.408 36 80.408 80.408v1.103c0 44.408-36 80.408-80.408 80.408s-80.408-36-80.408-80.408v-1.103c0-44.408 36-80.408 80.408-80.408z", - "M512.002 448c44.408 0 80.408 36 80.408 80.408v1.103c0 44.408-36 80.408-80.408 80.408s-80.408-36-80.408-80.408v-1.103c0-44.408 36-80.408 80.408-80.408z", - "M144.408 448c44.408 0 80.408 36 80.408 80.408v1.103c0 44.408-36 80.408-80.408 80.408s-80.408-36-80.408-80.408v-1.103c0-44.408 36-80.408 80.408-80.408z" + "M234.667 192h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z", + "M234.667 576h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z", + "M618.667 192h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z", + "M618.667 576h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z" ], - "attrs": [{}, {}, {}], + "attrs": [{}, {}, {}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["options-horizontal"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + "tags": ["blocks"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}, {}] } }, - "attrs": [{}, {}, {}], - "properties": { "order": 107, "id": 86, "name": "options-horizontal", "prevSize": 32, "code": 59665 }, + "attrs": [{}, {}, {}, {}], + "properties": { "order": 274, "id": 100, "name": "blocks", "prevSize": 32, "code": 59674 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 17 }, { "icon": { "paths": [ - "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM170.667 512c0 188.514 152.82 341.333 341.333 341.333s341.333-152.819 341.333-341.333c0-188.513-152.819-341.333-341.333-341.333s-341.333 152.82-341.333 341.333z", - "M450.999 682.667c-8.107 0-15.876-3.584-21.572-9.997l-121.852-136.627c-11.884-13.342-11.884-34.94 0.030-48.282 11.914-13.274 31.202-13.308 43.116 0.034l100.278 112.465 222.315-248.921c11.913-13.341 31.168-13.341 43.085 0 11.913 13.342 11.913 34.907 0 48.249l-243.857 273.081c-5.696 6.413-13.436 9.997-21.542 9.997z", - "M450.999 682.667v0c-8.107 0-15.876-3.584-21.572-9.997l-121.852-136.627c-11.884-13.342-11.884-34.94 0.030-48.282 11.914-13.274 31.202-13.308 43.116 0.034l100.278 112.465 222.315-248.921c11.913-13.341 31.168-13.341 43.085 0 11.913 13.342 11.913 34.907 0 48.249l-243.857 273.081c-5.696 6.413-13.436 9.997-21.542 9.997" - ], - "attrs": [ - {}, - {}, - { - "fill": "none", - "stroke": "rgb(0, 0, 0)", - "strokeLinejoin": "miter", - "strokeLinecap": "butt", - "strokeMiterlimit": "4", - "strokeWidth": 21.333333333333332 - } + "M234.667 192h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z", + "M234.667 576h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z", + "M618.667 192h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z", + "M618.667 576h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z" ], + "attrs": [{}, {}, {}, {}], "isMulticolor": false, - "isMulticolor2": true, + "isMulticolor2": false, "grid": 0, - "tags": ["check-oulined"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, { "s": 0 }] } + "tags": ["blocks-1"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}, {}] } }, - "attrs": [ - {}, - {}, - { - "fill": "none", - "stroke": "rgb(0, 0, 0)", - "strokeLinejoin": "miter", - "strokeLinecap": "butt", - "strokeMiterlimit": "4", - "strokeWidth": 21.333333333333332 - } - ], - "properties": { "order": 131, "id": 85, "name": "check-oulined", "prevSize": 32, "code": 59666 }, + "attrs": [{}, {}, {}, {}], + "properties": { "order": 275, "id": 99, "name": "blocks-1", "prevSize": 32, "code": 59675 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 18 }, { "icon": { "paths": [ - "M422.393 725.333c-7.945 0-15.532-3.2-21.117-8.93l-179.184-182.946c-11.679-11.913-11.679-31.206 0-43.089 11.679-11.917 30.556-11.917 42.234 0l158.066 161.395 337.278-344.16c11.682-11.916 30.558-11.916 42.236 0s11.678 31.177 0 43.092l-358.396 365.707c-5.585 5.73-13.171 8.93-21.118 8.93z", - "M422.393 725.333v0c-7.945 0-15.532-3.2-21.117-8.93l-179.184-182.946c-11.679-11.913-11.679-31.206 0-43.089 11.679-11.917 30.556-11.917 42.234 0l158.066 161.395 337.278-344.16c11.682-11.916 30.558-11.916 42.236 0s11.678 31.177 0 43.092l-358.396 365.707c-5.585 5.73-13.171 8.93-21.118 8.93" - ], - "attrs": [ - {}, - { - "fill": "none", - "stroke": "rgb(0, 0, 0)", - "strokeLinejoin": "miter", - "strokeLinecap": "butt", - "strokeMiterlimit": "4", - "strokeWidth": 42.666666666666664 - } + "M325.333 204.8c-9.901 0-19.397 4.046-26.399 11.247s-10.935 16.969-10.935 27.153v539.78l202.3-148.629c12.983-9.536 30.417-9.536 43.401 0l202.3 148.629v-539.78c0-10.184-3.934-19.951-10.935-27.153s-16.495-11.247-26.398-11.247h-373.333zM246.137 161.741c21.004-21.604 49.492-33.741 79.196-33.741h373.333c29.705 0 58.193 12.137 79.198 33.741 21.001 21.604 32.802 50.906 32.802 81.459v614.4c0 14.383-7.817 27.563-20.25 34.142-12.437 6.583-27.405 5.466-38.784-2.893l-239.633-176.060-239.634 176.060c-11.38 8.358-26.348 9.476-38.782 2.893-12.435-6.579-20.25-19.759-20.25-34.142v-614.4c0-30.553 11.8-59.855 32.804-81.459z" ], + "attrs": [{}], "isMulticolor": false, - "isMulticolor2": true, + "isMulticolor2": false, "grid": 0, - "tags": ["check"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, { "s": 0 }] } + "tags": ["bookmark"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [ - {}, - { - "fill": "none", - "stroke": "rgb(0, 0, 0)", - "strokeLinejoin": "miter", - "strokeLinecap": "butt", - "strokeMiterlimit": "4", - "strokeWidth": 42.666666666666664 - } - ], - "properties": { "order": 132, "id": 84, "name": "check", "prevSize": 32, "code": 59667 }, + "attrs": [{}], + "properties": { "order": 276, "id": 98, "name": "bookmark", "prevSize": 32, "code": 59676 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 19 }, { "icon": { "paths": [ - "M938.667 512c0-235.642-191.027-426.667-426.667-426.667-235.642 0-426.667 191.025-426.667 426.667 0 235.639 191.025 426.667 426.667 426.667 235.639 0 426.667-191.027 426.667-426.667zM395.726 456.499l96.366 90.159 139.785-143.341c24.678-25.306 65.199-25.815 90.505-1.137s25.813 65.195 1.135 90.5l-183.552 188.224c-24.316 24.939-64.111 25.852-89.545 2.052l-142.144-132.992c-25.81-24.149-27.158-64.649-3.009-90.458s64.649-27.157 90.46-3.008z" + "M246.137 161.741c21.004-21.604 49.492-33.741 79.196-33.741h373.333c29.705 0 58.193 12.137 79.198 33.741 21.001 21.604 32.802 50.906 32.802 81.459v614.4c0 14.383-7.817 27.563-20.25 34.142-12.437 6.583-27.405 5.466-38.784-2.893l-239.633-176.060-239.634 176.060c-11.38 8.358-26.348 9.476-38.782 2.893-12.435-6.579-20.25-19.759-20.25-34.142v-614.4c0-30.553 11.8-59.855 32.804-81.459z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["check-filled"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["bookmark-filled"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 104, "id": 83, "name": "check-filled", "prevSize": 32, "code": 59668 }, + "properties": { "order": 277, "id": 97, "name": "bookmark-filled", "prevSize": 32, "code": 59677 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 20 }, { "icon": { "paths": [ - "M474.509 764.894v-598.494c0-21.208 16.785-38.4 37.491-38.4s37.491 17.192 37.491 38.4v598.494l154.509-158.259c14.639-14.997 38.379-14.997 53.018 0 14.643 14.997 14.643 39.309 0 54.306l-212.075 217.225c-1.092 1.118-2.231 2.15-3.418 3.102-6.861 8.969-17.536 14.733-29.525 14.733s-22.665-5.764-29.525-14.733c-1.186-0.951-2.325-1.984-3.418-3.102l-212.076-217.225c-14.641-14.997-14.641-39.309 0-54.306s38.378-14.997 53.019 0l154.509 158.259z" + "M358.4 170.667l-102.4 102.4h-68.267c-56.554 0-102.4 45.846-102.4 102.4v375.612c0 56.555 45.846 102.4 102.4 102.4h648.533c56.555 0 102.4-45.845 102.4-102.4v-375.612c0-56.554-45.845-102.4-102.4-102.4h-68.267l-102.4-102.4h-307.2zM291.346 358.4l102.4-102.4h236.509l102.4 102.4h103.612c9.425 0 17.067 7.641 17.067 17.067v375.612c0 9.425-7.642 17.067-17.067 17.067h-648.533c-9.425 0-17.067-7.642-17.067-17.067v-375.612c0-9.425 7.641-17.067 17.067-17.067h103.613z", + "M512 725.333c-106.039 0-192-85.961-192-192s85.961-192 192-192c106.039 0 192 85.961 192 192s-85.961 192-192 192zM618.667 533.333c0-58.91-47.757-106.667-106.667-106.667s-106.667 47.757-106.667 106.667c0 58.91 47.757 106.667 106.667 106.667s106.667-47.757 106.667-106.667z" ], - "attrs": [{}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["arrow-down-1"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["camera"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}], - "properties": { "order": 103, "id": 82, "name": "arrow-down-1", "prevSize": 32, "code": 59669 }, + "attrs": [{}, {}], + "properties": { "order": 278, "id": 96, "name": "camera", "prevSize": 32, "code": 59678 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 21 }, { "icon": { "paths": [ - "M760.567 390.761c9.911 9.014 9.911 23.629 0 32.643l-230.63 209.834c-9.907 9.015-25.967 9.015-35.874 0l-230.633-209.834c-9.907-9.014-9.907-23.629 0-32.643s25.97-9.014 35.878 0l212.692 193.513 212.693-193.513c9.907-9.014 25.967-9.014 35.874 0z" - ], - "attrs": [ - { - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 42.666666666666664 - } + "M533.222 170.666c-49.591-0.129-98.517 11.458-142.785 33.818l-0.162 0.081c-53.148 26.563-97.851 67.397-129.103 117.931s-47.816 108.769-47.839 168.187l-0 0.094c-0.129 49.596 11.458 98.517 33.818 142.784 5.129 10.155 5.99 21.939 2.393 32.73l-54.082 162.249 162.246-54.084c10.792-3.597 22.575-2.735 32.729 2.394 44.268 22.362 93.193 33.946 142.785 33.818h0.094c59.418-0.021 117.653-16.589 168.188-47.838 50.534-31.253 91.371-75.955 117.931-129.105l0.081-0.162c22.362-44.267 33.95-93.188 33.818-142.784-0.247-94.757-37.461-171.721-92.924-227.185-55.467-55.464-132.429-92.68-227.187-92.927zM352.046 128.275c56.243-28.393 118.397-43.106 181.399-42.941 118.579 0.309 216.806 47.424 287.305 117.92 70.494 70.496 117.611 168.726 117.918 287.302 0.166 63.002-14.545 125.15-42.935 181.393-33.647 67.29-85.359 123.887-149.346 163.456-63.996 39.578-137.741 60.557-212.988 60.595h-0.047l-0.017-42.667 0.111 42.667h-0.047c-56.947 0.141-113.196-11.87-165.014-35.153l-16.419-7.829c5.42 2.739 10.894 5.346 16.419 7.829l-226.89 75.631c-15.331 5.111-32.235 1.118-43.662-10.308s-15.418-28.331-10.307-43.661l75.63-226.893c2.482 5.525 5.092 10.999 7.83 16.422l-7.83-16.422c-23.282-51.814-35.294-108.066-35.153-165.013v0.047l42.667 0.017-42.667-0.111v0.047c0.038-75.243 21.019-148.992 60.596-212.989 39.569-63.984 96.163-115.692 163.449-149.339zM352.046 128.275l-0.081 0.041 19.237 38.084-19.075-38.166-0.081 0.041z" ], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["arrow-down"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + "tags": ["chat"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [ - { - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 42.666666666666664 - } - ], - "properties": { "order": 135, "id": 81, "name": "arrow-down", "prevSize": 32, "code": 59670 }, + "attrs": [{}], + "properties": { "order": 279, "id": 95, "name": "chat", "prevSize": 32, "code": 59679 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 22 }, { "icon": { "paths": [ - "M263.43 633.237c-9.907-9.011-9.907-23.625 0-32.64l230.633-209.837c9.907-9.014 25.967-9.014 35.874 0l230.63 209.837c9.911 9.011 9.911 23.629 0 32.64-9.907 9.015-25.967 9.015-35.874 0l-212.693-193.51-212.692 193.51c-9.908 9.015-25.97 9.015-35.878 0z" - ], - "attrs": [ - { - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 42.666666666666664 - } + "M422.393 725.333c-7.945 0-15.532-3.2-21.117-8.93l-179.184-182.946c-11.679-11.913-11.679-31.206 0-43.089 11.679-11.917 30.556-11.917 42.234 0l158.066 161.395 337.278-344.16c11.682-11.916 30.558-11.916 42.236 0s11.678 31.177 0 43.092l-358.396 365.707c-5.585 5.73-13.171 8.93-21.118 8.93z", + "M786.671 322.536c-3.315-3.381-8.448-3.381-11.763 0l-352.52 359.712-173.297-176.951c-3.314-3.379-8.448-3.379-11.763 0-3.513 3.575-3.559 9.596 0 13.231l179.223 182.985c1.586 1.626 3.671 2.487 5.841 2.487s4.256-0.862 5.844-2.487l0.038-0.038 358.396-365.71c3.55-3.621 3.55-9.607 0-13.228zM744.435 292.671c20.045-20.451 52.663-20.451 72.708 0 19.81 20.211 19.81 52.747 0 72.957l-358.357 365.665c-0.009 0.013-0.021 0.021-0.030 0.034-9.583 9.813-22.656 15.339-36.363 15.339-13.704 0-26.777-5.525-36.358-15.334-0.001 0 0.001 0 0 0-0.011-0.013-0.024-0.026-0.035-0.038l-179.144-182.903c-19.796-20.198-19.825-52.774 0.012-72.969 20.044-20.437 52.655-20.433 72.694 0.013l142.836 145.847 322.037-328.611z" ], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["arrow-up"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + "tags": ["check"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [ - { - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 42.666666666666664 - } - ], - "properties": { "order": 136, "id": 80, "name": "arrow-up", "prevSize": 32, "code": 59671 }, + "attrs": [{}, {}], + "properties": { "order": 280, "id": 94, "name": "check", "prevSize": 32, "code": 59680 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 23 }, { "icon": { "paths": [ - "M633.237 760.567c-9.011 9.911-23.625 9.911-32.64 0l-209.837-230.63c-9.014-9.907-9.014-25.967 0-35.874l209.837-230.633c9.015-9.907 23.629-9.907 32.644 0 9.011 9.908 9.011 25.97 0 35.878l-193.515 212.692 193.51 212.693c9.015 9.907 9.015 25.967 0 35.874z" - ], - "attrs": [ - { - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 42.666666666666664 - } + "M938.667 512c0-235.642-191.027-426.667-426.667-426.667-235.642 0-426.667 191.025-426.667 426.667 0 235.639 191.025 426.667 426.667 426.667 235.639 0 426.667-191.027 426.667-426.667zM395.726 456.499l96.366 90.159 139.785-143.341c24.678-25.306 65.199-25.815 90.505-1.137s25.813 65.195 1.135 90.5l-183.552 188.224c-24.316 24.939-64.111 25.852-89.545 2.052l-142.144-132.992c-25.81-24.149-27.158-64.649-3.009-90.458s64.649-27.157 90.46-3.008z" ], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["arrow-left"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + "tags": ["check-filled"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [ - { - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 42.666666666666664 - } - ], - "properties": { "order": 138, "id": 79, "name": "arrow-left", "prevSize": 32, "code": 59672 }, + "attrs": [{}], + "properties": { "order": 281, "id": 93, "name": "check-filled", "prevSize": 32, "code": 59681 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 24 }, { "icon": { "paths": [ - "M390.761 263.43c9.014-9.907 23.629-9.907 32.643 0l209.834 230.633c9.015 9.907 9.015 25.967 0 35.874l-209.834 230.63c-9.014 9.911-23.629 9.911-32.643 0-9.014-9.907-9.014-25.967 0-35.874l193.513-212.693-193.513-212.692c-9.014-9.908-9.014-25.97 0-35.878z" - ], - "attrs": [ - { - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 42.666666666666664 - } + "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM170.667 512c0 188.514 152.82 341.333 341.333 341.333s341.333-152.819 341.333-341.333c0-188.513-152.819-341.333-341.333-341.333s-341.333 152.82-341.333 341.333z", + "M327.743 438.434c-17.204-16.102-44.204-15.21-60.307 1.993s-15.21 44.207 1.993 60.309l176.685 165.376c16.956 15.868 43.49 15.262 59.703-1.365l228.139-233.984c16.452-16.872 16.111-43.885-0.764-60.335-16.87-16.451-43.883-16.109-60.335 0.763l-198.955 204.050-146.16-136.806z" ], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["arrow-right"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + "tags": ["check-notifications"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [ - { - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 42.666666666666664 - } - ], - "properties": { "order": 137, "id": 78, "name": "arrow-right", "prevSize": 32, "code": 59673 }, + "attrs": [{}, {}], + "properties": { "order": 282, "id": 92, "name": "check-notifications", "prevSize": 32, "code": 59682 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 25 }, { "icon": { "paths": [ - "M873.997 602.462l-271.27 271.339c-7.027 7.040-15.373 12.621-24.555 16.431-9.186 3.81-19.034 5.769-28.979 5.769-9.941 0-19.789-1.958-28.975-5.769s-17.532-9.391-24.559-16.431l-324.992-324.698v-378.436h378.338l324.992 325.077c14.093 14.182 22.003 33.365 22.003 53.359 0 19.998-7.91 39.181-22.003 53.359z", - "M341.333 341.333h0.427" - ], - "attrs": [ - { - "fill": "none", - "stroke": "rgb(161, 163, 167)", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "stroke": "rgb(161, 163, 167)", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } + "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM170.667 512c0 188.514 152.82 341.333 341.333 341.333s341.333-152.819 341.333-341.333c0-188.513-152.819-341.333-341.333-341.333s-341.333 152.82-341.333 341.333z", + "M450.999 682.667c-8.107 0-15.876-3.584-21.572-9.997l-121.852-136.627c-11.884-13.342-11.884-34.94 0.030-48.282 11.914-13.274 31.202-13.308 43.116 0.034l100.278 112.465 222.315-248.921c11.913-13.341 31.168-13.341 43.085 0 11.913 13.342 11.913 34.907 0 48.249l-243.857 273.081c-5.696 6.413-13.436 9.997-21.542 9.997z" ], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["tag"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 3 }, { "s": 3 }] } + "tags": ["check-oulined"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [ - { - "fill": "none", - "stroke": "rgb(161, 163, 167)", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "stroke": "rgb(161, 163, 167)", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } - ], - "properties": { "order": 139, "id": 77, "name": "tag", "prevSize": 32, "code": 59674 }, + "attrs": [{}, {}], + "properties": { "order": 283, "id": 91, "name": "check-oulined", "prevSize": 32, "code": 59683 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 26 }, { "icon": { "paths": [ - "M358.4 170.667l-102.4 102.4h-68.267c-56.554 0-102.4 45.846-102.4 102.4v375.612c0 56.555 45.846 102.4 102.4 102.4h648.533c56.555 0 102.4-45.845 102.4-102.4v-375.612c0-56.554-45.845-102.4-102.4-102.4h-68.267l-102.4-102.4h-307.2zM291.346 358.4l102.4-102.4h236.509l102.4 102.4h103.612c9.425 0 17.067 7.641 17.067 17.067v375.612c0 9.425-7.642 17.067-17.067 17.067h-648.533c-9.425 0-17.067-7.642-17.067-17.067v-375.612c0-9.425 7.641-17.067 17.067-17.067h103.613z", - "M512 725.333c-106.039 0-192-85.961-192-192s85.961-192 192-192c106.039 0 192 85.961 192 192s-85.961 192-192 192zM618.667 533.333c0-58.91-47.757-106.667-106.667-106.667s-106.667 47.757-106.667 106.667c0 58.91 47.757 106.667 106.667 106.667s106.667-47.757 106.667-106.667z" + "M760.567 390.761c9.911 9.014 9.911 23.629 0 32.643l-230.63 209.838c-9.907 9.011-25.967 9.011-35.874 0l-230.633-209.838c-9.907-9.014-9.907-23.629 0-32.643s25.97-9.014 35.878 0l212.692 193.517 212.693-193.517c9.907-9.014 25.967-9.014 35.874 0z", + "M774.925 374.981c19.213 17.478 19.213 46.724 0 64.204l-230.63 209.835c-18.044 16.418-46.545 16.418-64.589 0l-230.632-209.839c-19.21-17.475-19.21-46.723 0-64.2 18.046-16.419 46.545-16.419 64.591 0l198.335 180.454 198.336-180.454c18.044-16.419 46.545-16.419 64.589 0zM746.658 407.081c-0.017-0.036-0.111-0.236-0.444-0.541-0.683-0.619-1.916-1.207-3.584-1.207s-2.901 0.588-3.58 1.207l-212.693 193.516c-8.141 7.403-20.574 7.403-28.715 0l-212.691-193.516c-0.68-0.619-1.913-1.207-3.582-1.207s-2.902 0.588-3.582 1.207c-0.335 0.305-0.428 0.505-0.444 0.541 0.016 0.036 0.109 0.238 0.444 0.543l230.629 209.836c0.683 0.619 1.916 1.207 3.584 1.207s2.901-0.589 3.584-1.207l230.63-209.836c0.333-0.305 0.427-0.507 0.444-0.543z" ], "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["camera"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["chevron-down"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, "attrs": [{}, {}], - "properties": { "order": 100, "id": 76, "name": "camera", "prevSize": 32, "code": 59675 }, + "properties": { "order": 284, "id": 90, "name": "chevron-down", "prevSize": 32, "code": 59684 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 27 }, { "icon": { "paths": [ - "M298.667 512.004c0-47.125 38.205-85.333 85.333-85.333 47.13 0 85.333 38.208 85.333 85.333 0 47.13-38.204 85.333-85.333 85.333-47.128 0-85.333-38.204-85.333-85.333z", - "M554.667 512.004c0-47.125 38.204-85.333 85.333-85.333s85.333 38.208 85.333 85.333c0 47.13-38.204 85.333-85.333 85.333s-85.333-38.204-85.333-85.333z", - "M298.667 213.333c0-47.128 38.205-85.333 85.333-85.333 47.13 0 85.333 38.205 85.333 85.333s-38.204 85.333-85.333 85.333c-47.128 0-85.333-38.205-85.333-85.333z", - "M554.667 213.333c0-47.128 38.204-85.333 85.333-85.333s85.333 38.205 85.333 85.333c0 47.128-38.204 85.333-85.333 85.333s-85.333-38.205-85.333-85.333z", - "M298.667 810.662c0-47.13 38.205-85.333 85.333-85.333 47.13 0 85.333 38.204 85.333 85.333 0 47.125-38.204 85.333-85.333 85.333-47.128 0-85.333-38.208-85.333-85.333z", - "M554.667 810.662c0-47.13 38.204-85.333 85.333-85.333s85.333 38.204 85.333 85.333c0 47.125-38.204 85.333-85.333 85.333s-85.333-38.208-85.333-85.333z" + "M633.237 760.567c-9.011 9.911-23.625 9.911-32.64 0l-209.837-230.63c-9.014-9.907-9.014-25.967 0-35.874l209.837-230.633c9.015-9.907 23.629-9.907 32.644 0 9.011 9.908 9.011 25.97 0 35.878l-193.515 212.692 193.51 212.693c9.015 9.907 9.015 25.967 0 35.874z", + "M649.020 774.925c-17.476 19.213-46.724 19.213-64.201 0l-209.838-230.63c-16.419-18.044-16.419-46.545 0-64.589l209.838-230.632c17.476-19.21 46.724-19.21 64.201 0 16.418 18.046 16.418 46.545 0 64.591l-180.454 198.335 180.454 198.336c16.418 18.044 16.418 46.545 0 64.589zM616.917 746.658c0.038-0.017 0.239-0.111 0.542-0.444 0.619-0.683 1.207-1.916 1.207-3.584s-0.589-2.901-1.207-3.58l-193.514-212.693c-7.405-8.141-7.405-20.574 0-28.715l193.514-212.691c0.619-0.68 1.207-1.913 1.207-3.582s-0.589-2.902-1.207-3.582c-0.303-0.335-0.503-0.428-0.542-0.444-0.034 0.016-0.235 0.109-0.542 0.444l-209.836 230.629c-0.619 0.683-1.207 1.916-1.207 3.584s0.588 2.901 1.207 3.584l209.836 230.63c0.307 0.333 0.508 0.427 0.542 0.444z" ], - "attrs": [{}, {}, {}, {}, {}, {}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["element-drag"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}, {}] } + "tags": ["chevron-left"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}, {}, {}, {}, {}, {}], - "properties": { "order": 99, "id": 75, "name": "element-drag", "prevSize": 32, "code": 59676 }, + "attrs": [{}, {}], + "properties": { "order": 285, "id": 89, "name": "chevron-left", "prevSize": 32, "code": 59685 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 28 }, { "icon": { "paths": [ - "M832.499 512.32c0-176.555-143.543-320.181-320-320.32v-128c239.906 0.135 436.343 189.779 447.501 427.012v-1.557c0.38 7.573 0.576 15.198 0.576 22.865s-0.196 15.287-0.576 22.865v-1.557c-10.807 229.734-195.362 414.784-424.892 426.372h0.013c-0.905 0.047-1.805 0.090-2.709 0.128-6.682 0.299-13.402 0.448-20.156 0.448zM512.499 960v-127.424c176.457-0.141 320-143.765 320-320.256zM512.243 960.576c-7.612 0-15.181-0.192-22.703-0.567-0.055-0.004-0.111-0.004-0.166-0.009h0.013c-229.529-11.588-414.085-196.638-424.889-426.372v1.557c-0.383-7.578-0.576-15.198-0.576-22.865s0.193-15.292 0.576-22.865v1.557c11.156-237.233 207.59-426.877 447.502-427.012v896h0.499z" + "M390.761 263.43c9.014-9.907 23.629-9.907 32.643 0l209.834 230.633c9.015 9.907 9.015 25.967 0 35.874l-209.834 230.63c-9.014 9.911-23.629 9.911-32.643 0-9.014-9.907-9.014-25.967 0-35.874l193.513-212.693-193.513-212.692c-9.014-9.908-9.014-25.97 0-35.878z", + "M374.981 249.074c17.477-19.21 46.724-19.21 64.2 0l209.839 230.632c16.418 18.044 16.418 46.545 0 64.589l-209.839 230.63c-17.475 19.213-46.723 19.213-64.2 0-16.419-18.044-16.419-46.545 0-64.589l180.454-198.336-180.454-198.335c-16.419-18.046-16.419-46.545 0-64.591zM407.081 277.343c0 0-0.236 0.109-0.541 0.444-0.619 0.68-1.207 1.912-1.207 3.582s0.588 2.902 1.207 3.582l193.516 212.691c7.403 8.141 7.403 20.574 0 28.715l-193.516 212.693c-0.619 0.678-1.207 1.911-1.207 3.58s0.588 2.901 1.207 3.584c0.305 0.333 0.541 0.444 0.541 0.444s0.238-0.111 0.543-0.444l209.836-230.63c0.619-0.683 1.207-1.916 1.207-3.584s-0.589-2.901-1.207-3.584l-209.836-230.629c-0.305-0.335-0.543-0.444-0.543-0.444z" ], - "attrs": [{}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["transaction-partial-fill"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["chevron-right"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}], - "properties": { "order": 98, "id": 74, "name": "transaction-partial-fill", "prevSize": 32, "code": 59677 }, + "attrs": [{}, {}], + "properties": { "order": 286, "id": 88, "name": "chevron-right", "prevSize": 32, "code": 59686 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 29 }, { "icon": { "paths": [ - "M341.333 490.667c0-35.345 28.654-64 64-64h426.667c35.345 0 64 28.655 64 64s-28.655 64-64 64h-426.667c-35.346 0-64-28.655-64-64z", - "M341.333 746.667c0-35.345 28.654-64 64-64h426.667c35.345 0 64 28.655 64 64s-28.655 64-64 64h-426.667c-35.346 0-64-28.655-64-64z", - "M341.333 234.667c0-35.346 28.654-64 64-64h426.667c35.345 0 64 28.654 64 64s-28.655 64-64 64h-426.667c-35.346 0-64-28.654-64-64z", - "M256 234.667c0 35.346-28.654 64-64 64s-64-28.654-64-64c0-35.346 28.654-64 64-64s64 28.654 64 64z", - "M256 490.667c0 35.346-28.654 64-64 64s-64-28.654-64-64c0-35.346 28.654-64 64-64s64 28.654 64 64z", - "M256 746.667c0 35.346-28.654 64-64 64s-64-28.654-64-64c0-35.346 28.654-64 64-64s64 28.654 64 64z" - ], - "attrs": [ - { "fill": "rgb(0, 0, 0)" }, - { "fill": "rgb(0, 0, 0)" }, - { "fill": "rgb(0, 0, 0)" }, - { "fill": "rgb(18, 19, 18)" }, - { "fill": "rgb(18, 19, 18)" }, - { "fill": "rgb(18, 19, 18)" } + "M263.43 633.237c-9.907-9.011-9.907-23.625 0-32.64l230.633-209.837c9.907-9.014 25.967-9.014 35.874 0l230.63 209.837c9.911 9.015 9.911 23.629 0 32.64-9.907 9.015-25.967 9.015-35.874 0l-212.693-193.51-212.692 193.51c-9.908 9.015-25.97 9.015-35.878 0z", + "M479.706 374.981c18.044-16.419 46.545-16.419 64.589 0l230.63 209.838c19.213 17.476 19.213 46.724 0 64.201-18.044 16.418-46.545 16.418-64.589 0l-198.336-180.454-198.335 180.454c-18.046 16.418-46.545 16.418-64.591 0-19.21-17.476-19.21-46.724 0-64.201l230.632-209.838zM512 405.333c-1.668 0-2.901 0.588-3.584 1.207l-230.629 209.836c-0.335 0.307-0.428 0.508-0.444 0.542 0.016 0.034 0.109 0.239 0.444 0.542 0.68 0.619 1.912 1.207 3.582 1.207s2.902-0.589 3.582-1.207l212.691-193.514c8.141-7.405 20.574-7.405 28.715 0l212.693 193.514c0.678 0.619 1.911 1.207 3.58 1.207s2.901-0.589 3.584-1.207c0.333-0.303 0.427-0.503 0.444-0.542-0.017-0.034-0.111-0.235-0.444-0.542l-230.63-209.836c-0.683-0.619-1.916-1.207-3.584-1.207z" ], - "isMulticolor": true, + "attrs": [{}, {}], + "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["rows-2"], - "colorPermutations": { - "11611631671181918125521401255951141": [ - { "f": 0 }, - { "f": 0 }, - { "f": 0 }, - { "f": 1 }, - { "f": 1 }, - { "f": 1 } - ] - } - }, - "attrs": [ - { "fill": "rgb(0, 0, 0)" }, - { "fill": "rgb(0, 0, 0)" }, - { "fill": "rgb(0, 0, 0)" }, - { "fill": "rgb(18, 19, 18)" }, - { "fill": "rgb(18, 19, 18)" }, - { "fill": "rgb(18, 19, 18)" } - ], - "properties": { - "order": 97, - "id": 73, - "name": "rows-2", - "prevSize": 32, - "code": 59678, - "codes": [59678, 59679, 59680, 59681, 59682, 59683] + "tags": ["chevron-up"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, + "attrs": [{}, {}], + "properties": { "order": 287, "id": 87, "name": "chevron-up", "prevSize": 32, "code": 59687 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 30 }, { "icon": { "paths": [ - "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM170.667 512c0 188.514 152.82 341.333 341.333 341.333s341.333-152.819 341.333-341.333c0-188.513-152.819-341.333-341.333-341.333s-341.333 152.82-341.333 341.333z", - "M327.74 438.434c-17.204-16.102-44.204-15.21-60.307 1.993s-15.21 44.207 1.993 60.309l176.683 165.376c16.956 15.868 43.49 15.262 59.703-1.365l228.139-233.984c16.452-16.872 16.111-43.885-0.764-60.335-16.87-16.451-43.883-16.109-60.335 0.763l-198.955 204.050-146.159-136.806z" + "M383.876 640.166c-13.781 0-27.307-6.656-35.541-18.987-13.099-19.584-7.808-46.080 11.819-59.179l109.184-72.832v-189.909c0-23.595 19.072-42.667 42.667-42.667s42.667 19.072 42.667 42.667v212.736c0 14.251-7.125 27.605-18.987 35.499l-128.171 85.504c-7.253 4.821-15.531 7.168-23.637 7.168z", + "M512 170.667c-188.203 0-341.333 153.131-341.333 341.333s153.131 341.333 341.333 341.333c188.203 0 341.333-153.131 341.333-341.333s-153.131-341.333-341.333-341.333zM512 938.667c-235.264 0-426.667-191.403-426.667-426.667s191.403-426.667 426.667-426.667c235.264 0 426.667 191.403 426.667 426.667s-191.403 426.667-426.667 426.667z" ], "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["check-notifications"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["clock"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, "attrs": [{}, {}], - "properties": { "order": 96, "id": 72, "name": "check-notifications", "prevSize": 32, "code": 59684 }, + "properties": { "order": 288, "id": 86, "name": "clock", "prevSize": 32, "code": 59688 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 31 }, { "icon": { "paths": [ - "M384 85.333h-213.333c-47.128 0-85.333 38.205-85.333 85.333v213.333c0 47.13 38.205 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.128-38.204-85.333-85.333-85.333zM170.667 384v-213.333h213.333v213.333h-213.333z", - "M384 554.667h-213.333c-47.128 0-85.333 38.204-85.333 85.333v213.333c0 47.13 38.205 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.13-38.204-85.333-85.333-85.333zM170.667 853.333v-213.333h213.333v213.333h-213.333z", - "M853.333 85.333h-213.333c-47.13 0-85.333 38.205-85.333 85.333v213.333c0 47.13 38.204 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.128-38.204-85.333-85.333-85.333zM640 384v-213.333h213.333v213.333h-213.333z", - "M661.333 554.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", - "M917.333 554.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", - "M789.333 682.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", - "M917.333 810.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", - "M661.333 810.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z" + "M566.336 512l318.455 318.46c14.942 14.976 14.942 39.364 0 54.306-14.98 14.98-39.369 14.98-54.345 0l-318.447-318.434-318.446 318.434c-14.979 14.98-39.367 14.98-54.345 0-14.94-14.942-14.94-39.33 0-54.306l318.455-318.46-318.459-318.449c-14.94-14.979-14.94-39.367 0-54.346 14.979-14.94 39.367-14.94 54.345 0l318.449 318.455 318.451-318.455c14.976-14.94 39.364-14.94 54.345 0 14.938 14.979 14.938 39.368 0 54.346l-318.46 318.449z", + "M124.14 124.101c23.305-23.246 61.171-23.246 84.477 0l303.384 303.389 303.364-303.37c23.305-23.246 61.193-23.265 84.497-0.019l0.038 0.039c23.245 23.305 23.245 61.172 0 84.477l-303.394 303.383 303.369 303.373c23.236 23.296 23.283 61.197 0 84.48-23.309 23.309-61.205 23.309-84.514 0l-303.36-303.351-303.361 303.351c-23.309 23.309-61.205 23.309-84.515 0-23.281-23.283-23.254-61.167-0.019-84.459l303.39-303.394-303.374-303.363c-23.245-23.305-23.265-61.191-0.020-84.497l0.039-0.039z" ], - "attrs": [{}, {}, {}, {}, {}, {}, {}, {}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["qr-code-1"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}, {}, {}, {}] } + "tags": ["close"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}, {}, {}, {}, {}, {}, {}, {}], - "properties": { "order": 95, "id": 71, "name": "qr-code-1", "prevSize": 32, "code": 59685 }, + "attrs": [{}, {}], + "properties": { "order": 289, "id": 85, "name": "close", "prevSize": 32, "code": 59689 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 32 }, { "icon": { "paths": [ - "M938.667 170.667v170.667c0 23.564-19.102 42.667-42.667 42.667s-42.667-19.103-42.667-42.667v-170.667h-170.667c-23.565 0-42.667-19.103-42.667-42.667s19.102-42.667 42.667-42.667h170.667c47.13 0 85.333 38.205 85.333 85.333z", - "M170.667 170.667v170.667c0 23.564-19.103 42.667-42.667 42.667s-42.667-19.103-42.667-42.667v-170.667c0-47.128 38.205-85.333 85.333-85.333h170.667c23.564 0 42.667 19.103 42.667 42.667s-19.103 42.667-42.667 42.667h-170.667z", - "M853.333 853.333v-170.667c0-23.565 19.102-42.667 42.667-42.667s42.667 19.102 42.667 42.667v170.667c0 47.13-38.204 85.333-85.333 85.333h-170.667c-23.565 0-42.667-19.102-42.667-42.667s19.102-42.667 42.667-42.667h170.667z", - "M170.667 853.333h170.667c23.564 0 42.667 19.102 42.667 42.667s-19.103 42.667-42.667 42.667h-170.667c-47.128 0-85.333-38.204-85.333-85.333v-170.667c0-23.565 19.103-42.667 42.667-42.667s42.667 19.102 42.667 42.667v170.667z", - "M128 469.333h768c23.564 0 42.667 19.103 42.667 42.667s-19.103 42.667-42.667 42.667h-768c-23.564 0-42.667-19.103-42.667-42.667s19.103-42.667 42.667-42.667z" + "M938.679 511.987c0 235.648-191.061 426.667-426.667 426.667-235.648 0-426.667-191.019-426.667-426.667 0-235.606 191.019-426.667 426.667-426.667 235.605 0 426.667 191.061 426.667 426.667zM572.356 512.004l146.782-146.765c16.597-16.64 16.597-43.733 0-60.373-16.64-16.597-43.733-16.597-60.373 0l-146.765 146.782-146.782-146.782c-16.597-16.597-43.733-16.597-60.331 0-16.597 16.64-16.597 43.733 0 60.373l146.765 146.765-146.765 146.782c-16.597 16.597-16.597 43.733 0 60.331s43.733 16.597 60.331 0l146.782-146.765 146.765 146.765c16.64 16.597 43.733 16.597 60.373 0 16.597-16.597 16.597-43.733 0-60.331l-146.782-146.782z" ], - "attrs": [{}, {}, {}, {}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["scan-1"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}] } + "tags": ["close-filled"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}, {}, {}, {}], - "properties": { "order": 94, "id": 70, "name": "scan-1", "prevSize": 32, "code": 59686 }, + "attrs": [{}], + "properties": { "order": 290, "id": 84, "name": "close-filled", "prevSize": 32, "code": 59690 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 33 }, { "icon": { "paths": [ - "M840.107 597.333c8.576-27.639 13.035-56.393 13.227-85.333v-298.667l-341.333-128-134.828 50.347", - "M201.813 201.812l-31.147 11.52v298.668c0 256 341.333 426.667 341.333 426.667 90.325-47.663 171.507-110.929 239.787-186.88", - "M42.667 42.667l938.667 938.667" - ], - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } + "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM170.667 512c0 188.514 152.82 341.333 341.333 341.333s341.333-152.819 341.333-341.333c0-188.513-152.819-341.333-341.333-341.333s-341.333 152.82-341.333 341.333z", + "M572.339 512l90.509 90.509c16.666 16.661 16.666 43.678 0 60.339-16.661 16.666-43.678 16.666-60.339 0l-90.509-90.509-90.51 90.509c-16.663 16.666-43.677 16.666-60.34 0-16.662-16.661-16.662-43.678 0-60.339l90.511-90.509-90.511-90.51c-16.662-16.663-16.662-43.677 0-60.34s43.677-16.662 60.34 0l90.51 90.511 90.509-90.511c16.661-16.662 43.678-16.662 60.339 0 16.666 16.663 16.666 43.677 0 60.34l-90.509 90.51z" ], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["shield-crossed"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }, { "s": 0 }, { "s": 0 }] } + "tags": ["close-outlined"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } - ], - "properties": { "order": 133, "id": 69, "name": "shield-crossed", "prevSize": 32, "code": 59687 }, + "attrs": [{}, {}], + "properties": { "order": 291, "id": 83, "name": "close-outlined", "prevSize": 32, "code": 59691 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 34 }, { "icon": { "paths": [ - "M498.129 935.945c9.698 3.639 20.386 3.631 30.076-0.030 168.346-63.599 275.204-151.066 328.93-250.807 10.091-18.052 17.984-38.097 24.009-59.78 6.959-25.050 11.2-51.371 13.321-78.067 1.434-18.014 1.745-33.506 1.455-47.334-0.068-2.987-0.068-2.987-0.073-2.688v-272c0-24.709-20.919-44.251-45.572-42.567-21.329 1.457-32.043 1.991-43.9 1.991-111.714 0-203.981-39.909-267.994-90.209-16.474-12.945-39.906-12.014-55.3 2.196-0.922 0.847-2.022 1.75-3.686 3.001-1.28 0.966-2.85 2.105-7.014 5.123-17.788 11.879-28.122 18.392-43.648 26.725-3.437 1.846-6.883 3.627-10.339 5.341-60.663 29.792-126.433 47.823-200.646 47.823-11.919 0-22.554-0.53-43.941-1.991-24.651-1.684-45.574 17.859-45.574 42.567v269.952c-3.589 50.202 9.391 138.615 44.909 199.134 6.782 12.574 15.632 25.626 26.72 39.684 62.988 83.503 163.576 151.36 298.268 201.937zM782.524 643.695l-0.38 0.687c-41.929 78.042-127.91 149.965-269.060 205.888-112.85-44.476-195.069-101.325-245.661-168.384-8.823-11.2-15.002-20.314-19.968-29.483-14.854-25.357-24.553-57.66-29.862-93.828-3.653-24.883-4.827-50.389-4.13-60.429l0.086-228.169c1.385 0.012 2.78 0.017 4.198 0.017 88.57 0 166.714-21.424 238.411-56.636 4.442-2.202 8.7-4.403 12.932-6.676 15.774-8.467 26.914-15.258 42.914-25.824 75.383 51.486 175.313 89.135 294.37 89.135 1.399 0 2.773-0.006 4.139-0.017v227.26c0.038 2.308 0.038 2.308 0.094 4.467 0.23 11.183-0.026 23.915-1.207 38.793-1.719 21.615-5.111 42.675-10.475 61.986-4.331 15.59-9.809 29.461-16.401 41.212z" + "M414.719 41.041c0-8.528 6.876-15.441 15.359-15.441h107.522c8.484 0 15.36 6.913 15.36 15.441v108.089c0 8.528-6.876 15.441-15.36 15.441h-122.881v-123.53z", + "M276.481 180.013c0-8.528 6.877-15.441 15.36-15.441h122.88v123.53c0 8.528-6.877 15.441-15.36 15.441h-122.88v-123.53z", + "M138.241 318.984c0-8.528 6.877-15.441 15.36-15.441h122.88v123.53c0 8.528-6.877 15.441-15.36 15.441h-122.88v-123.53z", + "M0 457.955c0-8.528 6.877-15.441 15.36-15.441h122.88v138.97h-122.88c-8.483 0-15.36-6.912-15.36-15.437v-108.092z", + "M138.241 581.484h122.88c8.483 0 15.36 6.917 15.36 15.442v123.53h-122.88c-8.483 0-15.36-6.912-15.36-15.442v-123.53z", + "M276.481 720.456h122.88c8.483 0 15.36 6.917 15.36 15.442v123.53h-122.88c-8.483 0-15.36-6.912-15.36-15.442v-123.53z", + "M414.719 859.428h122.881c8.484 0 15.36 6.912 15.36 15.442v108.088c0 8.53-6.876 15.442-15.36 15.442h-107.522c-8.483 0-15.359-6.912-15.359-15.442v-123.53z", + "M967.68 41.041c0-8.528-6.876-15.441-15.36-15.441h-107.52c-8.484 0-15.36 6.913-15.36 15.441v108.089c0 8.528 6.876 15.441 15.36 15.441h122.88v-123.53z", + "M1105.92 180.013c0-8.528-6.876-15.441-15.36-15.441h-122.88v123.53c0 8.528 6.876 15.441 15.36 15.441h122.88v-123.53z", + "M1244.16 318.984c0-8.528-6.876-15.441-15.36-15.441h-122.88v123.53c0 8.528 6.876 15.441 15.36 15.441h122.88v-123.53z", + "M1382.4 457.955c0-8.528-6.876-15.441-15.36-15.441h-122.88v138.97h122.88c8.484 0 15.36-6.912 15.36-15.437v-108.092z", + "M1244.16 581.484h-122.88c-8.484 0-15.36 6.917-15.36 15.442v123.53h122.88c8.484 0 15.36-6.912 15.36-15.442v-123.53z", + "M1105.92 720.456h-122.88c-8.484 0-15.36 6.917-15.36 15.442v123.53h122.88c8.484 0 15.36-6.912 15.36-15.442v-123.53z", + "M967.68 859.428h-122.88c-8.484 0-15.36 6.912-15.36 15.442v108.088c0 8.53 6.876 15.442 15.36 15.442h107.52c8.484 0 15.36-6.912 15.36-15.442v-123.53z" ], - "attrs": [{}], + "attrs": [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}], + "width": 1382, "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["shield"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["code-blocks"], + "colorPermutations": { + "11611631671255214012559511412828281": [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}] + } }, - "attrs": [{}], - "properties": { "order": 92, "id": 68, "name": "shield", "prevSize": 32, "code": 59688 }, + "attrs": [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}], + "properties": { "order": 292, "id": 82, "name": "code-blocks", "prevSize": 32, "code": 59692 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 35 }, { "icon": { "paths": [ - "M383.875 640.166c-13.781 0-27.307-6.656-35.541-18.987-13.099-19.584-7.808-46.080 11.819-59.179l109.186-72.832v-189.908c0-23.595 19.072-42.667 42.667-42.667s42.667 19.072 42.667 42.667v212.735c0 14.251-7.125 27.605-18.987 35.499l-128.172 85.504c-7.253 4.821-15.531 7.168-23.637 7.168z", - "M512 170.667c-188.203 0-341.333 153.131-341.333 341.333s153.131 341.333 341.333 341.333c188.203 0 341.333-153.131 341.333-341.333s-153.131-341.333-341.333-341.333zM512 938.667c-235.264 0-426.667-191.403-426.667-426.667s191.403-426.667 426.667-426.667c235.264 0 426.667 191.403 426.667 426.667s-191.403 426.667-426.667 426.667z" + "M384 298.667h-170.667c-47.128 0-85.333 38.205-85.333 85.333v469.333c0 47.13 38.205 85.333 85.333 85.333h341.333c47.13 0 85.333-38.204 85.333-85.333v-128h170.667c47.13 0 85.333-38.204 85.333-85.333v-469.333c0-47.128-38.204-85.333-85.333-85.333h-341.333c-47.128 0-85.333 38.205-85.333 85.333v128zM640 640v-256c0-47.128-38.204-85.333-85.333-85.333h-85.333v-128h341.333v469.333h-170.667zM213.333 853.333v-469.333h341.333v469.333h-341.333z" ], - "attrs": [{}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["clock"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["copy"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}], - "properties": { "order": 91, "id": 67, "name": "clock", "prevSize": 32, "code": 59689 }, + "attrs": [{}], + "properties": { "order": 293, "id": 81, "name": "copy", "prevSize": 32, "code": 59693 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 36 }, { "icon": { "paths": [ - "M908.809 229.248c-22.528-7.253-46.464 4.992-53.803 27.392l-16.896 52.053c-68.864-110.805-190.507-180.693-325.973-180.693-178.987 0-332.672 121.515-373.76 295.467-5.419 22.912 8.789 45.909 31.787 51.328 3.285 0.725 6.571 1.109 9.813 1.109 19.328 0 36.821-13.184 41.515-32.853 31.915-135.253 151.424-229.717 290.646-229.717 106.283 0 200.96 55.808 254.251 143.317l-66.133-20.309c-22.571-6.997-46.421 5.803-53.333 28.245-6.912 22.528 5.76 46.421 28.245 53.333l157.312 48.341c4.181 1.28 8.405 1.877 12.544 1.877 18.005 0 34.688-11.477 40.576-29.483l50.603-155.605c7.253-22.443-5.035-46.507-27.392-53.803z", - "M855.309 536.375c-23.424-4.565-45.611 10.539-50.133 33.621-27.392 139.477-150.656 240.683-293.035 240.683-105.941 0-200.704-55.765-254.037-143.104l65.237 20.096c22.613 7.040 46.421-5.76 53.333-28.245 6.912-22.571-5.717-46.421-28.245-53.333l-157.269-48.384c-22.315-6.869-45.909 5.461-53.163 27.605l-50.56 155.691c-7.296 22.357 4.992 46.464 27.435 53.76 4.352 1.408 8.789 2.048 13.141 2.048 18.005 0 34.731-11.435 40.576-29.483l17.152-52.736c68.779 111.189 190.891 181.419 326.4 181.419 183.040 0 341.504-130.176 376.789-309.504 4.565-23.125-10.539-45.611-33.621-50.133z" + "M814.289 306.612c-166.95-163.459-437.63-163.459-604.58 0l-22.054 21.592c-8.347 8.173-8.347 21.424 0 29.597l68.733 67.296c4.174 4.087 10.941 4.087 15.114 0l29.611-28.992c116.468-114.033 305.303-114.033 421.77 0l27.652 27.072c4.173 4.087 10.94 4.087 15.113 0l68.732-67.296c8.35-8.173 8.35-21.424 0-29.597l-20.092-19.672zM1017.591 505.681l-61.171-59.895c-8.346-8.171-21.879-8.171-30.229 0l-195.767 191.676c-2.086 2.044-5.47 2.044-7.556 0l-195.772-191.68c-8.35-8.171-21.884-8.171-30.229 0l-195.763 191.68c-2.087 2.044-5.47 2.044-7.557 0l-195.772-191.68c-8.348-8.171-21.882-8.171-30.229 0l-61.173 59.895c-8.348 8.171-8.348 21.423 0 29.598l275.84 270.067c8.348 8.175 21.882 8.175 30.229 0l195.765-191.671c2.086-2.044 5.47-2.044 7.556 0l195.767 191.671c8.35 8.175 21.884 8.175 30.229 0l275.831-270.067c8.35-8.171 8.35-21.423 0-29.594z" ], - "attrs": [{}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["update"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["dapp-logo"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}], - "properties": { "order": 90, "id": 66, "name": "update", "prevSize": 32, "code": 59690 }, + "attrs": [{}], + "properties": { "order": 294, "id": 80, "name": "dapp-logo", "prevSize": 32, "code": 59694 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 37 }, { "icon": { "paths": [ - "M880.35 303.767c14.694 26.481 26.65 54.71 35.588 84.342 68.083 225.455-58.581 463.706-282.889 532.083-224.292 68.373-461.277-58.931-529.306-284.335-68.087-225.471 58.556-463.71 282.895-532.082 40.347-12.292 81.811-18.442 123.202-18.442 26.048 0 47.168 21.225 47.168 47.408s-21.12 47.408-47.168 47.408c-32.141 0-64.384 4.783-95.834 14.364-174.462 53.171-272.951 238.447-219.995 413.815 52.906 175.296 237.219 274.304 411.662 221.129 174.434-53.18 272.947-238.468 219.998-413.818-7.317-24.248-17.203-47.249-29.414-68.718l-11.695 67.883c-4.446 25.798-28.855 43.091-54.524 38.624-25.664-4.467-42.871-29.004-38.426-54.803l29.056-168.631c4.39-25.487 28.297-42.732 53.726-38.755l167.019 26.122c25.737 4.026 43.358 28.262 39.351 54.134s-28.117 43.581-53.858 39.555l-46.558-7.282z", - "M362.099 548.407c-20.513 14.315-26.913 44.774-14.295 68.041s39.477 30.524 59.991 16.213l126.115-87.987c12.898-8.998 20.757-24.947 20.757-42.125v-197.091c0-27.315-19.524-49.458-43.605-49.458-24.085 0-43.61 22.143-43.61 49.458v169.447l-105.353 73.502z" + "M426.667 768.414c23.595 0 42.667-19.072 42.667-42.667v-256c0-23.595-19.072-42.667-42.667-42.667s-42.667 19.072-42.667 42.667v256c0 23.595 19.072 42.667 42.667 42.667z", + "M597.376 768.414c23.595 0 42.667-19.072 42.667-42.667v-256c0-23.595-19.072-42.667-42.667-42.667s-42.667 19.072-42.667 42.667v256c0 23.595 19.072 42.667 42.667 42.667z", + "M704.542 834.167c-0.555 10.837-11.008 19.328-21.205 19.243h-343.339c-9.813-1.109-19.669-8.363-20.224-19.541l-27.648-492.544h438.912l-26.496 492.843zM426.526 196.94c0-14.251 12.075-26.283 26.325-26.283h118.101c14.507 0 26.325 11.819 26.325 26.283v59.051h-170.752v-59.051zM853.321 255.991h-74.155c-0.256 0-0.512-0.213-0.811-0.213-0.512-0.043-0.939 0.213-1.493 0.213h-94.251v-59.051c0-61.525-50.048-111.616-111.659-111.616h-118.101c-61.568 0-111.659 50.091-111.659 111.616v59.051h-170.539c-23.552 0-42.667 19.072-42.667 42.667s19.115 42.667 42.667 42.667h36.053l27.861 496.939c2.688 55.893 49.536 100.523 104.107 100.523 0.768 0 1.536-0.043 2.304-0.043h343.766c55.467 0 102.357-44.544 105.045-100.181l26.709-497.238h36.821c23.595 0 42.667-19.072 42.667-42.667s-19.072-42.667-42.667-42.667z" ], - "attrs": [{}, {}], + "attrs": [{}, {}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["repeat"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["delete"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}] } }, - "attrs": [{}, {}], - "properties": { "order": 89, "id": 65, "name": "repeat", "prevSize": 32, "code": 59691 }, + "attrs": [{}, {}, {}], + "properties": { "order": 295, "id": 79, "name": "delete", "prevSize": 32, "code": 59695 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 38 }, { "icon": { "paths": [ - "M554.688 664.913v-536.873c0-23.595-19.115-42.667-42.667-42.667-23.595 0-42.667 19.072-42.667 42.667v536.873l-175.872-175.829c-16.683-16.683-43.691-16.683-60.331 0-16.683 16.683-16.683 43.648 0 60.331l241.366 241.365c1.237 1.237 2.56 2.389 3.883 3.413 7.851 10.027 19.968 16.384 33.621 16.384 13.611 0 25.771-6.357 33.579-16.384 1.323-1.024 2.688-2.176 3.883-3.413l241.365-241.365c16.683-16.683 16.683-43.648 0-60.331-16.64-16.683-43.648-16.683-60.331 0l-175.829 175.829z", - "M128.021 853.333h768c23.467 0 42.667 19.2 42.667 42.667s-19.2 42.667-42.667 42.667h-768c-23.509 0-42.667-19.2-42.667-42.667s19.157-42.667 42.667-42.667z" + "M170.675 83.198c-71.871 0-130.134 58.263-130.134 130.133v426.669c0 71.868 58.263 130.129 130.134 130.129h296.533v81.071h-125.867c-24.742 0-44.8 20.058-44.8 44.8 0 24.738 20.058 44.796 44.8 44.796h341.333c24.742 0 44.8-20.058 44.8-44.796 0-24.742-20.058-44.8-44.8-44.8h-125.867v-81.071h296.533c71.872 0 130.133-58.261 130.133-130.129v-426.669c0-71.871-58.261-130.133-130.133-130.133h-682.667zM130.142 213.331c0-22.386 18.147-40.533 40.533-40.533h682.667c22.387 0 40.533 18.147 40.533 40.533v426.669c0 22.383-18.146 40.533-40.533 40.533h-682.667c-22.386 0-40.533-18.15-40.533-40.533v-426.669z" ], - "attrs": [{}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["download"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["desktop"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}], - "properties": { "order": 88, "id": 64, "name": "download", "prevSize": 32, "code": 59692 }, + "attrs": [{}], + "properties": { "order": 296, "id": 78, "name": "desktop", "prevSize": 32, "code": 59696 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 39 }, { "icon": { "paths": [ - "M469.308 359.086v536.876c0 23.595 19.115 42.667 42.667 42.667 23.595 0 42.667-19.072 42.667-42.667v-536.876l175.872 175.83c16.683 16.683 43.691 16.683 60.331 0 16.683-16.683 16.683-43.648 0-60.331l-241.365-241.366c-1.237-1.237-2.56-2.389-3.883-3.413-7.851-10.027-19.968-16.384-33.621-16.384-13.611 0-25.771 6.357-33.579 16.384-1.323 1.024-2.688 2.176-3.883 3.413l-241.366 241.366c-16.683 16.683-16.683 43.648 0 60.331 16.64 16.683 43.648 16.683 60.331 0l175.83-175.83z", - "M895.979 170.667h-768c-23.467 0-42.667-19.2-42.667-42.667s19.2-42.667 42.667-42.667h768c23.509 0 42.667 19.2 42.667 42.667s-19.157 42.667-42.667 42.667z" + "M256.021 853.35v-682.667h255.915v171.52c0 46.421 37.888 84.224 84.437 84.224h171.648v426.923h-512zM732.821 341.478l-135.552 0.725v-136.107l135.552 135.381zM842.005 329.958l-231.765-231.509c-8.405-8.405-19.755-13.099-31.573-13.099h-322.646c-47.147 0-85.333 38.229-85.333 85.333v682.667c0 47.104 38.187 85.333 85.333 85.333h512c47.104 0 85.333-38.229 85.333-85.333v-496.171c0-10.197-4.096-20.011-11.349-27.221z" ], - "attrs": [{}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["upload"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["document"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}], - "properties": { "order": 87, "id": 63, "name": "upload", "prevSize": 32, "code": 59693 }, + "attrs": [{}], + "properties": { "order": 297, "id": 77, "name": "document", "prevSize": 32, "code": 59697 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 40 }, { "icon": { "paths": [ - "M384 85.333h-213.333c-47.128 0-85.333 38.205-85.333 85.333v213.333c0 47.13 38.205 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.128-38.204-85.333-85.333-85.333zM170.667 384v-213.333h213.333v213.333h-213.333z", - "M384 554.667h-213.333c-47.128 0-85.333 38.204-85.333 85.333v213.333c0 47.13 38.205 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.13-38.204-85.333-85.333-85.333zM170.667 853.333v-213.333h213.333v213.333h-213.333z", - "M853.333 85.333h-213.333c-47.13 0-85.333 38.205-85.333 85.333v213.333c0 47.13 38.204 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.128-38.204-85.333-85.333-85.333zM640 384v-213.333h213.333v213.333h-213.333z", - "M661.333 554.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", - "M917.333 554.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", - "M789.333 682.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", - "M917.333 810.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", - "M661.333 810.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z" + "M285.867 780.373c16.64 16.64 43.52 16.64 60.16 0l165.973-165.12 165.547 165.547c16.64 16.64 43.52 16.64 60.16 0s16.64-43.52 0-60.16l-195.84-195.84c-3.947-3.955-8.636-7.095-13.798-9.237-5.158-2.142-10.692-3.243-16.282-3.243s-11.123 1.101-16.282 3.243c-5.163 2.142-9.852 5.282-13.798 9.237l-195.84 195.413c-16.64 16.64-16.64 43.52 0 60.16z", + "M285.867 499.2c16.64 16.64 43.52 16.64 60.16 0l165.973-165.119 165.547 165.545c16.64 16.64 43.52 16.64 60.16 0s16.64-43.52 0-60.16l-195.84-195.839c-3.947-3.956-8.636-7.095-13.798-9.236-5.158-2.141-10.692-3.244-16.282-3.244s-11.123 1.103-16.282 3.244c-5.163 2.141-9.852 5.28-13.798 9.236l-195.84 195.412c-16.64 16.64-16.64 43.52 0 60.16z" ], - "attrs": [{}, {}, {}, {}, {}, {}, {}, {}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["qr-code"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}, {}, {}, {}] } + "tags": ["double-arrow"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}, {}, {}, {}, {}, {}, {}, {}], - "properties": { "order": 86, "id": 62, "name": "qr-code", "prevSize": 32, "code": 59694 }, + "attrs": [{}, {}], + "properties": { "order": 298, "id": 76, "name": "double-arrow", "prevSize": 32, "code": 59698 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 41 }, { "icon": { "paths": [ - "M938.667 170.667v170.667c0 23.564-19.102 42.667-42.667 42.667s-42.667-19.103-42.667-42.667v-170.667h-170.667c-23.565 0-42.667-19.103-42.667-42.667s19.102-42.667 42.667-42.667h170.667c47.13 0 85.333 38.205 85.333 85.333z", - "M170.667 170.667v170.667c0 23.564-19.103 42.667-42.667 42.667s-42.667-19.103-42.667-42.667v-170.667c0-47.128 38.205-85.333 85.333-85.333h170.667c23.564 0 42.667 19.103 42.667 42.667s-19.103 42.667-42.667 42.667h-170.667z", - "M853.333 853.333v-170.667c0-23.565 19.102-42.667 42.667-42.667s42.667 19.102 42.667 42.667v170.667c0 47.13-38.204 85.333-85.333 85.333h-170.667c-23.565 0-42.667-19.102-42.667-42.667s19.102-42.667 42.667-42.667h170.667z", - "M170.667 853.333h170.667c23.564 0 42.667 19.102 42.667 42.667s-19.103 42.667-42.667 42.667h-170.667c-47.128 0-85.333-38.204-85.333-85.333v-170.667c0-23.565 19.103-42.667 42.667-42.667s42.667 19.102 42.667 42.667v170.667z", - "M128 469.333h768c23.564 0 42.667 19.103 42.667 42.667s-19.103 42.667-42.667 42.667h-768c-23.564 0-42.667-19.103-42.667-42.667s19.103-42.667 42.667-42.667z" + "M554.688 664.913v-536.875c0-23.595-19.115-42.667-42.667-42.667-23.595 0-42.667 19.072-42.667 42.667v536.875l-175.872-175.829c-16.683-16.683-43.691-16.683-60.331 0-16.683 16.683-16.683 43.648 0 60.331l241.365 241.365c1.237 1.237 2.56 2.389 3.883 3.413 7.851 10.027 19.968 16.384 33.621 16.384 13.611 0 25.771-6.357 33.579-16.384 1.323-1.024 2.688-2.176 3.883-3.413l241.365-241.365c16.683-16.683 16.683-43.648 0-60.331-16.64-16.683-43.648-16.683-60.331 0l-175.829 175.829z", + "M128.021 853.333h768c23.467 0 42.667 19.2 42.667 42.667s-19.2 42.667-42.667 42.667h-768c-23.509 0-42.667-19.2-42.667-42.667s19.157-42.667 42.667-42.667z" ], - "attrs": [{}, {}, {}, {}, {}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["scan"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}, {}] } + "tags": ["download"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}, {}, {}, {}, {}], - "properties": { "order": 85, "id": 61, "name": "scan", "prevSize": 32, "code": 59695 }, + "attrs": [{}, {}], + "properties": { "order": 299, "id": 75, "name": "download", "prevSize": 32, "code": 59699 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 42 }, { "icon": { "paths": [ - "M512.009 170.674c-221.668 0-410.96 141.506-487.613 341.33 76.653 199.825 265.944 341.329 487.613 341.329 221.619 0 410.914-141.504 487.616-341.329-76.702-199.824-265.997-341.33-487.616-341.33zM512.009 268.197c163.499 0 309.491 94.499 381.461 243.807-71.97 149.308-217.963 243.806-381.461 243.806-163.543 0-309.535-94.498-381.458-243.806 71.923-149.308 217.914-243.807 381.458-243.807z", - "M512.055 316.198c-107.762 0-195.045 87.331-195.045 195.047 0 107.763 87.283 195.042 195.045 195.042 107.716 0 195.046-87.279 195.046-195.042 0-107.715-87.33-195.047-195.046-195.047zM512.060 413.721c53.781 0 97.519 43.738 97.519 97.524 0 53.833-43.738 97.523-97.519 97.523-53.786 0-97.524-43.691-97.524-97.523 0-53.786 43.739-97.524 97.524-97.524z" + "M658.36 896.616c60.636 56.067 158.788 56.067 219.279 0l612.618-566.444c97.717-90.353 28.5-244.838-109.64-244.838l-1225.236 0.134c-138.14 0-207.356 154.486-109.64 244.704l612.618 566.444z" ], - "attrs": [{}, {}], + "attrs": [{}], + "width": 1536, "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["eye-n"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["dropdown-arrow-small"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}], - "properties": { "order": 84, "id": 60, "name": "eye-n", "prevSize": 32, "code": 59696 }, + "attrs": [{}], + "properties": { "order": 300, "id": 74, "name": "dropdown-arrow-small", "prevSize": 32, "code": 59700 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 43 }, { "icon": { "paths": [ - "M372.8 284.969c41.137-13.363 84.574-21.184 129.638-21.184 160.619 0 304 90.231 374.686 232.795-30.793 62.11-75.998 113.417-129.779 152.576l68.433 66.53c73.271-56.384 131.123-131.806 165.555-219.106-75.281-190.799-261.188-325.914-478.895-325.914-72.457 0-141.228 15.271-203.772 42.276l74.133 72.027z", - "M497.502 426.497c2.449-0.175 4.762-0.743 7.296-0.743 49.045 0 88.934 39.235 88.934 87.389 0 2.492-0.58 4.762-0.802 7.121l70.882 69.649c11.695-23.245 18.854-49.067 18.854-76.77 0-96.521-79.642-174.778-177.869-174.778-28.194 0-54.473 7.078-78.131 18.483l70.835 69.649z", - "M511.983 733.47c-157.38 0-297.908-89.378-367.139-230.601 20.042-40.909 46.655-76.835 77.398-108.105l102.603 100.817c-0.094 2.214-0.516 4.335-0.516 6.549 0 101.926 84.063 184.482 187.748 184.482 2.3 0 4.459-0.371 6.711-0.461l44.399 43.584c-16.849 2.214-33.839 3.733-51.204 3.733zM785.011 817.271l-612.711-602.006c-18.352-18.033-48.063-18.033-66.368 0-18.352 17.987-18.352 47.181 0 65.214l50.41 49.533c-48.579 48.98-88.053 107.231-113.727 172.861 73.831 189.001 256.039 322.842 469.364 322.842 45.295 0 88.947-6.643 130.624-17.941l76.041 74.714c9.199 9.041 21.167 13.513 33.182 13.513s24.030-4.471 33.186-13.513c18.351-17.988 18.351-47.181 0-65.216z" + "M750.626 385.792l-110.123-111.061 105.344-103.253 106.539 107.477c0.256 0.256-0.213 4.352 0.085 4.608l-101.845 102.229zM281.166 853.333h-110.507v-111.829l408.959-406.955 110.72 111.659-409.172 407.125zM938.658 279.765c-0.299-23.723-9.728-45.653-25.685-60.885l-105.771-106.667c-15.744-16.811-38.187-26.581-61.653-26.88h-1.152c-23.509 0-46.293 9.472-62.635 25.899l-583.85 582.272c-8.021 7.979-12.587 18.901-12.587 30.251v172.245c0 23.595 19.072 42.667 42.667 42.667h170.795c11.307 0 22.101-4.437 30.080-12.416l584.106-582.485c16.64-16.811 26.027-40.149 25.685-64z" ], - "attrs": [{}, {}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["eye-off"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + "tags": ["edit"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}, {}], - "properties": { "order": 83, "id": 59, "name": "eye-off", "prevSize": 32, "code": 59697 }, + "attrs": [{}], + "properties": { "order": 301, "id": 73, "name": "edit", "prevSize": 32, "code": 59701 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 44 }, { "icon": { "paths": [ - "M768 810.522c0 23.552-19.115 42.667-42.667 42.667h-426.667c-23.552 0-42.667-19.115-42.667-42.667v-298.667c0-23.552 19.115-42.667 42.667-42.667h426.667c23.552 0 42.667 19.115 42.667 42.667v298.667zM768.171 391.791v-50.432c0-141.184-114.859-256.043-256-256.043-103.125 0-195.669 61.397-235.819 156.373-9.173 21.717 0.939 46.763 22.656 55.936 21.675 9.131 46.763-1.024 55.893-22.699 26.795-63.317 88.533-104.277 157.269-104.277 94.123 0 170.667 76.587 170.667 170.709v42.496h-384.171c-70.571 0-128 57.429-128 128v298.667c0 70.571 57.429 128 128 128h426.667c70.571 0 128-57.429 128-128v-298.667c0-55.509-35.712-102.357-85.163-120.064z", - "M576 618.398c0-35.371-28.672-64-64-64s-64 28.629-64 64c0 18.901 8.363 35.755 21.419 47.445v59.051c0 23.637 19.157 42.795 42.837 42.795 23.637 0 42.795-19.157 42.795-42.795v-59.392c12.8-11.733 20.949-28.416 20.949-47.104z" + "M512.132 170.667c70.571 0 128 57.387 128 128 0 70.571-57.429 128-128 128s-128-57.429-128-128c0-70.613 57.429-128 128-128zM696.068 514.944c-24.747-13.781-51.285-24.107-79.147-31.616 64.469-36.736 108.544-105.301 108.544-184.661 0-117.632-95.701-213.333-213.333-213.333s-213.333 95.701-213.333 213.333c0 79.061 43.733 147.413 107.819 184.277-191.275 50.005-271.36 235.904-319.573 401.323-6.571 22.613 6.4 46.336 29.013 52.907 4.011 1.195 8.021 1.707 11.947 1.707 18.517 0 35.541-12.075 40.96-30.677 72.192-247.637 174.805-353.323 343.040-353.323 54.272 0 100.821 11.307 142.379 34.56 20.523 11.435 46.549 4.096 58.069-16.427 11.52-20.565 4.139-46.592-16.384-58.069z", + "M571.674 869.773l0.341 57.259c0 6.443 5.248 11.648 11.691 11.648h56.875c3.115 0 6.101-1.237 8.32-3.456l171.861-171.861c6.741-6.741 6.741-17.664 0-24.405l-50.432-50.475c-6.144-6.144-16.128-6.144-22.272 0l-172.928 172.971c-2.219 2.176-3.456 5.205-3.456 8.32z", + "M872.171 711.94l63.104-63.104c4.736-4.693 4.736-12.416 0-17.152l-56.661-56.576c-4.693-4.736-12.416-4.736-17.152 0l-63.061 63.061c-4.779 4.736-4.779 12.459 0 17.195l56.576 56.576c4.736 4.736 12.459 4.736 17.195 0z" ], - "attrs": [{}, {}], + "attrs": [{}, {}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["unlock"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["edit-owner"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}] } }, - "attrs": [{}, {}], - "properties": { "order": 82, "id": 58, "name": "unlock", "prevSize": 32, "code": 59698 }, + "attrs": [{}, {}, {}], + "properties": { "order": 302, "id": 72, "name": "edit-owner", "prevSize": 32, "code": 59702 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 45 }, { "icon": { "paths": [ - "M768 810.522c0 23.552-19.115 42.667-42.667 42.667h-426.667c-23.552 0-42.667-19.115-42.667-42.667v-298.667c0-23.552 19.115-42.667 42.667-42.667h426.667c23.552 0 42.667 19.115 42.667 42.667v298.667zM512.171 170.65c94.123 0 170.667 76.587 170.667 170.709v42.496h-341.333v-42.496c0-94.123 76.544-170.709 170.667-170.709zM768.171 391.791v-50.432c0-141.184-114.859-256.043-256-256.043-141.184 0-256 114.859-256 256.043v50.304c-49.664 17.621-85.504 64.555-85.504 120.192v298.667c0 70.571 57.429 128 128 128h426.667c70.571 0 128-57.429 128-128v-298.667c0-55.509-35.712-102.357-85.163-120.064z", - "M576 618.398c0-35.371-28.672-64-64-64s-64 28.629-64 64c0 18.901 8.32 35.712 21.419 47.403v59.093c0 23.637 19.157 42.795 42.795 42.795 23.68 0 42.837-19.157 42.837-42.795v-59.392c12.8-11.733 20.949-28.416 20.949-47.104z" + "M298.667 512.004c0-47.13 38.205-85.333 85.333-85.333 47.13 0 85.333 38.204 85.333 85.333s-38.204 85.333-85.333 85.333c-47.128 0-85.333-38.204-85.333-85.333z", + "M554.667 512.004c0-47.13 38.204-85.333 85.333-85.333s85.333 38.204 85.333 85.333c0 47.13-38.204 85.333-85.333 85.333s-85.333-38.204-85.333-85.333z", + "M298.667 213.333c0-47.128 38.205-85.333 85.333-85.333 47.13 0 85.333 38.205 85.333 85.333s-38.204 85.333-85.333 85.333c-47.128 0-85.333-38.205-85.333-85.333z", + "M554.667 213.333c0-47.128 38.204-85.333 85.333-85.333s85.333 38.205 85.333 85.333c0 47.128-38.204 85.333-85.333 85.333s-85.333-38.205-85.333-85.333z", + "M298.667 810.662c0-47.13 38.205-85.333 85.333-85.333 47.13 0 85.333 38.204 85.333 85.333s-38.204 85.333-85.333 85.333c-47.128 0-85.333-38.204-85.333-85.333z", + "M554.667 810.662c0-47.13 38.204-85.333 85.333-85.333s85.333 38.204 85.333 85.333c0 47.13-38.204 85.333-85.333 85.333s-85.333-38.204-85.333-85.333z" ], - "attrs": [{}, {}], + "attrs": [{}, {}, {}, {}, {}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["lock"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["element-drag"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}, {}, {}, {}] } }, - "attrs": [{}, {}], - "properties": { "order": 81, "id": 57, "name": "lock", "prevSize": 32, "code": 59699 }, + "attrs": [{}, {}, {}, {}, {}, {}], + "properties": { "order": 303, "id": 71, "name": "element-drag", "prevSize": 32, "code": 59703 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 46 }, { "icon": { "paths": [ - "M698.091 716.335l-140.16-55.339c-21.888-8.704-46.763 2.091-55.339 24.021-8.661 21.888 2.091 46.677 23.979 55.296l49.749 19.669c-28.8 10.027-59.136 15.787-90.027 15.787h-0.64c-96.812-0.213-187.308-52.395-236.247-136.149-11.819-20.309-37.973-27.221-58.325-15.317-20.352 11.861-27.221 37.973-15.317 58.325 64.085 109.781 182.741 178.176 309.761 178.475h0.768c40.789 0 80.939-7.637 119.040-21.035l-15.36 41.003c-8.277 22.059 2.901 46.635 25.003 54.912 4.907 1.835 9.941 2.731 14.933 2.731 17.28 0 33.536-10.539 39.979-27.733l52.48-140.032c8.192-21.76-2.603-46.080-24.277-54.613z", - "M673.843 242.419c28.971 0 52.48 23.552 52.48 52.48s-23.509 52.48-52.48 52.48c-28.928 0-52.437-23.552-52.437-52.48s23.509-52.48 52.437-52.48zM350.090 170.653c28.928 0 52.48 23.552 52.48 52.48 0 28.971-23.552 52.48-52.48 52.48-28.971 0-52.48-23.509-52.48-52.48 0-28.928 23.509-52.48 52.48-52.48zM893.235 554.953c-26.709-71.168-77.269-123.989-137.728-149.675 33.877-25.088 56.149-65.067 56.149-110.378 0-75.99-61.824-137.814-137.813-137.814-75.947 0-137.771 61.824-137.771 137.814 0 45.312 22.272 85.291 56.149 110.378-16.939 7.168-33.195 15.957-48.213 27.264-28.203-45.654-67.328-79.958-112.299-98.987 33.877-25.131 56.192-65.109 56.192-110.421 0-75.947-61.865-137.813-137.812-137.813-75.989 0-137.813 61.867-137.813 137.813 0 45.312 22.272 85.291 56.149 110.421-60.459 25.6-111.019 78.507-137.685 149.675-8.277 22.059 2.901 46.635 24.917 54.955 22.059 8.107 46.677-2.901 54.997-24.96 25.429-67.883 80.171-111.744 139.435-111.744 54.955 0 105.769 38.016 133.161 97.664-11.179 17.152-21.12 35.584-28.757 55.851-8.235 22.059 2.944 46.635 25.003 54.912 4.949 1.835 9.984 2.731 14.976 2.731 17.237 0 33.536-10.539 39.936-27.733 25.472-67.84 80.171-111.701 139.435-111.701s114.005 43.861 139.477 111.744c8.277 22.101 32.853 33.152 54.955 24.96 22.016-8.277 33.195-32.853 24.96-54.955z" + "M448.090 59.85c19.439-11.2 41.476-17.096 63.91-17.096s44.476 5.897 63.91 17.097c0.030 0.017 0.060 0.035 0.090 0.052l298.667 170.666c15.787 9.114 29.402 21.469 39.974 36.198 2.577 2.576 4.86 5.526 6.771 8.829 1.647 2.845 2.927 5.8 3.857 8.812 8.777 17.641 13.376 37.106 13.397 56.882v341.42c-0.021 22.447-5.948 44.493-17.182 63.927-11.234 19.43-27.379 35.571-46.818 46.793l-0.166 0.094-298.5 170.573c-0.026 0.017-0.051 0.030-0.077 0.047-13.534 7.799-28.331 13.030-43.644 15.488-6.033 3.264-12.941 5.116-20.279 5.116s-14.246-1.852-20.279-5.116c-15.313-2.458-30.106-7.689-43.639-15.488-0.030-0.017-0.055-0.030-0.081-0.047l-298.667-170.667c-19.439-11.221-35.585-27.362-46.819-46.793-11.233-19.435-17.158-41.481-17.181-63.927v-341.42c0.020-19.776 4.622-39.24 13.398-56.881 0.93-3.012 2.211-5.968 3.856-8.812 1.911-3.302 4.195-6.253 6.771-8.829 10.575-14.729 24.189-27.084 39.975-36.198l0.165-0.095 138.612-79.206c6.297-7.096 14.661-11.727 23.627-13.501l136.353-77.917zM322.133 230.109l-107.136 61.221 297.003 171.804 106.944-61.863-296.811-171.162zM704.222 351.942l-296.29-170.86 82.735-47.278c6.485-3.745 13.845-5.716 21.333-5.716s14.848 1.972 21.333 5.716l275.669 157.526-104.781 60.612zM853.333 364.268l-298.667 172.769v340.966l277.333-158.473c0.021-0.013 0.047-0.026 0.068-0.038 6.447-3.742 11.806-9.105 15.539-15.561 3.742-6.477 5.717-13.828 5.726-21.308v-318.356zM469.333 878.003v-340.966l-298.667-172.769v318.373c0.011 7.475 1.986 14.818 5.727 21.291 3.732 6.455 9.089 11.819 15.539 15.561 0.023 0.013 0.045 0.026 0.067 0.038l277.333 158.473z" ], - "attrs": [{}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["replace-owner"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["experimental"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}], - "properties": { "order": 80, "id": 56, "name": "replace-owner", "prevSize": 32, "code": 59700 }, + "attrs": [{}], + "properties": { "order": 304, "id": 70, "name": "experimental", "prevSize": 32, "code": 59704 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 47 }, { "icon": { "paths": [ - "M512.132 170.667c70.571 0 128 57.387 128 128 0 70.571-57.429 128-128 128s-127.998-57.429-127.998-128c0-70.613 57.428-128 127.998-128zM696.068 514.944c-24.747-13.781-51.285-24.107-79.147-31.616 64.469-36.736 108.544-105.301 108.544-184.661 0-117.632-95.701-213.333-213.333-213.333-117.63 0-213.332 95.701-213.332 213.333 0 79.061 43.733 147.413 107.819 184.277-191.275 50.005-271.36 235.904-319.573 401.323-6.571 22.613 6.4 46.336 29.013 52.907 4.011 1.195 8.021 1.707 11.947 1.707 18.517 0 35.541-12.075 40.96-30.677 72.192-247.637 174.805-353.323 343.038-353.323 54.272 0 100.821 11.307 142.379 34.56 20.523 11.435 46.549 4.096 58.069-16.427 11.52-20.565 4.139-46.592-16.384-58.069z", - "M571.674 869.773l0.341 57.259c0 6.443 5.248 11.648 11.691 11.648h56.875c3.115 0 6.101-1.237 8.32-3.456l171.861-171.861c6.741-6.741 6.741-17.664 0-24.405l-50.432-50.475c-6.144-6.144-16.128-6.144-22.272 0l-172.928 172.971c-2.219 2.176-3.456 5.205-3.456 8.32z", - "M872.171 711.94l63.104-63.104c4.736-4.693 4.736-12.416 0-17.152l-56.661-56.576c-4.693-4.736-12.416-4.736-17.152 0l-63.061 63.061c-4.779 4.736-4.779 12.459 0 17.195l56.576 56.576c4.736 4.736 12.459 4.736 17.195 0z" + "M481.83 55.163c16.661-16.662 43.678-16.662 60.339 0l170.667 170.667c16.661 16.663 16.661 43.677 0 60.34s-43.678 16.662-60.339 0l-97.83-97.83v451.66c0 23.565-19.102 42.667-42.667 42.667s-42.667-19.102-42.667-42.667v-451.66l-97.83 97.83c-16.663 16.662-43.677 16.662-60.34 0s-16.662-43.677 0-60.34l170.667-170.667zM170.667 469.333c23.564 0 42.667 19.102 42.667 42.667v341.333c0 11.315 4.495 22.17 12.497 30.17s18.854 12.497 30.17 12.497h512c11.315 0 22.17-4.497 30.17-12.497s12.497-18.854 12.497-30.17v-341.333c0-23.565 19.102-42.667 42.667-42.667s42.667 19.102 42.667 42.667v341.333c0 33.95-13.487 66.505-37.491 90.509s-56.559 37.491-90.509 37.491h-512c-33.948 0-66.505-13.487-90.51-37.491s-37.49-56.559-37.49-90.509v-341.333c0-23.565 19.103-42.667 42.667-42.667z" ], - "attrs": [{}, {}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["edit-owner"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + "tags": ["export"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}, {}], - "properties": { "order": 79, "id": 55, "name": "edit-owner", "prevSize": 32, "code": 59701 }, + "attrs": [{}], + "properties": { "order": 305, "id": 69, "name": "export", "prevSize": 32, "code": 59705 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 48 }, { "icon": { "paths": [ - "M555.115 170.667c70.571 0 128 57.429 128 128s-57.429 128-128 128c-70.571 0-128-57.429-128-128s57.429-128 128-128zM979.947 884.309c-48.256-165.376-128.299-351.232-319.403-401.323 64.128-36.821 107.904-105.216 107.904-184.32 0-117.632-95.701-213.333-213.333-213.333s-213.333 95.701-213.333 213.333c0 79.317 44.032 147.883 108.459 184.619-27.989 7.552-54.613 17.877-79.317 31.701-20.608 11.52-27.947 37.504-16.384 58.069 11.435 20.48 37.504 27.947 58.027 16.341 41.515-23.168 88.107-34.475 142.421-34.475 168.192 0 270.805 105.643 343.040 353.28 5.419 18.645 22.443 30.72 40.96 30.72 3.925 0 7.979-0.555 11.947-1.707 22.656-6.571 35.584-30.293 29.013-52.907z", - "M298.624 725.461h85.333c23.552 0 42.667 19.115 42.667 42.667 0 23.595-19.115 42.667-42.667 42.667h-85.333v85.333c0 23.552-19.072 42.667-42.667 42.667-23.552 0-42.667-19.115-42.667-42.667v-85.333h-85.291c-23.552 0-42.667-19.072-42.667-42.667 0-23.552 19.115-42.667 42.667-42.667h85.291v-85.291c0-23.595 19.115-42.667 42.667-42.667 23.595 0 42.667 19.072 42.667 42.667v85.291z" + "M853.333 853.333v-341.333c0-23.565 19.102-42.667 42.667-42.667s42.667 19.102 42.667 42.667v341.333c0 47.13-38.204 85.333-85.333 85.333h-682.667c-47.128 0-85.333-38.204-85.333-85.333v-682.667c0-47.128 38.205-85.333 85.333-85.333h341.333c23.565 0 42.667 19.103 42.667 42.667s-19.102 42.667-42.667 42.667h-341.333v682.667h682.667z", + "M790.848 170.667h-110.327c-23.561 0-42.667-19.103-42.667-42.667s19.106-42.667 42.667-42.667h213.333c11.785 0 22.451 4.776 30.174 12.497 7.718 7.721 12.493 18.388 12.493 30.17v213.333c0 23.564-19.102 42.667-42.667 42.667-23.561 0-42.667-19.103-42.667-42.667v-110.327l-394.351 394.355c-16.661 16.661-43.677 16.661-60.34 0-16.662-16.666-16.662-43.678 0-60.343l394.351-394.351z" ], "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["add-owner"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["external-link"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, "attrs": [{}, {}], - "properties": { "order": 78, "id": 54, "name": "add-owner", "prevSize": 32, "code": 59702 }, + "properties": { "order": 306, "id": 68, "name": "external-link", "prevSize": 32, "code": 59706 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 49 }, { "icon": { "paths": [ - "M979.541 884.314c-65.873-225.809-162.812-360.751-316.979-401.515 63.13-37.052 105.519-105.64 105.519-184.123 0-117.815-95.518-213.333-213.333-213.333-117.845 0-213.334 95.505-213.334 213.333 0 78.872 42.786 147.741 106.424 184.661-72.535 19.913-133.866 61.282-184.672 122.799-15.005 18.172-12.44 45.065 5.728 60.070 18.169 15.002 45.062 12.437 60.067-5.73 58.092-70.34 130.554-105.549 225.62-105.549 172.625 0 272.363 111.010 343.040 353.284 6.601 22.618 30.289 35.61 52.907 29.009 22.622-6.601 35.61-30.285 29.013-52.907zM682.748 298.676c0 70.687-57.314 128-128 128-70.711 0-128-57.297-128-128s57.289-128 128-128c70.686 0 128 57.314 128 128z", - "M494.182 853.551h-366.161c-23.564 0-42.667-19.102-42.667-42.667s19.102-42.667 42.667-42.667h366.23l-12.079-12.079c-16.661-16.661-16.661-43.678 0-60.339s43.678-16.661 60.339 0l84.864 84.864c16.661 16.657 16.661 43.669 0.009 60.331l-84.864 84.907c-16.661 16.666-43.674 16.674-60.339 0.017-16.67-16.657-16.674-43.674-0.017-60.339l12.019-12.028z" + "M512.009 170.674c-221.668 0-410.96 141.506-487.613 341.33 76.653 199.825 265.944 341.329 487.613 341.329 221.619 0 410.914-141.504 487.616-341.329-76.702-199.824-265.997-341.33-487.616-341.33zM512.009 268.197c163.499 0 309.491 94.499 381.461 243.807-71.97 149.308-217.963 243.806-381.461 243.806-163.543 0-309.535-94.498-381.458-243.806 71.923-149.308 217.914-243.807 381.458-243.807z", + "M512.055 316.198c-107.762 0-195.045 87.331-195.045 195.046 0 107.763 87.283 195.042 195.045 195.042 107.716 0 195.046-87.279 195.046-195.042 0-107.715-87.33-195.046-195.046-195.046zM512.060 413.721c53.781 0 97.519 43.738 97.519 97.524 0 53.833-43.738 97.523-97.519 97.523-53.786 0-97.524-43.691-97.524-97.523 0-53.786 43.739-97.524 97.524-97.524z" ], "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["send-to"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["eye-n"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, "attrs": [{}, {}], - "properties": { "order": 77, "id": 53, "name": "send-to", "prevSize": 32, "code": 59703 }, + "properties": { "order": 307, "id": 67, "name": "eye-n", "prevSize": 32, "code": 59707 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 50 }, { "icon": { "paths": [ - "M682.671 383.957c47.061 0 85.333 38.272 85.333 85.333s-38.272 85.333-85.333 85.333c-47.061 0-85.333-38.272-85.333-85.333s38.272-85.333 85.333-85.333zM341.252 170.667c47.061 0 85.333 38.272 85.333 85.333s-38.272 85.333-85.333 85.333c-47.019 0-85.333-38.272-85.333-85.333s38.315-85.333 85.333-85.333zM979.503 884.011c-29.355-100.779-81.621-228.523-204.416-271.616 46.976-30.421 78.251-83.072 78.251-143.104 0-94.123-76.544-170.667-170.667-170.667-86.315 0-157.141 64.64-168.363 147.925-22.528-19.541-49.408-35.968-81.195-47.189 47.275-30.379 78.805-83.115 78.805-143.36 0-94.123-76.544-170.667-170.667-170.667s-170.667 76.544-170.667 170.667c0 60.032 31.317 112.725 78.336 143.104-122.88 43.093-175.147 170.88-204.544 271.701-6.571 22.613 6.357 46.336 29.013 52.907 3.968 1.152 7.979 1.707 11.947 1.707 18.517 0 35.541-12.075 40.96-30.72 46.080-157.909 110.379-225.28 215.040-225.28 92.288 0 151.552 51.371 195.968 168.917-85.973 56.405-126.934 160.683-151.723 245.675-6.571 22.613 6.443 46.336 29.013 52.907 4.011 1.152 8.021 1.707 11.947 1.707 18.518 0 35.542-12.075 40.96-30.72 46.037-157.909 110.336-225.28 215.040-225.28 104.661 0 168.96 67.371 215.040 225.28 6.571 22.571 29.952 35.584 52.907 29.013 22.656-6.571 35.584-30.293 29.013-52.907z" + "M372.8 284.969c41.137-13.363 84.574-21.184 129.638-21.184 160.619 0 304 90.231 374.686 232.795-30.793 62.11-75.998 113.417-129.779 152.576l68.433 66.53c73.271-56.384 131.123-131.806 165.555-219.106-75.281-190.799-261.188-325.914-478.895-325.914-72.457 0-141.228 15.271-203.772 42.276l74.133 72.027z", + "M497.502 426.496c2.449-0.175 4.762-0.743 7.296-0.743 49.045 0 88.934 39.236 88.934 87.39 0 2.487-0.58 4.762-0.802 7.121l70.882 69.649c11.695-23.245 18.854-49.071 18.854-76.77 0-96.522-79.642-174.778-177.869-174.778-28.194 0-54.473 7.078-78.131 18.482l70.835 69.649z", + "M511.983 733.47c-157.38 0-297.908-89.378-367.139-230.601 20.042-40.909 46.655-76.836 77.398-108.105l102.603 100.818c-0.094 2.214-0.516 4.335-0.516 6.549 0 101.926 84.063 184.482 187.748 184.482 2.3 0 4.459-0.371 6.711-0.461l44.399 43.584c-16.849 2.214-33.839 3.733-51.204 3.733zM785.011 817.271l-612.711-602.006c-18.352-18.033-48.063-18.033-66.368 0-18.352 17.987-18.352 47.181 0 65.214l50.41 49.533c-48.579 48.98-88.053 107.231-113.727 172.857 73.831 189.001 256.039 322.842 469.364 322.842 45.295 0 88.947-6.639 130.624-17.937l76.041 74.714c9.199 9.041 21.167 13.513 33.182 13.513s24.030-4.471 33.186-13.513c18.351-17.988 18.351-47.181 0-65.216z" ], - "attrs": [{}], + "attrs": [{}, {}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["owners"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["eye-off"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}] } }, - "attrs": [{}], - "properties": { "order": 76, "id": 52, "name": "owners", "prevSize": 32, "code": 59704 }, + "attrs": [{}, {}, {}], + "properties": { "order": 308, "id": 66, "name": "eye-off", "prevSize": 32, "code": 59708 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 51 }, { "icon": { "paths": [ - "M503.241 180.42l25.553-24.751c93.803-93.751 245.824-93.751 339.584 0.008 91.268 91.269 93.734 237.657 7.407 331.9l-7.403 7.731-128.781 128.823c-93.76 93.709-245.815 93.709-339.584-0.009-9.055-9.054-17.322-18.752-24.714-28.979-13.807-19.093-9.521-45.764 9.574-59.575 19.095-13.807 45.77-9.519 59.577 9.579 4.732 6.545 10.052 12.787 15.893 18.628 58.283 58.249 151.539 60.326 212.305 6.251l6.613-6.238 128.772-128.815c60.48-60.479 60.48-158.478 0-218.956-58.278-58.279-151.488-60.361-212.723-5.82l-6.669 6.293-26.027 25.216c-16.926 16.397-43.938 15.97-60.335-0.954-15.134-15.622-15.936-39.84-2.658-56.369l3.614-3.963z", - "M284.474 400.029c93.76-93.76 245.826-93.76 339.637 0 6.622 6.624 12.804 13.569 18.56 20.845 14.622 18.477 11.499 45.31-6.98 59.936-18.479 14.622-45.312 11.494-59.934-6.98-3.716-4.698-7.701-9.173-11.981-13.453-58.317-58.287-151.569-60.369-212.351-6.246l-6.617 6.242-128.797 128.755c-60.448 60.48-60.448 158.494-0.009 218.965 58.319 58.317 151.525 60.403 212.157 6.426l6.601-6.225 32.128-32.555c16.55-16.772 43.567-16.951 60.339-0.401 15.479 15.279 16.823 39.475 3.921 56.299l-3.524 4.041-32.324 32.755c-93.805 93.803-245.834 93.803-339.646-0.009-91.216-91.268-93.681-237.641-7.383-331.9l7.397-7.731 128.806-128.764z" + "M512.009 170.674c-221.668 0-410.96 141.506-487.613 341.33 76.653 199.825 265.944 341.329 487.613 341.329 221.619 0 410.914-141.504 487.616-341.329-76.702-199.824-265.997-341.33-487.616-341.33zM512.009 268.197c163.499 0 309.491 94.499 381.461 243.807-71.97 149.308-217.963 243.806-381.461 243.806-163.543 0-309.535-94.498-381.458-243.806 71.923-149.308 217.914-243.807 381.458-243.807z", + "M512.055 316.198c-107.762 0-195.045 87.331-195.045 195.046 0 107.763 87.283 195.042 195.045 195.042 107.716 0 195.046-87.279 195.046-195.042 0-107.715-87.33-195.046-195.046-195.046zM512.060 413.721c53.781 0 97.519 43.738 97.519 97.524 0 53.833-43.738 97.523-97.519 97.523-53.786 0-97.524-43.691-97.524-97.523 0-53.786 43.739-97.524 97.524-97.524z" ], "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["link"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["eye-on"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, "attrs": [{}, {}], - "properties": { "order": 75, "id": 51, "name": "link", "prevSize": 32, "code": 59705 }, + "properties": { "order": 309, "id": 65, "name": "eye-on", "prevSize": 32, "code": 59709 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 52 }, { "icon": { "paths": [ - "M426.656 512.149c0-15.859-2.47-31.138-7.048-45.474l216.957-131.441c27.311 29.952 66.65 48.745 110.387 48.745 82.483 0 149.333-66.847 149.333-149.333 0-82.456-66.859-149.333-149.333-149.333s-149.333 66.877-149.333 149.333c0 7.932 0.614 15.72 1.809 23.317l-228.064 138.169c-25.672-20.833-58.396-33.316-94.041-33.316-82.487 0-149.333 66.846-149.333 149.333 0 82.458 66.858 149.333 149.333 149.333 35.651 0 68.385-12.497 94.061-33.348l228.052 138.163c-1.199 7.62-1.818 15.433-1.818 23.39 0 82.487 66.846 149.333 149.333 149.333 82.483 0 149.333-66.846 149.333-149.333s-66.85-149.333-149.333-149.333c-43.712 0-83.034 18.773-110.34 48.695l-216.996-131.465c4.572-14.327 7.040-29.594 7.040-45.436zM746.953 170.645c35.341 0 64 28.666 64 64 0 35.358-28.642 64-64 64-35.362 0-64-28.642-64-64 0-35.334 28.655-64 64-64zM341.323 512.149c0 35.332-28.659 64-64 64s-64-28.668-64-64c0-35.358 28.641-64 64-64s64 28.642 64 64zM746.953 725.687c35.358 0 64 28.642 64 64s-28.642 64-64 64c-35.362 0-64-28.642-64-64s28.638-64 64-64z" + "M112.259 357.129c-18.095 0-27.142-9.348-27.142-28.047v-114.413c0-43.035 10.975-75.385 32.927-97.051s54.581-32.499 97.89-32.499h114.354c18.688 0 28.032 9.052 28.032 27.156 0 18.401-9.344 27.601-28.032 27.601h-113.019c-25.214 0-44.496 6.53-57.844 19.588-13.052 13.059-19.578 32.499-19.578 58.319v111.297c0 18.698-9.196 28.047-27.588 28.047zM908.73 357.129c-18.093 0-27.142-9.348-27.142-28.047v-111.297c0-25.821-6.822-45.261-20.467-58.319s-32.781-19.588-57.402-19.588h-112.57c-18.688 0-28.032-9.201-28.032-27.601 0-18.104 9.344-27.156 28.032-27.156h113.907c43.603 0 76.384 10.981 98.336 32.944 21.952 21.665 32.928 53.868 32.928 96.605v114.413c0 18.698-9.197 28.047-27.59 28.047zM215.934 936.32c-43.309 0-75.939-10.982-97.89-32.947-21.951-21.664-32.927-54.016-32.927-97.050v-113.965c0-18.701 9.047-28.051 27.142-28.051 18.392 0 27.588 9.35 27.588 28.051v111.296c0 25.818 6.525 45.261 19.578 58.317 13.348 13.062 32.63 19.59 57.844 19.59h113.019c18.688 0 28.032 9.197 28.032 27.603 0 18.099-9.344 27.155-28.032 27.155h-114.354zM691.149 936.32c-18.688 0-28.032-9.056-28.032-27.155 0-18.406 9.344-27.603 28.032-27.603h112.57c24.621 0 43.757-6.528 57.402-19.59 13.645-13.056 20.467-32.499 20.467-58.317v-111.296c0-18.701 9.050-28.051 27.142-28.051 18.394 0 27.59 9.35 27.59 28.051v113.965c0 43.034-10.976 75.386-32.928 97.050-21.952 21.965-54.733 32.947-98.336 32.947h-113.907zM345.416 475.104c-8.603 0-15.722-2.671-21.358-8.013-5.34-5.342-8.009-12.614-8.009-21.814v-60.1c0-8.904 2.669-16.027 8.009-21.37 5.636-5.639 12.755-8.458 21.358-8.458s15.722 2.819 21.357 8.458c5.636 5.343 8.454 12.465 8.454 21.37v60.1c0 9.201-2.818 16.472-8.454 21.814s-12.755 8.013-21.357 8.013zM474.453 579.278c-9.196 0-16.315-2.077-21.357-6.232-5.043-4.452-7.564-10.388-7.564-17.808 0-6.529 2.076-11.871 6.229-16.027 4.449-4.452 9.937-6.678 16.463-6.678h15.128c3.263 0 6.081-1.039 8.454-3.116 2.669-2.077 4.004-5.194 4.004-9.349v-142.46c0-7.123 2.076-12.614 6.229-16.472 4.153-4.156 9.641-6.233 16.463-6.233 7.119 0 12.756 2.077 16.909 6.233 4.153 3.858 6.229 9.349 6.229 16.472v138.454c0 20.776-5.34 36.506-16.019 47.19s-26.401 16.027-47.165 16.027c-0.593 0-1.334 0-2.225 0-0.593 0-1.187 0-1.78 0zM673.35 475.104c-8.902 0-16.019-2.671-21.357-8.013-5.344-5.342-8.013-12.614-8.013-21.814v-60.1c0-8.904 2.669-16.027 8.013-21.37 5.338-5.639 12.454-8.458 21.357-8.458 8.602 0 15.571 2.819 20.909 8.458 5.638 5.343 8.454 12.465 8.454 21.37v60.1c0 9.201-2.816 16.472-8.454 21.814-5.338 5.342-12.307 8.013-20.909 8.013zM507.38 723.072c-25.807 0-51.318-5.043-76.532-15.136-25.215-10.387-46.572-25.376-64.074-44.96-2.372-2.374-4.301-4.902-5.784-7.571-1.187-2.97-1.78-6.086-1.78-9.35 0-6.824 2.225-12.315 6.675-16.47s9.937-6.233 16.463-6.233c4.153 0 7.564 0.89 10.234 2.671 2.966 1.78 6.081 4.155 9.344 7.123 13.052 13.357 29.070 24.038 48.055 32.051 18.985 8.019 38.118 12.026 57.399 12.026 20.468 0 40.046-4.006 58.734-12.026 18.985-8.307 34.855-18.995 47.61-32.051 6.23-6.529 12.607-9.794 19.133-9.794 6.229 0 11.571 2.077 16.019 6.233s6.675 9.645 6.675 16.47c0 3.859-0.595 7.27-1.779 10.24-1.19 2.675-2.822 5.050-4.896 7.123-18.986 18.701-40.937 33.389-65.854 44.077-24.621 10.387-49.836 15.578-75.643 15.578z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["share"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["face-id"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 74, "id": 50, "name": "share", "prevSize": 32, "code": 59706 }, + "properties": { "order": 310, "id": 64, "name": "face-id", "prevSize": 32, "code": 59710 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 53 }, { "icon": { "paths": [ - "M853.333 853.333v-341.333c0-23.565 19.102-42.667 42.667-42.667s42.667 19.102 42.667 42.667v341.333c0 47.13-38.204 85.333-85.333 85.333h-682.667c-47.128 0-85.333-38.204-85.333-85.333v-682.667c0-47.128 38.205-85.333 85.333-85.333h341.333c23.565 0 42.667 19.103 42.667 42.667s-19.102 42.667-42.667 42.667h-341.333v682.667h682.667z", - "M790.848 170.667h-110.327c-23.561 0-42.667-19.103-42.667-42.667s19.106-42.667 42.667-42.667h213.333c11.785 0 22.451 4.776 30.174 12.497 7.718 7.721 12.493 18.388 12.493 30.17v213.333c0 23.564-19.102 42.667-42.667 42.667-23.561 0-42.667-19.103-42.667-42.667v-110.327l-394.351 394.355c-16.661 16.661-43.677 16.661-60.34 0-16.662-16.666-16.662-43.678 0-60.343l394.351-394.351z" + "M204.905 120.436c21.922-22.477 51.655-35.103 82.657-35.103h299.25c11.162 0 21.862 4.546 29.756 12.637l224.439 230.113c7.893 8.091 12.326 19.066 12.326 30.509v460.224c0 31.782-12.314 62.268-34.236 84.745-0.004 0-0.004 0-0.004 0.004-0.004 0-0.004 0.004-0.009 0.004-21.926 22.485-51.661 35.098-82.65 35.098h-448.873c-30.997 0-60.733-12.617-82.661-35.106-21.923-22.477-34.234-52.962-34.234-84.745v-613.632c0-31.786 12.316-62.27 34.237-84.747 0 0 0 0 0-0zM287.562 171.626c-8.682 0-17.007 3.536-23.144 9.828l-0 0.001c-6.138 6.294-9.587 14.83-9.587 23.729v613.632c0 8.905 3.45 17.438 9.584 23.727l0.006 0.009c6.132 6.289 14.453 9.822 23.141 9.822h448.873c8.687 0 17.011-3.537 23.138-9.822l0.013-0.013c6.135-6.285 9.583-14.822 9.583-23.723v-417.077h-182.396c-11.162 0-21.867-4.547-29.756-12.64-7.893-8.093-12.326-19.069-12.326-30.513l0.030-186.96h-257.159zM628.877 232.626l80.781 82.82h-80.794l0.013-82.82zM320.31 396.963c0-23.829 18.841-43.146 42.082-43.146h74.813c23.241 0 42.082 19.317 42.082 43.146s-18.842 43.148-42.082 43.148h-74.813c-23.241 0-42.082-19.319-42.082-43.148zM320.255 550.391c0-23.829 18.841-43.149 42.082-43.149h299.248c23.241 0 42.082 19.319 42.082 43.149s-18.842 43.145-42.082 43.145h-299.248c-23.241 0-42.082-19.315-42.082-43.145zM320.255 703.761c0-23.829 18.841-43.145 42.082-43.145h299.248c23.241 0 42.082 19.315 42.082 43.145s-18.842 43.149-42.082 43.149h-299.248c-23.241 0-42.082-19.319-42.082-43.149z" ], - "attrs": [{}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["external-link"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["file"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}], - "properties": { "order": 73, "id": 49, "name": "external-link", "prevSize": 32, "code": 59707 }, + "attrs": [{}], + "properties": { "order": 311, "id": 63, "name": "file", "prevSize": 32, "code": 59711 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 54 }, { "icon": { "paths": [ - "M170.667 512v341.333c0 22.63 8.99 44.335 24.994 60.339s37.708 24.994 60.34 24.994h512c22.63 0 44.335-8.99 60.339-24.994s24.994-37.709 24.994-60.339v-341.333", - "M682.667 256l-170.667-170.667-170.667 170.667", - "M512 85.333v554.667" - ], - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } + "M181.336 239.36c86.187 110.507 245.335 315.307 245.335 315.307v256c0 23.467 19.2 42.667 42.667 42.667h85.333c23.467 0 42.667-19.2 42.667-42.667v-256c0 0 158.72-204.8 244.907-315.307 21.76-28.16 1.707-68.693-33.707-68.693h-593.495c-35.413 0-55.467 40.533-33.707 68.693z" ], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["export"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }, { "s": 0 }, { "s": 0 }] } + "tags": ["filter"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } - ], - "properties": { "order": 140, "id": 48, "name": "export", "prevSize": 32, "code": 59708 }, + "attrs": [{}], + "properties": { "order": 312, "id": 62, "name": "filter", "prevSize": 32, "code": 59712 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 55 }, { "icon": { "paths": [ - "M704.009 128h63.991c47.13 0 85.333 38.205 85.333 85.333v640c0 47.13-38.204 85.333-85.333 85.333h-512c-47.128 0-85.333-38.204-85.333-85.333v-640c0-47.128 38.205-85.333 85.333-85.333h63.992c19.46-25.908 50.444-42.667 85.341-42.667h213.333c34.897 0 65.882 16.759 85.342 42.667zM300.8 213.333h-44.8v640h512v-640h-44.8c-9.882 48.688-52.928 85.333-104.533 85.333h-213.333c-51.604 0-94.65-36.646-104.533-85.333zM384 192c0 11.782 9.551 21.333 21.333 21.333h213.333c11.78 0 21.333-9.551 21.333-21.333s-9.553-21.333-21.333-21.333h-213.333c-11.782 0-21.333 9.551-21.333 21.333z" + "M882.112 682.669c-3.066 0-6.208-0.371-9.312-1.069-20.781-4.858-33.504-24.794-28.384-44.511 0.467-1.802 45.331-179.332-14.502-304.588-85.702-179.405-269.318-173.887-277.345-173.519-20.786 0.441-39.477-14.788-40.524-35.021-1.008-20.195 15.318-37.411 36.607-38.478 9.772-0.442 243.803-9.417 351.956 216.633 71.040 148.726 21.248 344.355 19.117 352.591-4.384 16.742-20.205 27.962-37.613 27.962z", + "M123.047 640c-7.509 0-15.094-2.36-21.735-7.2-17.018-12.552-21.094-37.106-9.057-54.852 31.094-45.92 41.32-87.787 51.395-158.772 37.885-268.555 226.559-329.703 234.558-332.182 19.924-6.256 40.98 5.745 46.904 26.561 5.887 20.776-5.434 42.615-25.282 48.832-6.566 2.164-150.938 51.586-181.541 268.28-9.547 67.523-20.98 128.552-64.376 192.691-7.358 10.86-19.018 16.644-30.867 16.644z", + "M296.97 597.334c-2.579 0-5.24-0.249-7.86-0.788-22.189-4.359-36.682-26.154-32.342-48.654 0.041-0.249 7.082-37.155 15.352-87.843 25.014-154.928 121.466-246.715 258.57-246.715 2.047 0 4.094 0 6.141 0.042 161.466 3.238 224.141 124.582 226.721 129.772 10.234 20.424 2.208 45.416-17.933 55.836-20.102 10.296-44.538 2.284-54.899-17.934-2.17-4.068-44.992-82.404-155.485-84.646-128.507-2.616-169.078 94.9-182.342 177.138-8.474 52.224-15.803 90.209-15.803 90.209-3.766 19.843-20.92 33.584-40.12 33.584z", + "M475.787 938.669c-7.003 0-14.076-2.278-20.269-6.95-15.869-12.109-19.67-35.757-8.445-52.915 77.977-119.168 121.258-291.705 122.525-370.401 0.211-12.11-1.478-28.662-10.591-38.684-7.425-8.086-17.735-9.795-25.089-9.795-19.987 0-36.877 17.843-36.877 38.988 0 20.993-15.729 37.962-35.188 37.962-19.424 0-35.188-16.97-35.188-37.962 0-63.359 48.137-114.912 107.253-114.912 29.417 0 56.16 11.54 75.267 32.496 14.462 15.906 31.526 44.985 30.79 93.235-1.654 101.511-53.873 288.246-135.404 412.88-6.861 10.477-17.734 16.058-28.783 16.058z", + "M645.69 938.669c-7.738 0-15.574-1.562-22.879-4.877-23.51-10.733-32.363-35.565-19.689-55.514 59.989-94.477 117.359-244.3 110.101-365.855-1.306-22.574 19.258-41.829 45.958-42.977 25.933-1.72 49.344 16.224 50.746 38.88 8.659 146.713-60.083 311.897-121.568 408.787-8.755 13.766-25.446 21.555-42.669 21.555z", + "M297.207 896c-10.179 0-20.317-3.898-28.312-11.686-16.525-16.224-17.267-43.328-1.648-60.538 61.734-67.853 100.101-142.816 120.706-235.845 5.11-23.032 27.199-37.502 49.371-32.151 22.212 5.309 36.060 28.255 30.949 51.288-24.026 108.529-68.863 196.075-141.106 275.531-8.118 8.909-18.998 13.402-29.96 13.402z", + "M162.751 810.669c-9.699 0-19.398-4.717-26.246-14.022-12.584-16.954-11.020-42.605 3.476-57.318 29.932-30.4 49.989-76.499 50.198-76.902 8.621-20.083 29.514-28.17 46.652-18.086 17.138 10.035 24.056 34.387 15.504 54.464-1.043 2.483-26.211 60.691-66.814 101.907-6.536 6.662-14.705 9.958-22.77 9.958z" + ], + "attrs": [ + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" } ], - "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["paste"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["fingerprint"], + "colorPermutations": { + "11611631671255214012559511412828281": [ + { "f": 3 }, + { "f": 3 }, + { "f": 3 }, + { "f": 3 }, + { "f": 3 }, + { "f": 3 }, + { "f": 3 } + ] + } }, - "attrs": [{}], - "properties": { "order": 71, "id": 47, "name": "paste", "prevSize": 32, "code": 59709 }, + "attrs": [ + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" }, + { "fill": "rgb(161, 163, 167)" } + ], + "properties": { "order": 313, "id": 61, "name": "fingerprint", "prevSize": 32, "code": 59713 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 56 }, { "icon": { "paths": [ - "M384 298.667h-170.667c-47.128 0-85.333 38.205-85.333 85.333v469.333c0 47.13 38.205 85.333 85.333 85.333h341.333c47.13 0 85.333-38.204 85.333-85.333v-128h170.667c47.13 0 85.333-38.204 85.333-85.333v-469.333c0-47.128-38.204-85.333-85.333-85.333h-341.333c-47.128 0-85.333 38.205-85.333 85.333v128zM640 640v-256c0-47.128-38.204-85.333-85.333-85.333h-85.333v-128h341.333v469.333h-170.667zM213.333 853.333v-469.333h341.333v469.333h-341.333z" + "M313.543 7.456l0.002-0.002-66.363 5.484-3.317-3.469-0.003 0.002-107.584 102.976c-18.063 19.25-17.409 48.943 1.925 67.422 0 0 0.001 0.001 0.001 0.001l93.004 88.947c-22.327 41.637-15.238 93.962 21.158 128.769l191.421 183.063-188.309 180.16c-61.841 62.165-60.854 160.597 2.94 221.602h0c32.358 30.933 74.734 46.387 117.054 46.387s84.707-15.454 117.053-46.387l362.62-346.85c61.841-62.165 60.851-160.585-2.94-221.594l-0.047-0.041-2.833-2.644-0.094-0.083-2.722-2.409c-2.027-2.139-4.117-4.24-6.272-6.302v0l-299.789-286.599c-22.127-19.727-50.355-29.554-78.528-29.554-18.176 0-36.37 4.088-52.925 12.287-0.132-0.129-0.265-0.257-0.399-0.385 0-0-0-0.001-0.001-0.001l-92.927-88.866-2.128-1.914zM813.022 452.032v0c20.762 19.857 32.145 46.187 32.145 74.176s-11.383 54.327-32.145 74.185l-362.3 346.406c-0.009 0.009-0.017 0.017-0.030 0.026-20.45 18.304-46.993 28.365-75.219 28.365-29.466 0-57.112-10.965-77.883-30.827-20.765-19.87-32.147-46.199-32.147-74.197 0-27.989 11.382-54.319 32.146-74.176l362.298-346.398c0.013-0.009 0.021-0.017 0.030-0.026 20.442-18.313 46.993-28.373 75.221-28.373 29.466 0 57.109 10.965 77.884 30.839zM725.077 367.874c-38.916 2.254-77.205 17.602-106.999 46.095l-134.814 128.913-193.652-185.283c-21.239-22.286-20.61-56.896 1.922-78.453l7.348-7.003c1.403-1.083 2.76-2.249 4.063-3.495v-0l107.535-102.936 1.708-1.928 7.342-7.033 2.246-2.046c11.005-9.527 25.123-14.761 40.137-14.761 16.043 0 31.053 5.969 42.334 16.766l0.004 0.002 220.826 211.162zM730.475 460.126c-39.983 0-72.759 31.044-72.759 69.79 0 38.75 32.777 69.794 72.759 69.794s72.772-31.044 72.772-69.794c0-38.746-32.789-69.79-72.772-69.79zM730.475 513.732c9.681 0 17.152 7.45 17.152 16.183 0 8.73-7.471 16.179-17.152 16.179-9.673 0-17.139-7.441-17.139-16.179 0-8.742 7.467-16.183 17.139-16.183zM279.803 50.589l85.787 82.207-98.808 94.493-85.777-82.186 98.798-94.513z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["copy"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["hardware"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 70, "id": 46, "name": "copy", "prevSize": 32, "code": 59710 }, + "properties": { "order": 314, "id": 60, "name": "hardware", "prevSize": 32, "code": 59714 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 57 }, { "icon": { "paths": [ - "M528.73 869.734l0.299 57.259c0.043 6.443 5.248 11.648 11.691 11.648h56.875c3.115 0 6.101-1.237 8.32-3.413l171.861-171.904c6.741-6.741 6.741-17.707 0-24.448l-50.432-50.432c-6.144-6.144-16.128-6.144-22.272 0l-172.928 172.971c-2.219 2.176-3.456 5.205-3.413 8.32z", - "M829.188 711.898l63.104-63.061c4.736-4.736 4.736-12.459 0-17.195l-56.619-56.619c-4.736-4.736-12.459-4.736-17.195 0l-63.061 63.104c-4.779 4.736-4.779 12.459 0 17.195l56.619 56.576c4.736 4.736 12.416 4.736 17.152 0z", - "M554.603 342.191v-136.107l135.552 135.424-135.552 0.683zM810.688 469.295v-112.085c0-10.24-4.096-20.011-11.349-27.221l-231.765-231.552c-8.405-8.405-19.755-13.099-31.616-13.099h-322.603c-47.189 0-85.333 38.187-85.333 85.333v682.666c0 47.104 38.144 85.333 85.333 85.333h170.197c0.171 0 0.299 0.085 0.469 0.085 23.595 0 42.667-19.115 42.667-42.667s-19.072-42.667-42.667-42.667v-0.085h-170.667v-682.666h255.915v171.52c0 46.464 37.888 84.267 84.437 84.267h171.648v43.050h0.085c0.256 23.381 19.157 42.283 42.581 42.283 23.467 0 42.368-18.901 42.581-42.283h0.085v-0.213z" + "M488.427 93.504c13.867-10.894 33.28-10.894 47.147 0l345.6 271.515c9.357 7.349 14.827 18.648 14.827 30.618v426.665c0 30.861-12.139 60.459-33.741 82.283-21.606 21.824-50.906 34.082-81.459 34.082h-537.6c-30.553 0-59.855-12.258-81.459-34.082s-33.741-51.422-33.741-82.283v-426.665c0-11.97 5.471-23.269 14.825-30.618l345.602-271.515zM204.8 414.607v407.695c0 10.287 4.046 20.156 11.247 27.43 7.202 7.27 16.969 11.358 27.153 11.358h537.6c10.185 0 19.951-4.087 27.153-11.358 7.202-7.275 11.247-17.143 11.247-27.43v-407.695l-307.2-241.347-307.2 241.347z", + "M341.333 508.446c0-21.602 19.103-39.113 42.667-39.113h256c23.565 0 42.667 17.51 42.667 39.113v391.108c0 21.602-19.102 39.113-42.667 39.113s-42.667-17.51-42.667-39.113v-352h-170.667v352c0 21.602-19.103 39.113-42.667 39.113s-42.667-17.51-42.667-39.113v-391.108z" ], - "attrs": [{}, {}, {}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["sign"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + "tags": ["home"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}, {}, {}], - "properties": { "order": 69, "id": 45, "name": "sign", "prevSize": 32, "code": 59711 }, + "attrs": [{}, {}], + "properties": { "order": 315, "id": 59, "name": "home", "prevSize": 32, "code": 59715 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 58 }, { "icon": { "paths": [ - "M256.021 853.35v-682.667h255.915v171.52c0 46.421 37.888 84.224 84.437 84.224h171.648v426.923h-512zM732.821 341.478l-135.552 0.725v-136.107l135.552 135.381zM842.005 329.958l-231.765-231.509c-8.405-8.405-19.755-13.099-31.573-13.099h-322.646c-47.147 0-85.333 38.229-85.333 85.333v682.667c0 47.104 38.187 85.333 85.333 85.333h512c47.104 0 85.333-38.229 85.333-85.333v-496.171c0-10.197-4.096-20.011-11.349-27.221z" + "M512 426.667c23.564 0 42.667 19.103 42.667 42.667v256c0 23.564-19.103 42.667-42.667 42.667s-42.667-19.103-42.667-42.667v-256c0-23.564 19.103-42.667 42.667-42.667z", + "M512 245.333c29.457 0 53.333 23.878 53.333 53.333s-23.876 53.333-53.333 53.333c-29.457 0-53.333-23.878-53.333-53.333s23.876-53.333 53.333-53.333z", + "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM170.667 512c0 188.514 152.82 341.333 341.333 341.333s341.333-152.819 341.333-341.333c0-188.513-152.819-341.333-341.333-341.333s-341.333 152.82-341.333 341.333z" ], - "attrs": [{}], + "attrs": [{}, {}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["document"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["info"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}] } }, - "attrs": [{}], - "properties": { "order": 68, "id": 44, "name": "document", "prevSize": 32, "code": 59712 }, + "attrs": [{}, {}, {}], + "properties": { "order": 316, "id": 58, "name": "info", "prevSize": 32, "code": 59716 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 59 }, { "icon": { "paths": [ - "M204.905 120.436c21.922-22.477 51.655-35.103 82.657-35.103h299.25c11.162 0 21.862 4.546 29.756 12.637l224.439 230.113c7.893 8.091 12.326 19.066 12.326 30.509v460.224c0 31.782-12.314 62.268-34.236 84.745-0.004 0-0.004 0-0.004 0.004-0.004 0-0.004 0.004-0.009 0.004-21.926 22.485-51.661 35.098-82.65 35.098h-448.873c-30.997 0-60.733-12.617-82.661-35.106-21.923-22.477-34.234-52.962-34.234-84.745v-613.632c0-31.786 12.316-62.27 34.237-84.747 0 0 0 0 0-0zM287.562 171.626c-8.682 0-17.007 3.536-23.144 9.828l-0 0.001c-6.138 6.294-9.587 14.83-9.587 23.729v613.632c0 8.905 3.45 17.438 9.584 23.727l0.006 0.009c6.132 6.289 14.453 9.822 23.141 9.822h448.873c8.687 0 17.011-3.537 23.138-9.822l0.013-0.013c6.135-6.285 9.583-14.822 9.583-23.723v-417.077h-182.396c-11.162 0-21.867-4.547-29.756-12.64-7.893-8.093-12.326-19.069-12.326-30.513l0.030-186.96h-257.159zM628.877 232.626l-0.013 82.82h80.794l-80.781-82.82zM320.31 396.963c0-23.829 18.841-43.146 42.082-43.146h74.813c23.241 0 42.082 19.317 42.082 43.146s-18.842 43.148-42.082 43.148h-74.813c-23.241 0-42.082-19.319-42.082-43.148zM320.255 550.391c0-23.829 18.841-43.149 42.082-43.149h299.248c23.241 0 42.082 19.319 42.082 43.149s-18.842 43.145-42.082 43.145h-299.248c-23.241 0-42.082-19.315-42.082-43.145zM320.255 703.761c0-23.829 18.841-43.145 42.082-43.145h299.248c23.241 0 42.082 19.315 42.082 43.145s-18.842 43.149-42.082 43.149h-299.248c-23.241 0-42.082-19.319-42.082-43.149z" + "M931.055 55.163c16.663 16.662 16.663 43.678 0 60.34l-51.177 51.174 91.847 91.845c8.004 8.001 12.498 18.854 12.498 30.17s-4.494 22.168-12.498 30.17l-142.353 142.351c-16.663 16.662-43.674 16.662-60.336 0l-91.847-91.845-109.080 109.080c12.153 16.657 22.353 34.708 30.38 53.806 13.602 32.387 20.668 67.144 20.787 102.273s-6.713 69.939-20.105 102.411c-13.386 32.478-33.070 61.986-57.909 86.83-24.841 24.838-54.349 44.521-86.827 57.907-32.477 13.392-67.283 20.224-102.412 20.105-35.129-0.114-69.888-7.179-102.275-20.787s-61.763-33.485-86.436-58.493l-0.32-0.324c-48.52-50.233-75.366-117.521-74.759-187.358 0.607-69.84 28.62-136.647 78.005-186.032s116.192-77.398 186.031-78.005c55.689-0.484 109.752 16.488 154.844 47.985l363.6-363.601c16.663-16.662 43.679-16.662 60.342 0zM481.477 507.974c-0.382-0.343-0.759-0.695-1.132-1.054-34.142-32.976-79.87-51.223-127.335-50.81s-92.869 19.451-126.433 53.015c-33.564 33.564-52.603 78.97-53.015 126.432-0.412 47.388 17.776 93.047 50.652 127.169 16.736 16.924 36.643 30.373 58.585 39.595 22.011 9.244 45.635 14.046 69.509 14.126s47.53-4.562 69.603-13.659c22.073-9.102 42.128-22.477 59.010-39.361 16.882-16.879 30.258-36.938 39.357-59.011s13.743-45.727 13.663-69.598c-0.080-23.876-4.882-47.502-14.129-69.512-8.989-21.396-22.006-40.859-38.336-57.331zM737.53 309.028l61.673 61.676 82.011-82.012-61.673-61.676-82.011 82.012z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["file"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["key"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 67, "id": 43, "name": "file", "prevSize": 32, "code": 59713 }, + "properties": { "order": 317, "id": 57, "name": "key", "prevSize": 32, "code": 59717 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 60 }, { "icon": { "paths": [ - "M170.682 426.662c0-141.184 114.816-256 256.001-256 141.141 0 256 114.816 256 256s-114.859 256-256 256c-141.185 0-256.001-114.816-256.001-256zM926.097 865.83l-230.059-230.059c44.928-57.771 71.979-130.219 71.979-209.109 0-188.501-152.832-341.333-341.333-341.333-188.545 0-341.335 152.832-341.335 341.333s152.789 341.333 341.335 341.333c78.848 0 151.253-27.008 208.981-71.893l230.101 230.059c16.597 16.597 43.733 16.597 60.331 0s16.597-43.733 0-60.331z" + "M436.297 645.107c-62.274 10.381-103.791 108.117-116.765 155.686-18.836 51.895 207.581 155.686 259.476 142.716 41.519-10.381 60.548-73.519 64.87-103.795 25.95 38.925-129.737-207.582-207.582-194.607z", + "M463.155 663.104c10.304 4.791 21.722 12.48 33.822 22.43 24.188 19.895 49.519 47.535 72.017 74.854 22.421 27.23 41.668 53.692 53.7 70.938 3.034 4.343 5.589 8.085 7.616 11.085-2.449 13.982-7.778 33.455-16.512 50.833-9.958 19.81-22.775 33.886-37.935 37.675-3.947 0.99-11.093 1.105-21.717-0.516-10.295-1.57-22.669-4.582-36.395-8.819-27.439-8.474-59.337-21.559-89.139-36.676-29.905-15.172-57.021-32.051-75.28-47.893-9.178-7.966-15.485-15.13-18.953-21.141-3.481-6.029-3.189-9.178-2.654-10.65l0.181-0.503 0.14-0.512c6.235-22.861 19.412-57.958 38.382-88.329 19.374-31.023 42.677-53.751 68.001-57.975 6.144-1.024 14.315 0.358 24.725 5.201zM635.75 804.851c-12.066-16.841-28.335-38.622-46.729-60.958-22.908-27.819-49.476-56.939-75.563-78.4-13.035-10.722-26.347-19.861-39.36-25.916-12.898-6.003-26.624-9.485-39.936-7.266-36.953 6.157-65.546 37.483-85.743 69.82-20.475 32.781-34.496 70.080-41.283 94.797-3.871 11.311-0.641 22.541 4.769 31.915 5.511 9.549 14.209 18.906 24.419 27.767 20.518 17.805 49.72 35.797 80.549 51.435 30.933 15.697 64.196 29.367 93.218 38.332 14.502 4.476 28.186 7.851 40.137 9.677 11.614 1.775 22.895 2.3 31.923 0.038 26.355-6.588 43.81-29.274 54.831-51.196 8.704-17.318 14.37-36.066 17.562-51.166 0.602-0.294 1.169-0.623 1.69-0.981 2.325-3.465 3.494-9.173 3.345-10.859-0.145-0.789-0.474-1.997-0.619-2.428-0.26-0.717-0.529-1.259-0.602-1.399-0.115-0.23-0.218-0.418-0.277-0.533-0.128-0.23-0.247-0.435-0.329-0.572-0.311-0.525-0.755-1.225-1.246-1.988-2.031-3.149-6.353-9.634-12.454-18.377l-8.218-12.326-0.085 0.585z", + "M812.535 593.212h-272.452v12.975c0 7.859 3.085 16.508 6.946 24.576 4.041 8.444 9.707 17.818 16.422 27.58 13.453 19.546 31.765 41.579 51.601 62.242 19.827 20.651 41.506 40.282 61.794 54.865 10.138 7.287 20.173 13.487 29.649 17.916 9.297 4.343 18.995 7.428 28.194 7.428 14.682 0 28.659-7.497 40.725-16.956 12.331-9.668 24.329-22.703 35.311-36.642 21.99-27.904 41.25-61.146 52.335-83.315l2.901-5.798-2.91-5.824-0.021-0.034-0.064-0.132-0.239-0.474-0.892-1.732c-0.764-1.481-1.869-3.58-3.23-6.093-2.709-5.009-6.498-11.746-10.743-18.539-4.186-6.699-9.075-13.867-14.007-19.503-2.453-2.803-5.252-5.619-8.294-7.829-2.769-2.018-7.33-4.71-13.026-4.71zM851.46 658.078l11.593-5.82c0 0.004 0.009 0.021-11.593 5.82z", + "M488.192 100.203l-298.4 493.009c-25.129 41.515 32.776 155.686 64.87 207.582l337.321-557.877c0-134.929-69.193-151.363-103.791-142.713z", + "M496.329 111.817l-295.437 488.11c-3.936 6.507-5.496 17.719-3.226 34.163 2.208 15.987 7.727 34.748 15.225 54.396 11.748 30.788 27.869 62.515 41.877 87.057l324.223-536.216c-0.602-63.161-17.118-95.872-34.714-112.061-15.842-14.575-34.33-17.4-47.949-15.449zM561.847 108.17c25.318 23.294 43.11 65.333 43.11 134.746v3.617l-350.171 579.13-11.159-18.048c-16.34-26.419-39.261-68.685-54.98-109.879-7.849-20.57-14.1-41.382-16.685-60.1-2.522-18.261-1.897-36.89 6.731-51.145l301.166-497.579 5.188-1.296c20.774-5.195 51.43-2.787 76.8 20.553z", + "M617.929 307.785l-103.791 168.661c-10.381 16.866-4.326 41.626 0 51.895h207.582l25.95-51.895c0-103.791-86.494-155.687-129.741-168.661z", + "M612.109 292.494l9.553 2.865c45.683 13.705 138.982 68.914 138.982 181.087v3.063l-30.903 61.807h-224.213l-3.345-7.94c-2.645-6.285-5.611-16.393-6.49-27.486-0.866-10.961 0.188-24.525 7.398-36.245l109.018-177.151zM623.548 323.414l-98.355 159.833c-3.17 5.146-4.279 12.395-3.631 20.599 0.329 4.139 1.071 8.102 1.946 11.52h190.199l20.966-41.929c-1.536-86.647-69.116-133.952-111.125-150.024z" ], - "attrs": [{}], + "attrs": [{}, {}, {}, {}, {}, {}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["search"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["keystone"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}, {}, {}, {}, {}] } }, - "attrs": [{}], - "properties": { "order": 66, "id": 42, "name": "search", "prevSize": 32, "code": 59714 }, + "attrs": [{}, {}, {}, {}, {}, {}, {}], + "properties": { "order": 318, "id": 56, "name": "keystone", "prevSize": 32, "code": 59718 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 61 }, { "icon": { "paths": [ - "M750.626 385.792l-110.123-111.061 105.344-103.253 106.539 107.477c0.256 0.256-0.213 4.352 0.085 4.608l-101.845 102.229zM281.163 853.333h-110.507v-111.829l408.962-406.955 110.72 111.659-409.175 407.125zM938.658 279.765c-0.299-23.723-9.728-45.653-25.685-60.885l-105.771-106.667c-15.744-16.811-38.187-26.581-61.653-26.88h-1.152c-23.509 0-46.293 9.472-62.635 25.899l-583.852 582.272c-8.021 7.979-12.587 18.901-12.587 30.251v172.245c0 23.595 19.072 42.667 42.667 42.667h170.795c11.307 0 22.101-4.437 30.080-12.416l584.108-582.485c16.64-16.811 26.027-40.149 25.685-64z" + "M0 383.312h233.335v225.015h-233.335v-225.015z", + "M404.644 772.372h233.334v225.014h-233.334v-225.014z", + "M0 155.611c0-85.942 69.669-155.611 155.611-155.611h77.724v216.677h-233.335v-61.066z", + "M0 841.836c0 85.944 69.669 155.612 155.611 155.612h77.724v-216.678h-233.335v61.066z", + "M1023.949 841.836c0 85.944-69.668 155.612-155.612 155.612h-77.722v-216.678h233.334v61.066z", + "M390.644 0h508.863c68.756 0 124.488 55.735 124.488 124.489v483.87h-633.351v-608.358z" ], - "attrs": [{}], + "attrs": [{}, {}, {}, {}, {}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["edit"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["ledger"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}, {}, {}, {}] } }, - "attrs": [{}], - "properties": { "order": 65, "id": 41, "name": "edit", "prevSize": 32, "code": 59715 }, + "attrs": [{}, {}, {}, {}, {}, {}], + "properties": { "order": 319, "id": 55, "name": "ledger", "prevSize": 32, "code": 59719 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 62 }, { "icon": { "paths": [ - "M426.667 768.414c23.595 0 42.667-19.072 42.667-42.667v-256c0-23.595-19.072-42.667-42.667-42.667s-42.667 19.072-42.667 42.667v256c0 23.595 19.072 42.667 42.667 42.667z", - "M597.376 768.414c23.595 0 42.667-19.072 42.667-42.667v-256c0-23.595-19.072-42.667-42.667-42.667s-42.667 19.072-42.667 42.667v256c0 23.595 19.072 42.667 42.667 42.667z", - "M704.546 834.167c-0.555 10.837-11.008 19.328-21.205 19.243h-343.34c-9.813-1.109-19.669-8.363-20.224-19.541l-27.648-492.544h438.914l-26.496 492.843zM426.528 196.941c0-14.251 12.076-26.283 26.327-26.283h118.101c14.507 0 26.325 11.819 26.325 26.283v59.051h-170.754v-59.051zM853.325 255.991h-74.155c-0.256 0-0.512-0.213-0.811-0.213-0.512-0.043-0.939 0.213-1.493 0.213h-94.251v-59.051c0-61.525-50.048-111.616-111.659-111.616h-118.101c-61.57 0-111.66 50.091-111.66 111.616v59.051h-170.539c-23.552 0-42.667 19.072-42.667 42.667s19.115 42.667 42.667 42.667h36.053l27.861 496.939c2.688 55.893 49.536 100.523 104.107 100.523 0.768 0 1.536-0.043 2.304-0.043h343.767c55.467 0 102.357-44.544 105.045-100.181l26.709-497.237h36.821c23.595 0 42.667-19.072 42.667-42.667s-19.072-42.667-42.667-42.667z" + "M512.034 85.333c-148.545 0-269.399 120.837-269.399 269.383 0 50.643 14.25 100.063 41.186 142.888 9.838 15.676 21.71 38.157 25.35 50.769l37.98 131.631c3.839 13.239 11.514 25.114 21.421 34.714-2.881 5.188-4.683 11.068-4.683 17.425v127.108c0 19.887 16.141 36.032 36.032 36.032h18.268c-1.172 3.243-1.944 6.635-1.944 10.202 0 18.321 16.163 33.182 36.033 33.182h119.467c19.908 0 36.032-14.861 36.032-33.182 0-3.584-0.777-6.976-1.894-10.202h18.197c19.908 0 36.032-16.145 36.032-36.032v-127.108c0-5.709-1.459-11.063-3.819-15.851 10.829-10.018 19.187-22.613 23.113-36.898l35.959-131.324c3.494-12.736 15.117-34.987 24.777-50.355 26.97-42.825 41.22-92.259 41.22-142.976 0-148.552-120.819-269.406-269.329-269.406zM679.138 459.319c-4.198 6.686-25.532 41.438-33.28 69.67l-35.959 131.319c-0.666 2.432-5.619 6.217-8.141 6.234h-176.688c-2.683 0-7.947-3.998-8.685-6.537l-37.98-131.631c-7.963-27.58-29.332-62.37-33.549-69.111-19.729-31.347-30.159-67.505-30.159-104.547 0-108.804 88.514-197.318 197.332-197.318 108.77 0 197.265 88.514 197.265 197.318 0 37.096-10.415 73.273-30.157 104.603z" ], - "attrs": [{}, {}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["delete"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + "tags": ["lightbulb"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}, {}], - "properties": { "order": 64, "id": 40, "name": "delete", "prevSize": 32, "code": 59716 }, + "attrs": [{}], + "properties": { "order": 320, "id": 54, "name": "lightbulb", "prevSize": 32, "code": 59720 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 63 }, { "icon": { "paths": [ - "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM170.667 512c0 188.514 152.82 341.333 341.333 341.333s341.333-152.819 341.333-341.333c0-188.513-152.819-341.333-341.333-341.333s-341.333 152.82-341.333 341.333z", - "M572.343 512l90.509 90.509c16.661 16.661 16.661 43.678 0 60.339-16.661 16.666-43.678 16.666-60.339 0l-90.509-90.509-90.511 90.509c-16.663 16.666-43.677 16.666-60.34 0-16.662-16.661-16.662-43.678 0-60.339l90.508-90.509-90.508-90.51c-16.662-16.663-16.662-43.677 0-60.34s43.677-16.662 60.34 0l90.511 90.511 90.509-90.511c16.661-16.662 43.678-16.662 60.339 0s16.661 43.677 0 60.34l-90.509 90.51z" + "M503.241 180.42l25.553-24.751c93.803-93.751 245.824-93.751 339.584 0.008 91.268 91.269 93.734 237.657 7.407 331.9l-7.403 7.731-128.781 128.823c-93.76 93.709-245.815 93.709-339.584-0.009-9.055-9.054-17.322-18.752-24.714-28.979-13.807-19.093-9.521-45.764 9.574-59.575 19.095-13.807 45.77-9.519 59.577 9.579 4.732 6.545 10.052 12.787 15.893 18.628 58.283 58.249 151.539 60.326 212.305 6.251l6.613-6.238 128.772-128.815c60.48-60.479 60.48-158.478 0-218.956-58.278-58.279-151.488-60.361-212.723-5.82l-6.669 6.293-26.027 25.216c-16.926 16.397-43.938 15.97-60.335-0.954-15.134-15.622-15.936-39.84-2.658-56.369l3.614-3.963z", + "M284.471 400.029c93.76-93.76 245.824-93.76 339.636 0 6.622 6.624 12.804 13.569 18.564 20.845 14.622 18.477 11.494 45.31-6.98 59.936-18.479 14.622-45.312 11.494-59.938-6.98-3.712-4.698-7.697-9.173-11.977-13.453-58.321-58.287-151.572-60.369-212.354-6.246l-6.617 6.242-128.797 128.755c-60.448 60.48-60.448 158.494-0.009 218.965 58.319 58.317 151.525 60.403 212.16 6.426l6.596-6.225 32.128-32.555c16.555-16.772 43.567-16.951 60.339-0.401 15.484 15.279 16.823 39.475 3.921 56.299l-3.524 4.041-32.324 32.755c-93.804 93.803-245.832 93.803-339.644-0.009-91.216-91.268-93.681-237.641-7.383-331.9l7.397-7.731 128.806-128.764z" ], "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["close-outlined"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["link"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, "attrs": [{}, {}], - "properties": { "order": 63, "id": 39, "name": "close-outlined", "prevSize": 32, "code": 59717 }, + "properties": { "order": 321, "id": 53, "name": "link", "prevSize": 32, "code": 59721 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 64 }, { "icon": { "paths": [ - "M938.675 511.987c0 235.648-191.061 426.667-426.667 426.667-235.646 0-426.665-191.019-426.665-426.667 0-235.605 191.019-426.667 426.665-426.667 235.605 0 426.667 191.061 426.667 426.667zM572.352 512.004l146.782-146.765c16.597-16.64 16.597-43.733 0-60.373-16.64-16.597-43.733-16.597-60.373 0l-146.761 146.782-146.784-146.782c-16.597-16.597-43.733-16.597-60.331 0-16.597 16.64-16.597 43.733 0 60.373l146.763 146.765-146.763 146.782c-16.597 16.597-16.597 43.733 0 60.331s43.733 16.597 60.331 0l146.784-146.765 146.761 146.765c16.64 16.597 43.733 16.597 60.373 0 16.597-16.597 16.597-43.733 0-60.331l-146.782-146.782z" + "M768 810.522c0 23.552-19.115 42.667-42.667 42.667h-426.667c-23.552 0-42.667-19.115-42.667-42.667v-298.667c0-23.552 19.115-42.667 42.667-42.667h426.667c23.552 0 42.667 19.115 42.667 42.667v298.667zM512.171 170.65c94.123 0 170.667 76.587 170.667 170.709v42.496h-341.333v-42.496c0-94.123 76.544-170.709 170.667-170.709zM768.171 391.791v-50.432c0-141.184-114.859-256.043-256-256.043-141.184 0-256 114.859-256 256.043v50.304c-49.664 17.621-85.504 64.555-85.504 120.192v298.667c0 70.571 57.429 128 128 128h426.667c70.571 0 128-57.429 128-128v-298.667c0-55.509-35.712-102.357-85.163-120.064z", + "M576 618.398c0-35.371-28.672-64-64-64s-64 28.629-64 64c0 18.901 8.32 35.712 21.419 47.403v59.093c0 23.637 19.157 42.795 42.795 42.795 23.68 0 42.837-19.157 42.837-42.795v-59.392c12.8-11.733 20.949-28.416 20.949-47.104z" ], - "attrs": [{}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["close-filled"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["lock"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}], - "properties": { "order": 62, "id": 38, "name": "close-filled", "prevSize": 32, "code": 59718 }, + "attrs": [{}, {}], + "properties": { "order": 322, "id": 52, "name": "lock", "prevSize": 32, "code": 59722 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 65 }, { "icon": { "paths": [ - "M566.336 512l318.455 318.46c14.942 14.976 14.942 39.364 0 54.306-14.98 14.98-39.369 14.98-54.345 0l-318.447-318.434-318.446 318.434c-14.979 14.98-39.367 14.98-54.345 0-14.94-14.942-14.94-39.33 0-54.306l318.455-318.46-318.459-318.449c-14.94-14.979-14.94-39.367 0-54.346 14.979-14.94 39.367-14.94 54.345 0l318.449 318.455 318.451-318.455c14.976-14.94 39.364-14.94 54.345 0 14.938 14.979 14.938 39.368 0 54.346l-318.46 318.449z" - ], - "attrs": [ - { - "strokeLinejoin": "miter", - "strokeLinecap": "butt", - "strokeMiterlimit": "4", - "strokeWidth": 42.666666666666664 - } + "M484.167 989.582c-5.278 0-9.861-1.803-13.75-5.416-3.611-3.334-5.833-7.919-6.666-13.75-6.389-45.557-13.334-83.888-20.834-115.001-7.222-30.834-16.944-56.246-29.166-76.248s-28.334-35.971-48.333-47.918c-19.722-11.947-45-21.527-75.833-28.752-30.556-7.498-68.333-14.165-113.333-19.996-6.111-0.558-10.972-2.782-14.583-6.667-3.611-3.891-5.417-8.613-5.417-14.165 0-5.279 1.806-9.865 5.417-13.75 3.611-3.891 8.472-6.116 14.583-6.667 45-5.001 82.917-10.974 113.75-17.92 30.833-6.94 56.25-16.526 76.25-28.746 20-12.225 36.111-28.475 48.333-48.752s21.944-45.972 29.166-77.083c7.5-31.389 14.167-70 20-115.834 0.833-5.555 3.056-10 6.666-13.333 3.889-3.611 8.472-5.417 13.75-5.417 5.556 0 10.139 1.806 13.75 5.417 3.611 3.333 5.972 7.778 7.083 13.333 5.833 45.834 12.361 84.445 19.583 115.834 7.5 31.111 17.223 56.805 29.167 77.083 12.222 20.277 28.331 36.527 48.333 48.752 20.002 12.22 45.414 21.806 76.248 28.746 30.834 6.946 68.892 12.919 114.17 17.92 5.831 0.552 10.553 2.776 14.165 6.667 3.612 3.886 5.416 8.471 5.416 13.75 0 5.552-1.803 10.274-5.416 14.165-3.612 3.886-8.334 6.11-14.165 6.667-45.278 5.001-83.337 10.968-114.17 17.914s-56.246 16.526-76.248 28.752c-20.002 11.941-36.111 28.052-48.333 48.333-11.944 20.275-21.667 46.108-29.167 77.5-7.222 31.391-13.75 69.86-19.583 115.416-1.111 5.831-3.472 10.416-7.083 13.75-3.611 3.612-8.194 5.416-13.75 5.416zM201.667 528.75c-8.333 0-13.055-4.583-14.166-13.75-3.334-27.778-6.944-49.583-10.833-65.417-3.611-15.833-9.584-27.639-17.917-35.417-8.055-8.055-20.278-14.166-36.667-18.333-16.112-4.167-38.333-8.611-66.667-13.334-9.444-1.389-14.167-6.111-14.167-14.166 0-7.778 4.167-12.361 12.5-13.75 28.611-5.556 51.111-10.417 67.5-14.583 16.389-4.444 28.75-10.555 37.083-18.333s14.445-19.306 18.334-34.583c3.889-15.556 7.5-37.222 10.833-65 1.111-9.167 5.833-13.75 14.166-13.75s13.055 4.444 14.167 13.333c3.611 28.056 7.222 50.139 10.833 66.251 3.888 16.111 9.861 28.333 17.917 36.667 8.333 8.055 20.694 14.028 37.083 17.917s39.028 7.917 67.917 12.083c3.611 0.278 6.528 1.667 8.75 4.167 2.5 2.5 3.75 5.695 3.75 9.584 0 7.778-4.167 12.5-12.5 14.166-28.889 5.556-51.528 10.556-67.917 15-16.111 4.167-28.334 10.278-36.667 18.334-8.055 7.778-14.028 19.445-17.917 35s-7.639 37.222-11.25 65c-0.556 3.611-2.084 6.667-4.584 9.167s-5.695 3.75-9.584 3.75zM403.333 242.083c-5.278 0-8.333-2.778-9.167-8.333-3.333-16.944-6.25-30.278-8.75-40-2.222-9.722-5.833-17.083-10.833-22.083-4.722-5.278-12.083-9.445-22.083-12.5s-24.028-6.111-42.084-9.167c-5.555-1.111-8.333-4.305-8.333-9.584 0-5 2.778-8.055 8.333-9.167 18.056-3.333 32.084-6.389 42.084-9.167 10-3.056 17.361-7.084 22.083-12.084 5-5.278 8.611-12.778 10.833-22.5 2.5-9.722 5.417-23.056 8.75-40 0.833-5.556 3.889-8.333 9.167-8.333 5 0 8.056 2.778 9.167 8.333 3.056 16.944 5.833 30.278 8.333 40s6.111 17.222 10.833 22.5c5 5 12.5 9.028 22.5 12.084 10 2.777 24.028 5.833 42.083 9.167 5.556 1.111 8.334 4.167 8.334 9.167 0 5.278-2.778 8.472-8.334 9.584-18.055 3.056-32.083 6.111-42.083 9.167s-17.5 7.222-22.5 12.5c-4.722 5-8.333 12.361-10.833 22.083s-5.278 23.056-8.333 40c-1.111 5.555-4.167 8.333-9.167 8.333z" ], + "attrs": [{}], + "width": 853, "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["close"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + "tags": ["magic"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [ - { - "strokeLinejoin": "miter", - "strokeLinecap": "butt", - "strokeMiterlimit": "4", - "strokeWidth": 42.666666666666664 - } - ], - "properties": { "order": 134, "id": 37, "name": "close", "prevSize": 32, "code": 59719 }, + "attrs": [{}], + "properties": { "order": 323, "id": 51, "name": "magic", "prevSize": 32, "code": 59723 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 66 }, { "icon": { "paths": [ - "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM170.667 512c0 188.514 152.82 341.333 341.333 341.333s341.333-152.819 341.333-341.333c0-188.513-152.819-341.333-341.333-341.333s-341.333 152.82-341.333 341.333z", - "M554.667 469.333h128c23.565 0 42.667 19.102 42.667 42.667s-19.102 42.667-42.667 42.667h-128v128c0 23.565-19.102 42.667-42.667 42.667s-42.667-19.102-42.667-42.667v-128h-128c-23.564 0-42.667-19.102-42.667-42.667s19.103-42.667 42.667-42.667h128v-128c0-23.564 19.102-42.667 42.667-42.667s42.667 19.103 42.667 42.667v128z" + "M682.662 85.329h-341.334c-70.357 0-128 57.6-128 128v597.334c0 70.4 57.643 128 128 128h341.334c70.4 0 128-57.6 128-128v-597.334c0-70.4-57.6-128-128-128zM682.662 170.662c23.125 0 42.667 19.541 42.667 42.667v597.334c0 23.125-19.541 42.667-42.667 42.667h-341.334c-23.125 0-42.667-19.541-42.667-42.667v-597.334c0-23.125 19.541-42.667 42.667-42.667h341.334z", + "M511.996 714.953c-29.44 0-53.333 23.893-53.333 53.333s23.893 53.333 53.333 53.333c29.44 0 53.333-23.893 53.333-53.333s-23.893-53.333-53.333-53.333z" ], "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["plus-outlined"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["mobile"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, "attrs": [{}, {}], - "properties": { "order": 60, "id": 36, "name": "plus-outlined", "prevSize": 32, "code": 59720 }, + "properties": { "order": 324, "id": 50, "name": "mobile", "prevSize": 32, "code": 59724 }, "setIdx": 0, - "setId": 2, + "setId": 0, "iconIdx": 67 }, { "icon": { "paths": [ - "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM682.667 469.333h-128v-128c0-23.564-19.102-42.667-42.667-42.667s-42.667 19.103-42.667 42.667v128h-128c-23.564 0-42.667 19.102-42.667 42.667s19.103 42.667 42.667 42.667h128v128c0 23.565 19.102 42.667 42.667 42.667s42.667-19.102 42.667-42.667v-128h128c23.565 0 42.667-19.102 42.667-42.667s-19.102-42.667-42.667-42.667z" + "M879.501 335.075h-737.449v73.955h737.449v-73.955z", + "M902.882 381.44l-9.216-7.055c-9.903-7.566-11.891-21.675-4.437-31.63l6.997-9.386c11.375-14.963 6.37-33.338-6.716-48.755l-95.343-133.517c-14.336-20.082-37.491-32.029-62.182-32.029h-440.262c-24.689 0-47.843 11.947-62.179 32.029l-95.119 133.517c-13.084 15.418-18.033 33.736-6.713 48.755l6.997 9.386c7.395 10.013 5.461 24.064-4.437 31.63l-9.216 7.055c-14.961 11.378-17.92 32.71-6.542 47.675l335.073 466.543c5.009 6.545 12.745 10.411 20.996 10.411h82.658c8.247 0 15.987-3.866 20.992-10.411l335.245-466.543c11.379-14.965 8.422-36.354-6.541-47.675h-0.055zM519.45 838.37c-3.814 5.009-11.319 5.009-15.134 0l-335.188-459.603c-3.813-5.007-2.56-12.288 1.138-17.237l105.984-148.538c8.931-12.573 23.38-19.968 38.798-19.968h393.615c15.415 0 29.867 7.453 38.797 19.968l106.155 148.538c3.755 4.949 4.949 12.288 1.139 17.237l-335.305 459.603z" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["nft"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 325, "id": 49, "name": "nft", "prevSize": 32, "code": 59725 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 68 + }, + { + "icon": { + "paths": [ + "M879.589 448c44.408 0 80.408 36 80.408 80.408v1.103c0 44.408-36 80.408-80.408 80.408s-80.408-36-80.408-80.408v-1.103c0-44.408 36-80.408 80.408-80.408z", + "M512 448c44.408 0 80.408 36 80.408 80.408v1.103c0 44.408-36 80.408-80.408 80.408s-80.408-36-80.408-80.408v-1.103c0-44.408 36-80.408 80.408-80.408z", + "M144.408 448c44.408 0 80.408 36 80.408 80.408v1.103c0 44.408-36 80.408-80.408 80.408s-80.408-36-80.408-80.408v-1.103c0-44.408 36-80.408 80.408-80.408z" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["options-horizontal"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 326, "id": 48, "name": "options-horizontal", "prevSize": 32, "code": 59726 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 69 + }, + { + "icon": { + "paths": [ + "M512 245.68c-49.939 0-90.422-40.483-90.422-90.422s40.483-90.422 90.422-90.422c49.939 0 90.422 40.483 90.422 90.422s-40.484 90.422-90.422 90.422z", + "M512 603.258c-49.939 0-90.422-40.483-90.422-90.422s40.483-90.422 90.422-90.422c49.939 0 90.422 40.483 90.422 90.422s-40.484 90.422-90.422 90.422z", + "M512 960.838c-49.939 0-90.422-40.486-90.422-90.426s40.483-90.419 90.422-90.419c49.939 0 90.422 40.48 90.422 90.419s-40.483 90.426-90.422 90.426z" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["options-vertical"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 327, "id": 47, "name": "options-vertical", "prevSize": 32, "code": 59727 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 70 + }, + { + "icon": { + "paths": [ + "M682.671 383.957c47.061 0 85.333 38.272 85.333 85.333s-38.272 85.333-85.333 85.333c-47.061 0-85.333-38.272-85.333-85.333s38.272-85.333 85.333-85.333zM341.252 170.667c47.061 0 85.333 38.272 85.333 85.333s-38.272 85.333-85.333 85.333c-47.019 0-85.333-38.272-85.333-85.333s38.315-85.333 85.333-85.333zM979.503 884.011c-29.355-100.779-81.621-228.523-204.416-271.616 46.976-30.421 78.251-83.072 78.251-143.104 0-94.123-76.544-170.667-170.667-170.667-86.315 0-157.141 64.64-168.363 147.925-22.528-19.541-49.408-35.968-81.195-47.189 47.275-30.379 78.805-83.115 78.805-143.36 0-94.123-76.544-170.667-170.667-170.667s-170.667 76.544-170.667 170.667c0 60.032 31.317 112.725 78.336 143.104-122.88 43.093-175.147 170.88-204.544 271.701-6.571 22.613 6.357 46.336 29.013 52.907 3.968 1.152 7.979 1.707 11.947 1.707 18.517 0 35.541-12.075 40.96-30.72 46.080-157.909 110.379-225.28 215.040-225.28 92.288 0 151.552 51.371 195.968 168.917-85.973 56.405-126.934 160.683-151.723 245.675-6.571 22.613 6.443 46.336 29.013 52.907 4.011 1.152 8.021 1.707 11.947 1.707 18.518 0 35.542-12.075 40.96-30.72 46.037-157.909 110.336-225.28 215.040-225.28 104.661 0 168.96 67.371 215.040 225.28 6.571 22.571 29.952 35.584 52.907 29.013 22.656-6.571 35.584-30.293 29.013-52.907z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["plus-filled"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["owners"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 59, "id": 35, "name": "plus-filled", "prevSize": 32, "code": 59721 }, + "properties": { "order": 328, "id": 46, "name": "owners", "prevSize": 32, "code": 59728 }, "setIdx": 0, - "setId": 2, - "iconIdx": 68 + "setId": 0, + "iconIdx": 71 + }, + { + "icon": { + "paths": [ + "M704.009 128h63.991c47.13 0 85.333 38.205 85.333 85.333v640c0 47.13-38.204 85.333-85.333 85.333h-512c-47.128 0-85.333-38.204-85.333-85.333v-640c0-47.128 38.205-85.333 85.333-85.333h63.992c19.46-25.908 50.444-42.667 85.341-42.667h213.333c34.897 0 65.882 16.759 85.342 42.667zM300.8 213.333h-44.8v640h512v-640h-44.8c-9.882 48.688-52.928 85.333-104.533 85.333h-213.333c-51.604 0-94.65-36.646-104.533-85.333zM384 192c0-11.782 9.551-21.333 21.333-21.333h213.333c11.78 0 21.333 9.551 21.333 21.333s-9.553 21.333-21.333 21.333h-213.333c-11.782 0-21.333-9.551-21.333-21.333z" + ], + "attrs": [{}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["paste"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } + }, + "attrs": [{}], + "properties": { "order": 329, "id": 45, "name": "paste", "prevSize": 32, "code": 59729 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 72 }, { "icon": { @@ -1639,60 +1495,196 @@ "isMulticolor2": false, "grid": 0, "tags": ["plus"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, "attrs": [{}, {}], - "properties": { "order": 58, "id": 34, "name": "plus", "prevSize": 32, "code": 59722 }, + "properties": { "order": 330, "id": 44, "name": "plus", "prevSize": 32, "code": 59730 }, "setIdx": 0, - "setId": 2, - "iconIdx": 69 + "setId": 0, + "iconIdx": 73 }, { "icon": { "paths": [ - "M512 1024c282.768 0 512-229.232 512-512 0-282.77-229.232-512-512-512-282.77 0-512 229.23-512 512 0 282.768 229.23 512 512 512z", - "M501.592 258.457c6.554-3.276 14.262-3.276 20.815 0l232.723 116.364c7.885 3.942 12.865 12 12.865 20.816s-4.98 16.874-12.865 20.816l-232.723 116.363c-6.554 3.277-14.262 3.277-20.815 0l-232.723-116.363c-7.884-3.942-12.865-12-12.865-20.816s4.98-16.874 12.865-20.816l232.723-116.364zM331.316 395.636l180.684 90.345 180.685-90.345-180.685-90.344-180.684 90.344zM258.462 501.592c5.748-11.497 19.727-16.156 31.223-10.408l222.315 111.16 222.315-111.16c11.497-5.748 25.474-1.089 31.223 10.408s1.089 25.474-10.408 31.223l-232.723 116.364c-6.554 3.277-14.262 3.277-20.815 0l-232.723-116.364c-11.496-5.748-16.156-19.726-10.408-31.223zM258.462 617.956c5.748-11.497 19.727-16.156 31.223-10.408l222.315 111.16 222.315-111.16c11.497-5.748 25.474-1.089 31.223 10.408s1.089 25.474-10.408 31.223l-232.723 116.364c-6.554 3.277-14.262 3.277-20.815 0l-232.723-116.364c-11.496-5.748-16.156-19.726-10.408-31.223z" + "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM682.667 469.333h-128v-128c0-23.564-19.102-42.667-42.667-42.667s-42.667 19.103-42.667 42.667v128h-128c-23.564 0-42.667 19.102-42.667 42.667s19.103 42.667 42.667 42.667h128v128c0 23.565 19.102 42.667 42.667 42.667s42.667-19.102 42.667-42.667v-128h128c23.565 0 42.667-19.102 42.667-42.667s-19.102-42.667-42.667-42.667z" ], - "attrs": [{ "fill": "rgb(255, 214, 0)" }, { "fill": "rgb(0, 0, 0)" }], - "isMulticolor": true, + "attrs": [{}], + "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["transaction-Batch"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "f": 2 }, { "f": 0 }] } + "tags": ["plus-filled"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{ "fill": "rgb(255, 214, 0)" }, { "fill": "rgb(0, 0, 0)" }], - "properties": { - "order": 57, - "id": 33, - "name": "transaction-Batch", - "prevSize": 32, - "code": 59723, - "codes": [59723, 59724] + "attrs": [{}], + "properties": { "order": 331, "id": 43, "name": "plus-filled", "prevSize": 32, "code": 59731 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 74 + }, + { + "icon": { + "paths": [ + "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM170.667 512c0 188.514 152.82 341.333 341.333 341.333s341.333-152.819 341.333-341.333c0-188.513-152.819-341.333-341.333-341.333s-341.333 152.82-341.333 341.333z", + "M554.667 469.333h128c23.565 0 42.667 19.102 42.667 42.667s-19.102 42.667-42.667 42.667h-128v128c0 23.565-19.102 42.667-42.667 42.667s-42.667-19.102-42.667-42.667v-128h-128c-23.564 0-42.667-19.102-42.667-42.667s19.103-42.667 42.667-42.667h128v-128c0-23.564 19.102-42.667 42.667-42.667s42.667 19.103 42.667 42.667v128z" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["plus-outlined"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, + "attrs": [{}, {}], + "properties": { "order": 332, "id": 42, "name": "plus-outlined", "prevSize": 32, "code": 59732 }, "setIdx": 0, - "setId": 2, - "iconIdx": 70 + "setId": 0, + "iconIdx": 75 }, { "icon": { "paths": [ - "M234.667 192h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z", - "M234.667 576h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z", - "M618.667 192h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z", - "M618.667 576h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z" + "M616.64 555.337l32.883-16.546c20.791-10.462 43.58-19.307 67.26-26.79-23.68-7.484-46.468-16.329-67.26-26.79l-32.883-16.546 11.55-34.953c7.305-22.107 17.165-44.481 28.621-66.52-22.042 11.453-44.416 21.316-66.522 28.62l-34.953 11.548-16.546-32.881c-10.466-20.793-19.307-43.582-26.79-67.258-7.484 23.677-16.329 46.466-26.79 67.258l-16.546 32.881-34.953-11.548c-22.107-7.304-44.48-17.167-66.52-28.62 11.453 22.039 21.316 44.413 28.62 66.519l11.548 34.953-32.881 16.546c-20.793 10.462-43.582 19.307-67.259 26.79 23.677 7.484 46.466 16.329 67.259 26.79l32.881 16.546-11.548 34.953c-7.304 22.106-17.167 44.48-28.62 66.522 22.039-11.456 44.413-21.316 66.52-28.621l34.953-11.55 16.546 32.883c10.462 20.791 19.307 43.58 26.79 67.26 7.484-23.68 16.324-46.468 26.79-67.26l16.546-32.883 34.953 11.55c22.106 7.305 44.48 17.165 66.522 28.621-11.456-22.042-21.316-44.416-28.621-66.522l-11.55-34.953zM734.609 703.876c62.31 93.005 139.422 170.155 139.422 170.155s-77.15-77.111-170.155-139.422c-40.994-27.469-85.069-52.062-126.972-65.907-19.84 39.42-33.613 87.974-43.174 136.384-21.7 109.828-21.73 218.914-21.73 218.914s-0.030-109.086-21.73-218.914c-9.562-48.41-23.339-96.964-43.174-136.384-41.902 13.845-85.977 38.438-126.973 65.903-93.002 62.315-170.156 139.426-170.156 139.426s77.113-77.15 139.426-170.155c27.468-40.994 52.061-85.069 65.905-126.972-39.419-19.84-87.973-33.613-136.382-43.174-109.828-21.7-218.916-21.73-218.916-21.73s109.088-0.030 218.916-21.73c48.41-9.562 96.963-23.334 136.382-43.174-13.844-41.902-38.437-85.977-65.905-126.973-62.314-93.002-139.427-170.156-139.427-170.156s77.154 77.113 170.156 139.427c40.995 27.468 85.071 52.061 126.973 65.905 19.836-39.419 33.613-87.972 43.174-136.382 21.7-109.828 21.73-218.916 21.73-218.916s0.030 109.088 21.73 218.916c9.562 48.41 23.334 96.963 43.174 136.382 41.903-13.844 85.978-38.437 126.972-65.905 93.005-62.313 170.155-139.426 170.155-139.426s-77.111 77.154-139.422 170.156c-27.469 40.996-52.062 85.071-65.907 126.973 39.42 19.84 87.974 33.613 136.384 43.174 109.828 21.7 218.914 21.73 218.914 21.73s-109.086 0.030-218.914 21.73c-48.41 9.562-96.964 23.334-136.384 43.174 13.845 41.903 38.438 85.978 65.907 126.972z" ], - "attrs": [{}, {}, {}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["blocks-1"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}] } + "tags": ["points"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}, {}, {}], - "properties": { "order": 56, "id": 32, "name": "blocks-1", "prevSize": 32, "code": 59725 }, + "attrs": [{}], + "properties": { "order": 333, "id": 41, "name": "points", "prevSize": 32, "code": 59733 }, "setIdx": 0, - "setId": 2, - "iconIdx": 71 + "setId": 0, + "iconIdx": 76 + }, + { + "icon": { + "paths": [ + "M384 85.333h-213.333c-47.128 0-85.333 38.205-85.333 85.333v213.333c0 47.13 38.205 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.128-38.204-85.333-85.333-85.333zM170.667 384v-213.333h213.333v213.333h-213.333z", + "M384 554.667h-213.333c-47.128 0-85.333 38.204-85.333 85.333v213.333c0 47.13 38.205 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.13-38.204-85.333-85.333-85.333zM170.667 853.333v-213.333h213.333v213.333h-213.333z", + "M853.333 85.333h-213.333c-47.13 0-85.333 38.205-85.333 85.333v213.333c0 47.13 38.204 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.128-38.204-85.333-85.333-85.333zM640 384v-213.333h213.333v213.333h-213.333z", + "M661.333 554.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", + "M917.333 554.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", + "M789.333 682.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", + "M917.333 810.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", + "M661.333 810.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z" + ], + "attrs": [{}, {}, {}, {}, {}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["qr-code"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}, {}, {}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}, {}, {}, {}, {}], + "properties": { "order": 334, "id": 40, "name": "qr-code", "prevSize": 32, "code": 59734 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 77 + }, + { + "icon": { + "paths": [ + "M384 85.333h-213.333c-47.128 0-85.333 38.205-85.333 85.333v213.333c0 47.13 38.205 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.128-38.204-85.333-85.333-85.333zM170.667 384v-213.333h213.333v213.333h-213.333z", + "M384 554.667h-213.333c-47.128 0-85.333 38.204-85.333 85.333v213.333c0 47.13 38.205 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.13-38.204-85.333-85.333-85.333zM170.667 853.333v-213.333h213.333v213.333h-213.333z", + "M853.333 85.333h-213.333c-47.13 0-85.333 38.205-85.333 85.333v213.333c0 47.13 38.204 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.128-38.204-85.333-85.333-85.333zM640 384v-213.333h213.333v213.333h-213.333z", + "M661.333 554.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", + "M917.333 554.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", + "M789.333 682.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", + "M917.333 810.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z", + "M661.333 810.667h-85.333c-11.782 0-21.333 9.551-21.333 21.333v85.333c0 11.782 9.551 21.333 21.333 21.333h85.333c11.782 0 21.333-9.551 21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333z" + ], + "attrs": [{}, {}, {}, {}, {}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["qr-code-1"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}, {}, {}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}, {}, {}, {}, {}], + "properties": { "order": 335, "id": 39, "name": "qr-code-1", "prevSize": 32, "code": 59735 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 78 + }, + { + "icon": { + "paths": [ + "M938.667 512c0 235.639-191.027 426.667-426.667 426.667-235.642 0-426.667-191.027-426.667-426.667 0-235.642 191.025-426.667 426.667-426.667 235.639 0 426.667 191.025 426.667 426.667zM170.667 512c0 188.514 152.82 341.333 341.333 341.333s341.333-152.819 341.333-341.333c0-188.513-152.819-341.333-341.333-341.333s-341.333 152.82-341.333 341.333z", + "M445.525 389.725c0-32.858 27.814-59.947 62.72-59.947 34.833 0 62.635 27.096 62.635 59.947 0.371 23.981-5.726 32.305-35.209 53.824l-2.014 1.468c-49.997 36.484-70.972 65.579-68.023 123.435l-0.055 8.41c0 23.565 19.102 42.667 42.667 42.667s42.667-19.102 42.667-42.667v-10.581c-1.165-23.983 3.981-31.121 33.045-52.331l2.022-1.476c49.643-36.233 71.13-65.574 70.229-123.413 0.004-79.814-66.492-144.615-147.964-144.615-81.54 0-148.052 64.783-148.052 145.28 0 23.564 19.102 42.667 42.667 42.667s42.666-19.103 42.666-42.667z", + "M512 672c29.457 0 53.333 23.876 53.333 53.333s-23.876 53.333-53.333 53.333c-29.457 0-53.333-23.876-53.333-53.333s23.876-53.333 53.333-53.333z" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["question"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 336, "id": 38, "name": "question", "prevSize": 32, "code": 59736 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 79 + }, + { + "icon": { + "paths": [ + "M880.35 303.767c14.694 26.481 26.65 54.71 35.588 84.342 68.083 225.455-58.581 463.706-282.889 532.083-224.292 68.373-461.277-58.931-529.306-284.335-68.087-225.471 58.556-463.71 282.895-532.082 40.347-12.292 81.811-18.442 123.202-18.442 26.048 0 47.168 21.225 47.168 47.408s-21.12 47.408-47.168 47.408c-32.141 0-64.384 4.783-95.834 14.364-174.462 53.171-272.951 238.447-219.995 413.815 52.906 175.296 237.219 274.304 411.662 221.129 174.434-53.18 272.947-238.468 219.998-413.818-7.317-24.248-17.203-47.249-29.414-68.718l-11.695 67.883c-4.446 25.798-28.855 43.091-54.524 38.624-25.664-4.467-42.871-29.004-38.426-54.803l29.056-168.631c4.39-25.487 28.297-42.732 53.726-38.755l167.019 26.122c25.737 4.026 43.358 28.262 39.351 54.134s-28.117 43.581-53.858 39.555l-46.558-7.282z", + "M362.099 548.407c-20.513 14.315-26.913 44.774-14.295 68.041s39.477 30.524 59.991 16.213l126.115-87.987c12.898-8.998 20.757-24.947 20.757-42.125v-197.091c0-27.315-19.524-49.458-43.605-49.458-24.085 0-43.61 22.143-43.61 49.458v169.447l-105.353 73.502z" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["repeat"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 337, "id": 37, "name": "repeat", "prevSize": 32, "code": 59737 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 80 + }, + { + "icon": { + "paths": [ + "M698.091 716.335l-140.16-55.339c-21.888-8.704-46.763 2.091-55.339 24.021-8.661 21.888 2.091 46.677 23.979 55.296l49.749 19.669c-28.8 10.027-59.136 15.787-90.027 15.787h-0.64c-96.811-0.213-187.307-52.395-236.245-136.149-11.819-20.309-37.973-27.221-58.325-15.317-20.352 11.861-27.221 37.973-15.317 58.325 64.085 109.781 182.741 178.176 309.76 178.475h0.768c40.789 0 80.939-7.637 119.040-21.035l-15.36 41.003c-8.277 22.059 2.901 46.635 25.003 54.912 4.907 1.835 9.941 2.731 14.933 2.731 17.28 0 33.536-10.539 39.979-27.733l52.48-140.032c8.192-21.76-2.603-46.080-24.277-54.613z", + "M673.843 242.419c28.971 0 52.48 23.552 52.48 52.48s-23.509 52.48-52.48 52.48c-28.928 0-52.437-23.552-52.437-52.48s23.509-52.48 52.437-52.48zM350.089 170.654c28.928 0 52.48 23.552 52.48 52.48 0 28.971-23.552 52.48-52.48 52.48-28.971 0-52.48-23.509-52.48-52.48 0-28.928 23.509-52.48 52.48-52.48zM893.235 554.953c-26.709-71.168-77.269-123.989-137.728-149.675 33.877-25.088 56.149-65.067 56.149-110.379 0-75.989-61.824-137.813-137.813-137.813-75.947 0-137.771 61.824-137.771 137.813 0 45.312 22.272 85.291 56.149 110.379-16.939 7.168-33.195 15.957-48.213 27.264-28.203-45.653-67.328-79.957-112.299-98.987 33.877-25.131 56.192-65.109 56.192-110.421 0-75.947-61.867-137.813-137.813-137.813-75.989 0-137.813 61.867-137.813 137.813 0 45.312 22.272 85.291 56.149 110.421-60.459 25.6-111.019 78.507-137.685 149.675-8.277 22.059 2.901 46.635 24.917 54.955 22.059 8.107 46.677-2.901 54.997-24.96 25.429-67.883 80.171-111.744 139.435-111.744 54.955 0 105.771 38.016 133.163 97.664-11.179 17.152-21.12 35.584-28.757 55.851-8.235 22.059 2.944 46.635 25.003 54.912 4.949 1.835 9.984 2.731 14.976 2.731 17.237 0 33.536-10.539 39.936-27.733 25.472-67.84 80.171-111.701 139.435-111.701s114.005 43.861 139.477 111.744c8.277 22.101 32.853 33.152 54.955 24.96 22.016-8.277 33.195-32.853 24.96-54.955z" + ], + "attrs": [{}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["replace-owner"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } + }, + "attrs": [{}, {}], + "properties": { "order": 338, "id": 36, "name": "replace-owner", "prevSize": 32, "code": 59738 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 81 + }, + { + "icon": { + "paths": [ + "M128 224c0-35.346 28.654-64 64-64h640c35.345 0 64 28.654 64 64s-28.655 64-64 64h-640c-35.346 0-64-28.654-64-64z", + "M128 736c0-35.345 28.654-64 64-64h640c35.345 0 64 28.655 64 64s-28.655 64-64 64h-640c-35.346 0-64-28.655-64-64z", + "M128 480c0-35.345 28.654-64 64-64h640c35.345 0 64 28.655 64 64s-28.655 64-64 64h-640c-35.346 0-64-28.655-64-64z" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["rows"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 339, "id": 35, "name": "rows", "prevSize": 32, "code": 59739 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 82 }, { "icon": { @@ -1706,720 +1698,672 @@ "isMulticolor2": false, "grid": 0, "tags": ["rows-1"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}] } }, "attrs": [{}, {}, {}], - "properties": { "order": 55, "id": 31, "name": "rows-1", "prevSize": 32, "code": 59726 }, + "properties": { "order": 340, "id": 34, "name": "rows-1", "prevSize": 32, "code": 59740 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 83 + }, + { + "icon": { + "paths": [ + "M341.333 490.667c0-35.345 28.654-64 64-64h426.667c35.345 0 64 28.655 64 64s-28.655 64-64 64h-426.667c-35.346 0-64-28.655-64-64z", + "M341.333 746.667c0-35.345 28.654-64 64-64h426.667c35.345 0 64 28.655 64 64s-28.655 64-64 64h-426.667c-35.346 0-64-28.655-64-64z", + "M341.333 234.667c0-35.346 28.654-64 64-64h426.667c35.345 0 64 28.654 64 64s-28.655 64-64 64h-426.667c-35.346 0-64-28.654-64-64z", + "M256 234.667c0 35.346-28.654 64-64 64s-64-28.654-64-64c0-35.346 28.654-64 64-64s64 28.654 64 64z", + "M256 490.667c0 35.346-28.654 64-64 64s-64-28.654-64-64c0-35.346 28.654-64 64-64s64 28.654 64 64z", + "M256 746.667c0 35.346-28.654 64-64 64s-64-28.654-64-64c0-35.346 28.654-64 64-64s64 28.654 64 64z" + ], + "attrs": [{}, {}, {}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["rows-2"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}, {}, {}], + "properties": { "order": 341, "id": 33, "name": "rows-2", "prevSize": 32, "code": 59741 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 84 + }, + { + "icon": { + "paths": [ + "M853.342 170.658c44.962 0 81.839 34.833 85.099 78.967l0.235 6.366v512c0 44.962-34.833 81.839-78.967 85.099l-6.494 0.192c0.004 22.118-16.439 40.128-37.683 42.59l-4.979 0.286c-21.871 0-39.915-16.444-42.381-37.687l-0.29-5.188h-511.958c0.004 22.118-16.477 40.128-37.689 42.59l-4.974 0.286c-23.595 0-42.667-19.072-42.667-42.667-44.93-0.205-81.763-35.029-85.018-79.138l-0.234-6.362v-512c0-44.963 34.833-81.84 78.968-85.099l6.365-0.234h682.668zM853.342 255.991h-682.668v512h682.668v-512z", + "M639.885 341.367c-94.251 0-170.667 76.416-170.667 170.667s76.416 170.667 170.667 170.667c94.251 0 170.667-76.416 170.667-170.667s-76.416-170.667-170.667-170.667zM639.885 426.701c47.061 0 85.333 38.229 85.333 85.333 0 47.061-38.272 85.333-85.333 85.333s-85.333-38.272-85.333-85.333c0-47.104 38.272-85.333 85.333-85.333z", + "M298.594 341.324c21.881 0 39.915 16.471 42.38 37.691l0.287 4.976v256c0 23.565-19.102 42.667-42.667 42.667-21.881 0-39.915-16.474-42.38-37.692l-0.287-4.975v-256c0-23.564 19.103-42.667 42.667-42.667z" + ], + "attrs": [{}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["safe"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}] } + }, + "attrs": [{}, {}, {}], + "properties": { "order": 342, "id": 32, "name": "safe", "prevSize": 32, "code": 59742 }, + "setIdx": 0, + "setId": 0, + "iconIdx": 85 + }, + { + "icon": { + "paths": [ + "M938.667 170.667v170.667c0 23.564-19.102 42.667-42.667 42.667s-42.667-19.103-42.667-42.667v-170.667h-170.667c-23.565 0-42.667-19.103-42.667-42.667s19.102-42.667 42.667-42.667h170.667c47.13 0 85.333 38.205 85.333 85.333z", + "M170.667 170.667v170.667c0 23.564-19.103 42.667-42.667 42.667s-42.667-19.103-42.667-42.667v-170.667c0-47.128 38.205-85.333 85.333-85.333h170.667c23.564 0 42.667 19.103 42.667 42.667s-19.103 42.667-42.667 42.667h-170.667z", + "M853.333 853.333v-170.667c0-23.565 19.102-42.667 42.667-42.667s42.667 19.102 42.667 42.667v170.667c0 47.13-38.204 85.333-85.333 85.333h-170.667c-23.565 0-42.667-19.102-42.667-42.667s19.102-42.667 42.667-42.667h170.667z", + "M170.667 853.333h170.667c23.564 0 42.667 19.102 42.667 42.667s-19.103 42.667-42.667 42.667h-170.667c-47.128 0-85.333-38.204-85.333-85.333v-170.667c0-23.565 19.103-42.667 42.667-42.667s42.667 19.102 42.667 42.667v170.667z", + "M128 469.333h768c23.564 0 42.667 19.103 42.667 42.667s-19.103 42.667-42.667 42.667h-768c-23.564 0-42.667-19.103-42.667-42.667s19.103-42.667 42.667-42.667z" + ], + "attrs": [{}, {}, {}, {}, {}], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 0, + "tags": ["scan"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}, {}, {}] } + }, + "attrs": [{}, {}, {}, {}, {}], + "properties": { "order": 343, "id": 31, "name": "scan", "prevSize": 32, "code": 59743 }, "setIdx": 0, - "setId": 2, - "iconIdx": 72 + "setId": 0, + "iconIdx": 86 }, { "icon": { "paths": [ - "M494.656 89.428c10.918-5.46 23.77-5.46 34.688 0l387.874 193.939c13.141 6.57 21.44 20.001 21.44 34.693s-8.299 28.122-21.44 34.693l-387.874 193.939c-10.918 5.461-23.77 5.461-34.688 0l-387.873-193.939c-13.14-6.57-21.441-20.001-21.441-34.693s8.3-28.123 21.441-34.693l387.873-193.939zM210.86 318.061l301.14 150.573 301.141-150.573-301.141-150.573-301.14 150.573zM89.437 494.652c9.58-19.157 32.879-26.927 52.038-17.344l370.525 185.267 370.526-185.267c19.157-9.583 42.458-1.813 52.036 17.344 9.583 19.162 1.813 42.462-17.344 52.041l-387.874 193.941c-10.918 5.457-23.77 5.457-34.688 0l-387.873-193.941c-19.16-9.579-26.926-32.879-17.346-52.041zM89.437 688.593c9.58-19.162 32.879-26.927 52.038-17.348l370.525 185.267 370.526-185.267c19.157-9.579 42.458-1.813 52.036 17.348 9.583 19.162 1.813 42.458-17.344 52.041l-387.874 193.937c-10.918 5.461-23.77 5.461-34.688 0l-387.873-193.937c-19.16-9.583-26.926-32.879-17.346-52.041z" + "M938.667 170.667v170.667c0 23.564-19.102 42.667-42.667 42.667s-42.667-19.103-42.667-42.667v-170.667h-170.667c-23.565 0-42.667-19.103-42.667-42.667s19.102-42.667 42.667-42.667h170.667c47.13 0 85.333 38.205 85.333 85.333z", + "M170.667 170.667v170.667c0 23.564-19.103 42.667-42.667 42.667s-42.667-19.103-42.667-42.667v-170.667c0-47.128 38.205-85.333 85.333-85.333h170.667c23.564 0 42.667 19.103 42.667 42.667s-19.103 42.667-42.667 42.667h-170.667z", + "M853.333 853.333v-170.667c0-23.565 19.102-42.667 42.667-42.667s42.667 19.102 42.667 42.667v170.667c0 47.13-38.204 85.333-85.333 85.333h-170.667c-23.565 0-42.667-19.102-42.667-42.667s19.102-42.667 42.667-42.667h170.667z", + "M170.667 853.333h170.667c23.564 0 42.667 19.102 42.667 42.667s-19.103 42.667-42.667 42.667h-170.667c-47.128 0-85.333-38.204-85.333-85.333v-170.667c0-23.565 19.103-42.667 42.667-42.667s42.667 19.102 42.667 42.667v170.667z", + "M128 469.333h768c23.564 0 42.667 19.103 42.667 42.667s-19.103 42.667-42.667 42.667h-768c-23.564 0-42.667-19.103-42.667-42.667s19.103-42.667 42.667-42.667z" ], - "attrs": [{}], + "attrs": [{}, {}, {}, {}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["batch"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["scan-1"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}, {}, {}] } }, - "attrs": [{}], - "properties": { "order": 54, "id": 30, "name": "batch", "prevSize": 32, "code": 59727 }, + "attrs": [{}, {}, {}, {}, {}], + "properties": { "order": 344, "id": 30, "name": "scan-1", "prevSize": 32, "code": 59744 }, "setIdx": 0, - "setId": 2, - "iconIdx": 73 + "setId": 0, + "iconIdx": 87 }, { "icon": { "paths": [ - "M181.33 239.36c86.187 110.507 245.333 315.307 245.333 315.307v256c0 23.467 19.199 42.667 42.665 42.667h85.333c23.467 0 42.667-19.2 42.667-42.667v-256c0 0 158.72-204.8 244.907-315.307 21.76-28.16 1.707-68.693-33.707-68.693h-593.492c-35.413 0-55.467 40.533-33.707 68.693z" + "M170.685 426.663c0-141.184 114.816-256 255.999-256 141.141 0 256 114.816 256 256s-114.859 256-256 256c-141.183 0-255.999-114.816-255.999-256zM926.097 865.83l-230.059-230.059c44.928-57.771 71.979-130.219 71.979-209.109 0-188.501-152.832-341.333-341.333-341.333-188.543 0-341.332 152.832-341.332 341.333s152.789 341.333 341.332 341.333c78.848 0 151.253-27.008 208.981-71.893l230.101 230.059c16.597 16.597 43.733 16.597 60.331 0s16.597-43.733 0-60.331z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["filter"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["search"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 53, "id": 29, "name": "filter", "prevSize": 32, "code": 59728 }, + "properties": { "order": 345, "id": 29, "name": "search", "prevSize": 32, "code": 59745 }, "setIdx": 0, - "setId": 2, - "iconIdx": 74 + "setId": 0, + "iconIdx": 88 }, { "icon": { "paths": [ - "M246.137 161.741c21.004-21.604 49.492-33.741 79.196-33.741h373.333c29.705 0 58.193 12.137 79.198 33.741 21.001 21.604 32.802 50.906 32.802 81.459v614.4c0 14.383-7.817 27.563-20.25 34.142-12.437 6.583-27.405 5.466-38.784-2.893l-239.633-176.060-239.634 176.060c-11.38 8.358-26.348 9.476-38.782 2.893-12.435-6.579-20.25-19.759-20.25-34.142v-614.4c0-30.553 11.8-59.855 32.804-81.459z" + "M1149.343 35.129c4.646-27.646 4.635-27.647 4.624-27.649l20.626 3.24 3.352 19.936c0-0.010 0-0.019-28.602 4.473z", + "M7.738 30.675l-0.016 0.098-0.037 0.224-0.123 0.765-0.421 2.762c-0.351 2.375-0.832 5.802-1.38 10.174-1.095 8.74-2.461 21.277-3.587 36.751-2.249 30.897-3.566 73.767 0.206 121.657 7.453 94.65 35.198 214.31 121.066 297.307 64.218 62.071 149.532 92.575 228.631 107.062 63.939 11.711 125.561 13.28 172.263 11.781v333.474c0 35.107 29.446 63.569 65.772 63.569s65.773-28.462 65.773-63.569v-333.619c46.215 1.757 108.258 0.631 172.986-10.747 80.454-14.142 167.877-44.625 233.359-107.919 85.876-83.005 113.621-202.678 121.080-297.339 3.767-47.895 2.452-90.77 0.205-121.671-1.126-15.476-2.495-28.014-3.589-36.756-0.55-4.372-1.029-7.8-1.38-10.175l-0.42-2.762-0.124-0.765-0.038-0.224-0.016-0.098-28.602 4.473 4.624-27.649-1.121-0.17-2.862-0.407c-2.458-0.339-6.004-0.804-10.526-1.334-9.044-1.059-22.016-2.379-38.028-3.467-31.97-2.174-76.326-3.447-125.877 0.199-97.938 7.205-221.75 34.025-307.626 117.031-32.903 31.798-57.215 68.938-75.118 107.828-17.898-38.887-42.21-76.024-75.107-107.82-85.868-82.997-209.667-109.813-307.592-117.017-49.546-3.645-93.9-2.372-125.865-0.199-16.009 1.089-28.98 2.408-38.023 3.467-4.523 0.53-8.069 0.995-10.526 1.334l-2.857 0.407-0.792 0.119-0.232 0.036-0.101 0.016 4.626 27.648c-4.644-27.622-4.636-27.647-4.626-27.648l-20.625 3.24-3.352 19.936c0.002-0.010 0.026-0.016 28.603 4.473l-28.603-4.473zM985.535 129.078c24.625-1.811 47.896-2.181 68.462-1.795 0.399 19.879 0.016 42.371-1.859 66.173-6.645 84.434-30.548 166.457-82.928 217.084s-137.238 73.731-224.596 80.158c-24.625 1.811-47.896 2.181-68.462 1.795-0.399-19.88-0.016-42.371 1.859-66.173 6.645-84.434 30.553-166.457 82.928-217.084 52.38-50.628 137.243-73.731 224.596-80.158zM133.543 193.457c-1.873-23.794-2.256-46.279-1.857-66.153 20.561-0.386 43.824-0.016 68.441 1.795 87.343 6.425 172.19 29.526 224.559 80.144s76.269 132.628 82.917 217.051c1.874 23.794 2.257 46.279 1.857 66.153-20.562 0.386-43.825 0.017-68.441-1.795-87.343-6.425-172.19-29.526-224.56-80.144s-76.269-132.628-82.917-217.051z" ], - "attrs": [{}], + "attrs": [{}, {}], + "width": 1186, "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["bookmark-filled"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["seed"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}], - "properties": { "order": 52, "id": 28, "name": "bookmark-filled", "prevSize": 32, "code": 59729 }, + "attrs": [{}, {}], + "properties": { "order": 346, "id": 28, "name": "seed", "prevSize": 32, "code": 59746 }, "setIdx": 0, - "setId": 2, - "iconIdx": 75 + "setId": 0, + "iconIdx": 89 }, { "icon": { "paths": [ - "M325.333 204.8c-9.901 0-19.397 4.046-26.399 11.247s-10.935 16.969-10.935 27.153v539.78l202.3-148.629c12.983-9.536 30.417-9.536 43.401 0l202.3 148.629v-539.78c0-10.184-3.934-19.951-10.935-27.153s-16.495-11.247-26.398-11.247h-373.333zM246.137 161.741c21.004-21.604 49.492-33.741 79.196-33.741h373.333c29.705 0 58.193 12.137 79.198 33.741 21.001 21.604 32.802 50.906 32.802 81.459v614.4c0 14.383-7.817 27.563-20.25 34.142-12.437 6.583-27.405 5.466-38.784-2.893l-239.633-176.060-239.634 176.060c-11.38 8.358-26.348 9.476-38.782 2.893-12.435-6.579-20.25-19.759-20.25-34.142v-614.4c0-30.553 11.8-59.855 32.804-81.459z" + "M979.546 884.309c-65.873-225.805-162.807-360.747-316.979-401.515 63.13-37.047 105.519-105.636 105.519-184.119 0-117.815-95.518-213.333-213.333-213.333-117.845 0-213.333 95.505-213.333 213.333 0 78.871 42.786 147.742 106.423 184.657-72.535 19.917-133.865 61.286-184.671 122.803-15.005 18.172-12.44 45.065 5.728 60.066 18.169 15.006 45.062 12.442 60.067-5.726 58.092-70.34 130.553-105.549 225.619-105.549 172.625 0 272.367 111.010 343.040 353.28 6.601 22.622 30.289 35.614 52.911 29.013 22.618-6.601 35.61-30.289 29.009-52.911zM682.752 298.675c0 70.686-57.314 128-128 128-70.711 0-128-57.297-128-128s57.289-128 128-128c70.686 0 128 57.313 128 128z", + "M494.178 853.551h-366.161c-23.564 0-42.667-19.102-42.667-42.667s19.102-42.667 42.667-42.667h366.229l-12.079-12.079c-16.661-16.661-16.661-43.678 0-60.339s43.678-16.661 60.339 0l84.864 84.864c16.661 16.657 16.661 43.669 0.009 60.331l-84.864 84.907c-16.657 16.67-43.674 16.674-60.339 0.017-16.67-16.657-16.674-43.674-0.017-60.339l12.019-12.028z" ], - "attrs": [{}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["bookmark"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["send-to"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}], - "properties": { "order": 51, "id": 27, "name": "bookmark", "prevSize": 32, "code": 59730 }, + "attrs": [{}, {}], + "properties": { "order": 347, "id": 27, "name": "send-to", "prevSize": 32, "code": 59747 }, "setIdx": 0, - "setId": 2, - "iconIdx": 76 + "setId": 0, + "iconIdx": 90 }, { "icon": { "paths": [ - "M512.009 42.671c-63.962-0.254-127.283 12.687-186.014 38.014s-111.606 62.497-155.32 109.186v-83.2c0.070-6.185-1.206-12.311-3.738-17.954s-6.262-10.668-10.929-14.726c-4.667-4.059-10.161-7.054-16.101-8.778s-12.183-2.137-18.298-1.209c-10.351 2.006-19.661 7.599-26.295 15.793s-10.164 18.466-9.972 29.007v189.867c0 11.316 4.495 22.168 12.497 30.17s18.854 12.497 30.17 12.497h192c6.185 0.070 12.311-1.206 17.954-3.738s10.668-6.262 14.726-10.929c4.059-4.667 7.054-10.161 8.779-16.101s2.137-12.183 1.208-18.298c-2.006-10.351-7.599-19.661-15.793-26.295s-18.466-10.164-29.007-9.972h-91.733c49.696-55.344 114.396-95.063 186.238-114.332 71.84-19.269 147.736-17.258 218.456 5.789s133.227 66.136 179.921 124.034c46.694 57.898 75.571 128.111 83.119 202.109 1.058 10.56 6.020 20.348 13.909 27.447s18.142 11.004 28.757 10.953c5.982 0.030 11.9-1.199 17.374-3.605 5.478-2.406 10.385-5.935 14.409-10.359 4.023-4.429 7.074-9.647 8.947-15.33 1.873-5.679 2.534-11.686 1.937-17.638-11.639-115.805-65.89-223.153-152.226-301.208-86.332-78.054-198.588-121.247-314.974-121.193z", - "M896.081 682.667h-192c-6.187-0.068-12.309 1.207-17.954 3.738-5.645 2.534-10.667 6.263-14.724 10.931-4.062 4.668-7.057 10.159-8.781 16.098-1.724 5.943-2.138 12.186-1.207 18.3 2.005 10.351 7.599 19.661 15.791 26.295 8.196 6.635 18.466 10.163 29.009 9.971h91.733c-49.698 55.343-114.398 95.061-186.24 114.334-71.842 19.268-147.733 17.254-218.455-5.79-70.721-23.049-133.227-66.138-179.922-124.036-46.695-57.894-75.571-128.111-83.117-202.108-1.061-10.56-6.021-20.348-13.91-27.447s-18.142-11.004-28.756-10.953c-5.981-0.030-11.901 1.199-17.377 3.605s-10.385 5.935-14.408 10.359c-4.023 4.429-7.071 9.647-8.946 15.33-1.875 5.679-2.534 11.686-1.936 17.638 8.961 89.579 43.494 174.686 99.483 245.18s131.070 123.401 216.292 152.405c85.223 29.009 176.986 32.892 264.355 11.191 87.373-21.696 166.656-68.066 228.403-133.577v83.2c-0.073 6.187 1.203 12.309 3.738 17.954s6.263 10.667 10.927 14.724c4.668 4.062 10.163 7.057 16.102 8.781s12.186 2.138 18.3 1.207c10.351-2.005 19.661-7.599 26.295-15.791 6.63-8.196 10.163-18.466 9.971-29.009v-189.867c0-11.315-4.497-22.17-12.497-30.17s-18.854-12.497-30.17-12.497z", - "M738.842 465.613h-180.275v-180.275c0-25.617-20.766-46.409-46.409-46.409-25.617 0-46.409 20.791-46.409 46.409v180.275h-180.227c-25.641 0-46.408 20.791-46.408 46.409s20.768 46.409 46.408 46.409h180.227v180.25c0 25.617 20.791 46.409 46.409 46.409 25.643 0 46.409-20.791 46.409-46.409v-180.25h180.275c25.617 0 46.404-20.791 46.404-46.409s-20.787-46.409-46.404-46.409z" + "M512.427 767.987c-141.184 0-256-114.816-256-256s114.816-256 256-256c141.184 0 256 114.816 256 256s-114.816 256-256 256zM902.443 460.318l-14.763-1.28c-29.568-3.157-40.704-20.949-44.629-30.592-8.192-32.555-21.035-63.147-37.803-91.264-4.053-9.387-9.259-30.123 9.771-53.632l9.472-11.349c20.736-23.637 12.459-38.443-4.096-55.083l-13.739-13.696c-16.597-16.597-31.445-24.832-55.083-4.139l-11.349 9.515c-24.96 20.139-46.763 13.141-55.211 9.045-26.411-15.531-55.083-27.52-85.419-35.541-7.040-1.963-31.104-11.221-34.816-46.165l-1.323-14.763c-2.091-31.36-18.347-36.053-41.856-36.053h-19.413c-23.467 0-39.808 4.693-41.856 36.053l-1.323 14.763c-4.352 40.917-36.821 46.72-36.821 46.72 0 0.085 0.043 0.171 0.043 0.256-28.075 7.68-54.656 18.987-79.317 33.152-0.085-0.128-0.128-0.341-0.256-0.469 0 0-27.051 18.859-59.051-6.955l-11.392-9.557c-23.637-20.693-38.485-12.459-55.083 4.139l-13.696 13.696c-16.597 16.64-24.832 31.446-4.181 55.083l9.557 11.349c25.856 32.043 6.955 59.093 6.955 59.093 0.213 0.171 0.469 0.256 0.683 0.384-14.165 24.704-25.387 51.243-33.024 79.317-0.213-0.042-0.427-0.128-0.597-0.171 0 0-5.76 32.47-46.677 36.864l-14.805 1.28c-31.317 2.091-36.011 18.432-36.011 41.899v19.413c0 23.424 4.693 39.765 36.011 41.813l14.805 1.323c40.917 4.352 46.677 36.864 46.677 36.864 0.171 0 0.341-0.085 0.512-0.128 7.68 28.075 18.688 54.741 32.768 79.445-0.085 0.085-0.256 0.085-0.341 0.171 0 0 18.901 27.093-6.955 59.093l-9.557 11.349c-20.651 23.637-12.459 38.528 4.181 55.083l13.696 13.739c16.597 16.597 31.445 24.832 55.083 4.096l11.392-9.472c32-25.899 59.051-6.997 59.051-6.997 0.043-0.043 0.043-0.085 0.085-0.128 24.747 14.208 51.371 25.259 79.445 32.981v0.128c0 0 32.469 5.803 36.821 46.72l1.323 14.72c2.048 31.36 18.389 36.011 41.856 36.011h19.413c23.509 0 39.765-4.651 41.856-36.011l1.323-14.72c3.413-31.915 23.723-42.411 32.597-45.483 31.019-7.936 60.203-20.267 87.125-36.011 8.021-4.011 30.165-11.861 55.723 8.789l11.349 9.515c23.637 20.693 38.485 12.459 55.083-4.139l13.739-13.739c16.555-16.555 24.832-31.445 4.096-55.083l-9.472-11.307c-20.437-25.344-13.013-47.317-8.917-55.509 16.299-27.691 29.013-57.685 37.077-89.643 4.011-9.685 15.104-27.179 44.501-30.336l14.763-1.323c31.317-2.048 35.968-18.389 35.968-41.813v-19.413c0-23.467-4.651-39.808-35.968-41.899z" ], - "attrs": [{}, {}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["transaction-recovery"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + "tags": ["settings"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}, {}], - "properties": { "order": 50, "id": 26, "name": "transaction-recovery", "prevSize": 32, "code": 59731 }, + "attrs": [{}], + "properties": { "order": 348, "id": 26, "name": "settings", "prevSize": 32, "code": 59748 }, "setIdx": 0, - "setId": 2, - "iconIdx": 77 + "setId": 0, + "iconIdx": 91 }, { "icon": { "paths": [ - "M512.081 691.2c-101.632 0-184.235-80.213-184.235-179.2s82.517-179.2 184.32-179.2c101.717 0 184.149 80.213 184.149 179.2s-82.432 179.2-184.235 179.2zM903.164 561.664c2.133-16.384 3.755-32.768 3.755-49.664s-1.621-33.792-3.755-51.2l111.104-83.456c4.843-3.771 8.149-9.172 9.31-15.198 1.165-6.026 0.098-12.269-2.995-17.57l-105.301-177.152c-3.136-5.387-8.085-9.481-13.965-11.545-5.884-2.065-12.305-1.965-18.121 0.281l-131.072 51.2c-27.277-20.606-57.178-37.48-88.917-50.176l-19.541-135.68c-1.161-6.099-4.429-11.595-9.237-15.526-4.804-3.931-10.837-6.047-17.045-5.978h-210.517c-6.223-0.090-12.277 2.017-17.1 5.95s-8.105 9.44-9.268 15.554l-19.456 135.68c-33.195 12.8-61.611 30.208-88.917 50.176l-131.072-51.2c-5.824-2.269-12.268-2.381-18.168-0.315s-10.866 6.173-14.003 11.579l-105.216 177.152c-3.225 5.265-4.365 11.546-3.197 17.609s4.561 11.47 9.512 15.159l111.019 83.456c-2.251 16.977-3.476 34.074-3.669 51.2 0 16.896 1.621 33.28 3.669 49.664l-111.019 84.992c-4.951 3.691-8.343 9.097-9.512 15.159s-0.028 12.343 3.197 17.609l105.216 177.152c6.315 11.264 20.565 15.36 32.171 11.264l131.072-51.712c27.307 20.48 55.723 37.888 88.917 50.688l19.456 135.68c1.164 6.114 4.445 11.622 9.268 15.552 4.823 3.934 10.877 6.042 17.1 5.952h210.517c6.208 0.068 12.241-2.048 17.045-5.978 4.809-3.93 8.077-9.429 9.237-15.526l19.541-135.68c31.757-12.873 61.662-29.918 88.917-50.688l131.072 51.712c11.605 4.096 25.771 0 32.085-11.264l105.301-177.152c3.093-5.299 4.16-11.541 2.995-17.57-1.161-6.025-4.467-11.426-9.31-15.198l-111.104-84.992z" + "M426.659 512.149c0-15.855-2.47-31.138-7.048-45.474l216.958-131.441c27.311 29.952 66.65 48.745 110.383 48.745 82.487 0 149.333-66.846 149.333-149.333 0-82.456-66.859-149.333-149.333-149.333s-149.333 66.877-149.333 149.333c0 7.933 0.619 15.72 1.809 23.318l-228.062 138.169c-25.672-20.833-58.396-33.316-94.041-33.316-82.487 0-149.333 66.845-149.333 149.333 0 82.458 66.859 149.333 149.333 149.333 35.651 0 68.385-12.497 94.061-33.344l228.054 138.163c-1.199 7.62-1.822 15.428-1.822 23.386 0 82.487 66.846 149.333 149.333 149.333s149.333-66.846 149.333-149.333c0-82.487-66.846-149.333-149.333-149.333-43.712 0-83.029 18.773-110.336 48.695l-216.998-131.465c4.572-14.327 7.040-29.594 7.040-45.436zM746.953 170.646c35.341 0 64 28.667 64 64 0 35.359-28.642 64-64 64s-64-28.641-64-64c0-35.333 28.659-64 64-64zM341.326 512.149c0-35.358-28.642-64-64-64s-64 28.642-64 64c0 35.332 28.659 64 64 64s64-28.668 64-64zM746.953 725.687c35.358 0 64 28.642 64 64s-28.642 64-64 64c-35.358 0-64-28.642-64-64s28.642-64 64-64z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["transaction-change-settings"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["share"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 49, "id": 25, "name": "transaction-change-settings", "prevSize": 32, "code": 59732 }, + "properties": { "order": 349, "id": 25, "name": "share", "prevSize": 32, "code": 59749 }, "setIdx": 0, - "setId": 2, - "iconIdx": 78 + "setId": 0, + "iconIdx": 92 }, { "icon": { "paths": [ - "M384.023 810.637c-10.913 0-21.825-4.186-30.138-12.463l-256.063-255.953c-7.971-8.026-12.49-18.863-12.49-30.212 0-11.307 4.518-22.187 12.49-30.161l255.849-256.002c16.667-16.683 43.651-16.683 60.275 0 16.666 16.683 16.666 43.648 0 60.331l-225.669 225.833 225.882 225.754c16.666 16.683 16.666 43.644 0.043 60.373-8.312 8.316-19.267 12.501-30.18 12.501z", - "M640.009 810.637c-10.923 0-21.845-4.186-30.165-12.501-16.683-16.683-16.683-43.652 0-60.335l225.792-225.792-225.792-225.833c-16.683-16.683-16.683-43.648 0-60.331s43.648-16.683 60.331 0l255.962 256.002c16.678 16.678 16.678 43.648 0 60.331l-255.962 255.957c-8.32 8.316-19.238 12.501-30.165 12.501z" + "M498.125 935.945c9.698 3.639 20.39 3.631 30.080-0.030 168.341-63.599 275.204-151.070 328.926-250.807 10.091-18.052 17.984-38.097 24.009-59.78 6.963-25.054 11.2-51.371 13.321-78.067 1.434-18.014 1.745-33.506 1.459-47.334-0.068-2.987-0.068-2.987-0.073-2.688v-272c0-24.709-20.924-44.251-45.577-42.568-21.329 1.457-32.043 1.992-43.895 1.992-111.714 0-203.985-39.909-267.998-90.209-16.469-12.945-39.906-12.014-55.3 2.196-0.917 0.847-2.022 1.75-3.682 3.001-1.284 0.965-2.854 2.105-7.014 5.123-17.792 11.878-28.126 18.392-43.648 26.725-3.44 1.846-6.886 3.627-10.342 5.341-60.663 29.792-126.433 47.823-200.646 47.823-11.919 0-22.554-0.531-43.941-1.992-24.651-1.684-45.574 17.859-45.574 42.568v269.952c-3.589 50.202 9.391 138.615 44.909 199.134 6.782 12.574 15.632 25.626 26.72 39.684 62.988 83.503 163.576 151.36 298.266 201.937zM782.519 643.695l-0.375 0.687c-41.933 78.042-127.915 149.965-269.060 205.888-112.852-44.476-195.072-101.325-245.663-168.384-8.823-11.204-15.002-20.314-19.968-29.483-14.854-25.357-24.553-57.66-29.862-93.828-3.653-24.883-4.827-50.394-4.13-60.429l0.086-228.169c1.385 0.012 2.78 0.018 4.198 0.018 88.57 0 166.714-21.424 238.414-56.636 4.442-2.202 8.7-4.404 12.932-6.676 15.774-8.467 26.91-15.258 42.91-25.824 75.383 51.486 175.313 89.135 294.374 89.135 1.395 0 2.773-0.006 4.139-0.017v227.26c0.038 2.308 0.038 2.308 0.090 4.467 0.235 11.183-0.021 23.915-1.207 38.793-1.715 21.615-5.107 42.675-10.475 61.986-4.331 15.59-9.805 29.461-16.401 41.212z" ], - "attrs": [{}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["transaction-contract"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["shield"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}], - "properties": { "order": 48, "id": 24, "name": "transaction-contract", "prevSize": 32, "code": 59733 }, + "attrs": [{}], + "properties": { "order": 350, "id": 24, "name": "shield", "prevSize": 32, "code": 59750 }, "setIdx": 0, - "setId": 2, - "iconIdx": 79 + "setId": 0, + "iconIdx": 93 }, { "icon": { "paths": [ - "M897.032 99.153c2.961 2.253 5.607 4.901 7.86 7.872 37.29 49.112 14.074 242.14-55.288 328.405l-6.431 7.545-18.506 18.678 1.896 10.543c19.878 119.267 0.057 230.666-45.294 329.597-19.915 43.442-40.096 74.924-54.338 92.721-14.201 17.744-39.637 20.316-56.975 6.685l-3.846-3.432-136.684-138.031-29.475 29.848c-44.397 44.745-124.609 7.549-199.403-65.319l-7.22-7.16c-74.377-75.092-114.932-157.11-75.762-204.194l4.041-4.448 29.413-29.729-136.609-137.993c-17.2-17.371-15.702-45.96 3.219-61.4 17.623-14.381 48.802-34.757 91.823-54.863 100.859-47.136 214.813-66.956 336.893-43.851l18.629-18.704 8.995-7.909c58.298-48.033 152.216-71.286 241.275-71.286 37.99 0 65.409 3.976 81.785 16.428zM587.346 701.26l101.065 102.048c4.329-7.569 8.462-15.393 12.513-23.618l6.005-12.636c32.424-70.738 49.562-148.595 44.503-231.436l-164.086 165.642zM815.247 165.433c-71.029 0-146.907 18.706-187.163 50.672l-6.373 5.449-336.347 339.499 1.029 3.826 2.098 6.078 1.384 3.404 2.501 5.517c11.216 23.376 32.218 51.823 58.882 78.742 26.676 26.931 54.846 48.144 77.987 59.474 3.793 1.86 7.299 3.371 10.437 4.542l4.42 1.503 3.793 1.008 335.704-338.928c26.477-29.211 44.896-81.041 52.543-141.297 2.63-20.728 3.785-41.26 3.523-59.102l-0.324-10.362-0.647-9.090-9.339-0.623-6.836-0.232-7.27-0.079zM219.438 311.785l-11.529 6.395 101.072 102.037 164.131-165.614c-82.064-5.115-159.182 12.186-229.244 44.929-8.541 3.992-16.69 8.092-24.431 12.252z" + "M12.497 12.497c16.662-16.662 43.677-16.662 60.34 0l158.504 158.504c0.45 0.431 0.891 0.872 1.324 1.324l549.109 549.109c0.141 0.137 0.282 0.277 0.422 0.418l229.308 229.312c16.661 16.661 16.661 43.678 0 60.339s-43.678 16.661-60.339 0l-198.519-198.515c-64.806 65.28-139.221 120.405-220.732 163.413-12.169 6.421-26.688 6.579-38.993 0.427l19.081-38.161c-19.081 38.161-19.068 38.17-19.081 38.161l-0.154-0.077-0.277-0.137-0.917-0.469c-0.768-0.393-1.856-0.956-3.238-1.681-2.761-1.451-6.707-3.558-11.665-6.302-9.907-5.487-23.898-13.534-40.614-23.983-33.342-20.838-77.978-51.503-122.816-90.735-87.883-76.898-185.237-194.398-185.237-341.444v-298.667c0-7.14 1.778-13.978 4.983-20.011l-120.486-120.486c-16.662-16.663-16.662-43.678 0-60.34zM213.333 273.673v238.327c0 108.954 73.312 204.787 156.096 277.222 40.496 35.435 81.195 63.437 111.851 82.598 12.058 7.531 22.49 13.658 30.656 18.287 66.202-37.291 126.899-83.558 180.369-137.463l-478.972-478.971zM497.075 45.362c9.643-3.601 20.267-3.594 29.905 0.021l341.333 128c16.653 6.245 27.686 22.165 27.686 39.95v298.948c-0.218 33.135-5.321 66.057-15.142 97.698-6.989 22.507-30.895 35.089-53.397 28.105-22.507-6.989-35.089-30.895-28.105-53.397 7.326-23.595 11.136-48.137 11.311-72.836v-268.949l-298.697-112.012-119.871 44.762c-22.075 8.243-46.653-2.97-54.897-25.045s2.97-46.653 25.045-54.897l134.828-50.347z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["transaction-execute"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["shield-crossed"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 47, "id": 23, "name": "transaction-execute", "prevSize": 32, "code": 59734 }, + "properties": { "order": 351, "id": 23, "name": "shield-crossed", "prevSize": 32, "code": 59751 }, "setIdx": 0, - "setId": 2, - "iconIdx": 80 + "setId": 0, + "iconIdx": 94 }, { "icon": { "paths": [ - "M980.258 154.546c3.703-20.891 3.699-20.892 3.691-20.893l16.439 2.448 2.675 15.065c-0.004-0.008-0.004-0.015-22.805 3.38z", - "M70.168 151.181l-0.013 0.074-0.029 0.169-0.098 0.578-0.336 2.087c-0.28 1.795-0.663 4.384-1.1 7.688-0.873 6.605-1.961 16.079-2.86 27.772-1.793 23.348-2.842 55.745 0.164 91.934 5.942 71.526 28.060 161.951 96.514 224.671 51.195 46.903 119.207 69.956 182.265 80.905 50.973 8.849 100.099 10.035 137.33 8.9v252.002c0 26.53 23.475 48.038 52.433 48.038s52.433-21.508 52.433-48.038v-252.109c36.843 1.323 86.306 0.474 137.907-8.124 64.137-10.688 133.828-33.724 186.031-81.553 68.463-62.724 90.581-153.159 96.525-224.693 3.008-36.193 1.958-68.593 0.162-91.944-0.896-11.695-1.984-21.17-2.859-27.776-0.435-3.304-0.819-5.894-1.101-7.689l-0.337-2.087-0.094-0.578-0.030-0.169-0.013-0.074-22.805 3.38 3.691-20.893-0.9-0.129-2.278-0.308c-1.958-0.256-4.783-0.608-8.388-1.008-7.211-0.8-17.553-1.798-30.319-2.621-25.485-1.643-60.847-2.604-100.352 0.15-78.071 5.445-176.777 25.712-245.239 88.438-26.227 24.029-45.611 52.096-59.883 81.484-14.268-29.386-33.651-57.45-59.874-81.478-68.455-62.719-167.148-82.984-245.214-88.428-39.498-2.755-74.857-1.793-100.34-0.15-12.763 0.823-23.103 1.82-30.312 2.62-3.606 0.4-6.432 0.751-8.391 1.008l-2.278 0.308-0.631 0.090-0.185 0.027-0.081 0.012 3.688 20.893c-3.702-20.873-3.696-20.892-3.688-20.893l-16.442 2.448-2.672 15.065c0.002-0.008 0.020-0.012 22.802 3.38l-22.802-3.38zM849.673 225.542c19.631-1.369 38.182-1.648 54.579-1.356 0.316 15.023 0.013 32.019-1.481 50.006-5.303 63.805-24.358 125.788-66.112 164.047-41.758 38.259-109.41 55.718-179.051 60.574-19.631 1.37-38.182 1.647-54.575 1.357-0.32-15.023-0.017-32.021 1.481-50.005 5.299-63.806 24.354-125.789 66.112-164.048 41.754-38.258 109.406-55.717 179.046-60.573zM170.461 274.192c-1.494-17.981-1.799-34.972-1.481-49.99 16.392-0.292 34.937-0.012 54.562 1.356 69.631 4.855 137.271 22.312 179.020 60.564 41.747 38.252 60.802 100.224 66.101 164.020 1.493 17.98 1.801 34.974 1.481 49.993-16.393 0.29-34.935 0.013-54.562-1.357-69.63-4.855-137.27-22.315-179.020-60.565s-60.802-100.224-66.102-164.020z" + "M528.73 869.734l0.299 57.259c0.043 6.443 5.248 11.648 11.691 11.648h56.875c3.115 0 6.101-1.237 8.32-3.413l171.861-171.904c6.741-6.741 6.741-17.707 0-24.448l-50.432-50.432c-6.144-6.144-16.128-6.144-22.272 0l-172.928 172.971c-2.219 2.176-3.456 5.205-3.413 8.32z", + "M829.188 711.898l63.104-63.061c4.736-4.736 4.736-12.459 0-17.195l-56.619-56.619c-4.736-4.736-12.459-4.736-17.195 0l-63.061 63.104c-4.779 4.736-4.779 12.459 0 17.195l56.619 56.576c4.736 4.736 12.416 4.736 17.152 0z", + "M554.603 342.191v-136.107l135.552 135.424-135.552 0.683zM810.688 469.295v-112.086c0-10.24-4.096-20.011-11.349-27.221l-231.765-231.552c-8.405-8.405-19.755-13.099-31.616-13.099h-322.603c-47.189 0-85.333 38.187-85.333 85.333v682.667c0 47.104 38.144 85.333 85.333 85.333h170.197c0.171 0 0.299 0.085 0.469 0.085 23.595 0 42.667-19.115 42.667-42.667s-19.072-42.667-42.667-42.667v-0.085h-170.667v-682.667h255.915v171.52c0 46.464 37.888 84.267 84.437 84.267h171.648v43.051h0.085c0.256 23.381 19.157 42.283 42.581 42.283 23.467 0 42.368-18.901 42.581-42.283h0.085v-0.213z" ], - "attrs": [{}, {}], + "attrs": [{}, {}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["transaction-stake"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["sign"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}] } }, - "attrs": [{}, {}], - "properties": { "order": 46, "id": 22, "name": "transaction-stake", "prevSize": 32, "code": 59735 }, + "attrs": [{}, {}, {}], + "properties": { "order": 352, "id": 22, "name": "sign", "prevSize": 32, "code": 59752 }, "setIdx": 0, - "setId": 2, - "iconIdx": 81 + "setId": 0, + "iconIdx": 95 }, { "icon": { "paths": [ - "M690.655 133.067c-12.157-11.836-31.605-11.576-43.438 0.58-11.837 12.156-11.579 31.605 0.578 43.441l73.273 71.343h-370.421c-41.68 0-81.813 16.114-111.528 45.047-29.744 28.96-46.609 68.416-46.609 109.735v62.032c0 16.966 13.754 30.72 30.72 30.72s30.72-13.754 30.72-30.72v-62.032c0-24.487 9.983-48.143 28.031-65.715 18.075-17.599 42.761-27.628 68.667-27.628h370.396l-73.249 71.321c-12.157 11.835-12.415 31.284-0.578 43.441 11.833 12.153 31.281 12.415 43.438 0.578l127.418-124.060c5.939-5.783 9.29-13.721 9.29-22.010s-3.351-16.227-9.29-22.010l-127.418-124.062zM372.658 567.865c11.836 12.157 11.576 31.605-0.58 43.442l-73.254 71.324h370.368c25.907 0 50.594-10.027 68.669-27.628 18.047-17.572 28.029-41.226 28.029-65.716v-62.030c0-16.966 13.754-30.72 30.72-30.72s30.72 13.754 30.72 30.72v62.030c0 41.32-16.867 80.777-46.608 109.736-29.716 28.934-69.849 45.048-111.53 45.048h-370.384l73.27 71.34c12.156 11.833 12.415 31.285 0.58 43.438-11.836 12.157-31.284 12.415-43.441 0.582l-127.418-124.064c-1.823-1.774-3.403-3.752-4.716-5.882-2.755-4.452-4.403-9.667-4.561-15.249-0.009-0.295-0.013-0.59-0.013-0.885 0-8.847 3.739-16.818 9.723-22.426l126.985-123.638c12.156-11.837 31.605-11.575 43.441 0.578z" + "M246.611 734.925c-24.747 30.080-48.64 59.738-73.387 88.96-9.813 11.738-21.12 22.189-32 33.069-12.8 12.8-26.453 13.869-36.907 3.411-10.24-10.24-8.96-24.531 3.627-36.902 40.107-39.040 75.093-81.92 105.6-128.858 4.267-6.611 4.053-11.302 0.427-17.92-39.467-73.384-78.507-146.771-109.014-224.424-19.2-49.066-36.48-98.986-39.893-152.106-1.28-21.547-0.427-44.374 4.48-65.28 9.387-40.534 34.56-68.694 76.587-76.374 44.16-8.106 81.706 6.614 107.733 43.52 42.027 59.947 66.987 126.934 75.947 199.68 12.373 98.774-5.974 191.574-53.12 279.042-1.707 3.2-1.067 8.742 0.426 12.371 3.627 7.891 8.32 15.149 12.8 22.611 14.72 24.538 41.814 28.378 62.294 7.898 24.32-24.538 49.066-49.069 71.68-75.309 44.586-51.839 115.626-37.119 145.28 17.069 13.226 24.102 38.826 28.582 59.733 10.662 11.948-10.24 23.468-20.691 35.416-30.931 47.782-41.386 115.84-24.106 138.022 34.989 7.68 20.262 18.778 28.16 40.96 28.16 30.298 0 60.589 0 91.098 0 19.2 0 30.72 17.28 23.251 33.491-4.051 8.531-11.309 13.651-20.48 13.651-35.411 0.218-70.829 1.498-106.029-0.851-36.48-2.349-60.371-23.68-72.742-58.029-7.258-19.84-21.978-29.862-41.389-26.451-7.040 1.28-14.509 5.12-20.269 9.811-12.16 9.389-23.462 19.84-34.771 30.298-46.934 43.091-109.441 23.68-135.894-23.469-6.4-11.309-14.506-20.051-27.733-22.4-14.72-2.56-26.667 2.349-36.693 13.229-23.894 25.6-48.64 50.342-72.32 76.16-43.52 47.36-113.28 35.84-141.014-15.789-0.64-1.069-1.493-2.131-1.92-2.771l0.214-0.218zM244.264 635.514c1.493-1.28 2.133-1.707 2.347-2.134 0.64-1.066 1.28-2.346 1.706-3.626 59.52-138.667 49.067-271.787-31.786-398.934-13.867-21.76-35.414-30.293-60.587-26.026-25.173 4.053-36.693 22.826-42.027 45.44-7.253 30.72-2.346 61.013 5.12 90.88 26.027 104.533 76.16 199.040 125.227 294.613v-0.213z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["transaction-swap"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["signature"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 45, "id": 21, "name": "transaction-swap", "prevSize": 32, "code": 59736 }, + "properties": { "order": 353, "id": 21, "name": "signature", "prevSize": 32, "code": 59753 }, "setIdx": 0, - "setId": 2, - "iconIdx": 82 + "setId": 0, + "iconIdx": 96 }, { "icon": { "paths": [ - "M777.183 648.356c-34.431 0.045-62.538-28.336-62.493-63.095v-204.367l-423.727 427.783c-24.309 24.543-64.087 24.543-88.396 0-24.353-24.584-24.309-64.7 0-89.24l423.727-427.787-202.472-0.044c-34.386 0-62.496-28.38-62.496-63.095s28.11-63.095 62.496-63.095l353.361 0.045c5.218-0.089 9.9 1.785 14.717 2.99 2.961 0.759 6.058 0.669 8.974 1.83 2.609 1.026 4.641 3.079 6.984 4.551 11.887 6.917 21.746 16.956 27.050 29.807 1.102 2.811 1.016 5.845 1.72 8.79 1.282 4.953 3.052 9.683 3.052 15.127v356.704c0 34.714-28.111 63.095-62.497 63.095z" + "M512.006 85.334c-15.744 0-31.488 8.32-39.253 25.003l-97.067 224.64c-6.144 13.056-18.389 22.229-32.682 24.448l-220.928 33.92c-34.731 5.333-48.982 47.701-24.491 72.96l162.859 150.443c9.514 9.813 13.824 23.553 11.648 37.032l-55.638 234.669c-4.522 27.949 17.707 50.349 42.752 50.349 6.955 0 14.123-1.754 20.992-5.549l210.731-107.309c6.571-3.622 13.824-5.459 21.077-5.459s14.507 1.837 21.035 5.459l206.979 107.309c6.912 3.795 14.080 5.549 20.992 5.549 25.088 0 47.27-22.4 42.752-50.349l-51.84-234.669c-2.221-13.478 2.086-27.219 11.648-37.032l162.816-150.443c24.486-25.259 10.278-67.627-24.448-72.96l-220.973-33.92c-14.291-2.219-26.541-11.392-32.64-24.448l-97.067-224.64c-7.808-16.683-23.51-25.003-39.254-25.003zM512.010 234.624l57.985 134.186c0.298 0.726 0.682 1.494 1.023 2.219 18.219 38.997 54.443 66.219 96.982 72.704l134.656 20.693-97.024 89.643c-1.107 1.067-2.221 2.133-3.283 3.243-28.205 29.013-41.178 70.229-34.688 110.208 0.256 1.581 0.595 3.155 0.934 4.691l31.917 144.429-127.361-66.003c-18.773-10.157-39.851-15.488-61.142-15.488-21.162 0-42.154 5.286-60.799 15.27l-130.048 66.221 33.962-143.149c0.47-2.003 0.896-4.006 1.238-6.016 6.442-39.977-6.528-81.15-34.731-110.163-1.024-1.11-2.176-2.176-3.285-3.243l-96.982-89.643 134.614-20.693c42.538-6.485 78.762-33.664 96.981-72.661 0.384-0.726 0.725-1.494 1.067-2.262l57.983-134.186z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["transaction-outgoing"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["star"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 44, "id": 20, "name": "transaction-outgoing", "prevSize": 32, "code": 59737 }, + "properties": { "order": 354, "id": 20, "name": "star", "prevSize": 32, "code": 59754 }, "setIdx": 0, - "setId": 2, - "iconIdx": 83 + "setId": 0, + "iconIdx": 97 }, { "icon": { "paths": [ - "M235.766 355.070c35.865-0.046 65.146 29.234 65.1 65.097v210.852l441.38-441.361c25.323-25.321 66.756-25.321 92.079 0 25.365 25.367 25.323 66.754 0 92.075l-441.38 441.361 210.908 0.047c35.819 0 65.097 29.278 65.097 65.097s-29.278 65.097-65.097 65.097l-368.086-0.047c-5.433 0.094-10.313-1.839-15.331-3.085-3.084-0.781-6.307-0.691-9.346-1.886-2.717-1.058-4.834-3.179-7.274-4.698-12.385-7.134-22.652-17.493-28.176-30.75-1.151-2.901-1.059-6.033-1.796-9.071-1.335-5.111-3.177-9.988-3.177-15.607v-368.023c0-35.817 29.281-65.097 65.1-65.097z" + "M128 170.667c0-23.564 19.103-42.667 42.667-42.667h378.338c11.319 0 22.17 4.497 30.174 12.501l325.082 325.167c22.037 22.174 34.406 52.173 34.406 83.435 0 31.266-12.369 61.261-34.406 83.435l-271.343 271.415c-10.987 11.004-24.038 19.733-38.404 25.69s-29.769 9.024-45.321 9.024c-15.552 0-30.95-3.068-45.316-9.024s-27.413-14.686-38.404-25.685c0-0.004 0.004 0 0 0l-324.962-324.672c-8.010-8-12.51-18.859-12.51-30.182v-378.436zM213.333 213.333v318.084l312.516 312.235c3.063 3.072 6.703 5.504 10.709 7.164 4.006 1.664 8.299 2.517 12.634 2.517s8.631-0.853 12.634-2.517c4.006-1.66 7.646-4.092 10.714-7.164l271.194-271.266c0.013-0.013 0.021-0.021 0.034-0.034 6.127-6.182 9.566-14.537 9.566-23.249 0-8.708-3.439-17.067-9.566-23.249-0.013-0.009-0.021-0.021-0.034-0.034l-312.405-312.486h-317.995zM298.667 341.333c0-23.564 19.103-42.667 42.667-42.667h0.427c23.564 0 42.667 19.103 42.667 42.667s-19.103 42.667-42.667 42.667h-0.427c-23.564 0-42.667-19.103-42.667-42.667z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["transaction-incoming"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["tag"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 43, "id": 19, "name": "transaction-incoming", "prevSize": 32, "code": 59738 }, + "properties": { "order": 355, "id": 19, "name": "tag", "prevSize": 32, "code": 59755 }, "setIdx": 0, - "setId": 2, - "iconIdx": 84 + "setId": 0, + "iconIdx": 98 }, { "icon": { "paths": [ - "M682.667 85.329h-341.333c-70.357 0-128 57.6-128 128v597.333c0 70.4 57.643 128 128 128h341.333c70.4 0 128-57.6 128-128v-597.333c0-70.4-57.6-128-128-128zM682.667 170.663c23.125 0 42.667 19.541 42.667 42.667v597.333c0 23.125-19.541 42.667-42.667 42.667h-341.333c-23.125 0-42.667-19.541-42.667-42.667v-597.333c0-23.125 19.541-42.667 42.667-42.667h341.333z", - "M512 714.953c-29.44 0-53.333 23.893-53.333 53.333s23.893 53.333 53.333 53.333c29.44 0 53.333-23.893 53.333-53.333s-23.893-53.333-53.333-53.333z" + "M639.915 213.312c-164.907 0-298.665 133.76-298.665 298.666 0 164.949 133.758 298.667 298.665 298.667 164.949 0 298.667-133.717 298.667-298.667 0-164.906-133.717-298.666-298.667-298.666zM639.915 298.646c117.675 0 213.333 95.701 213.333 213.333 0 117.675-95.659 213.333-213.333 213.333-117.632 0-213.331-95.659-213.331-213.333 0-117.632 95.699-213.333 213.331-213.333z", + "M290.324 228.618c-121.393 39.957-204.986 153.478-204.986 283.216s83.593 243.26 204.986 283.217c22.383 7.364 46.5-4.809 53.868-27.187 7.367-22.383-4.805-46.502-27.188-53.871-86.66-28.523-146.332-109.559-146.332-202.159s59.672-173.636 146.332-202.16c22.383-7.367 34.555-31.485 27.188-53.868s-31.485-34.555-53.868-27.188z" ], "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["mobile"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["token"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, "attrs": [{}, {}], - "properties": { "order": 42, "id": 18, "name": "mobile", "prevSize": 32, "code": 59739 }, + "properties": { "order": 356, "id": 18, "name": "token", "prevSize": 32, "code": 59756 }, "setIdx": 0, - "setId": 2, - "iconIdx": 85 + "setId": 0, + "iconIdx": 99 }, { "icon": { "paths": [ - "M896 640v85.333c0 47.13-38.204 85.333-85.333 85.333h-640c-47.128 0-85.333-38.204-85.333-85.333v-426.667c0-47.128 38.205-85.333 85.333-85.333h640c47.13 0 85.333 38.205 85.333 85.333v85.333c23.565 0 42.667 19.103 42.667 42.667v170.667c0 23.565-19.102 42.667-42.667 42.667zM810.667 384v-85.333h-640v426.667h640v-85.333h-128c-23.565 0-42.667-19.102-42.667-42.667v-170.667c0-23.564 19.102-42.667 42.667-42.667h128zM725.333 554.667h128v-85.333h-128v85.333z" + "M512 1024c282.768 0 512-229.232 512-512 0-282.77-229.232-512-512-512-282.77 0-512 229.23-512 512 0 282.768 229.23 512 512 512z", + "M501.592 258.457c6.554-3.276 14.262-3.276 20.815 0l232.723 116.364c7.885 3.942 12.865 12 12.865 20.816s-4.98 16.874-12.865 20.816l-232.723 116.363c-6.554 3.277-14.262 3.277-20.815 0l-232.723-116.363c-7.884-3.942-12.865-12-12.865-20.816s4.98-16.874 12.865-20.816l232.723-116.364zM331.316 395.636l180.684 90.345 180.685-90.345-180.685-90.344-180.684 90.344zM258.462 501.592c5.748-11.497 19.727-16.156 31.223-10.408l222.315 111.16 222.315-111.16c11.497-5.748 25.474-1.089 31.223 10.408s1.089 25.474-10.408 31.223l-232.723 116.364c-6.554 3.277-14.262 3.277-20.815 0l-232.723-116.364c-11.496-5.748-16.156-19.726-10.408-31.223zM258.462 617.956c5.748-11.497 19.727-16.156 31.223-10.408l222.315 111.16 222.315-111.16c11.497-5.748 25.474-1.089 31.223 10.408s1.089 25.474-10.408 31.223l-232.723 116.364c-6.554 3.277-14.262 3.277-20.815 0l-232.723-116.364c-11.496-5.748-16.156-19.726-10.408-31.223z" ], - "attrs": [{}], - "isMulticolor": false, + "attrs": [{ "fill": "rgb(255, 214, 0)" }, { "fill": "rgb(0, 0, 0)" }], + "isMulticolor": true, "isMulticolor2": false, "grid": 0, - "tags": ["wallet"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["transaction-batch"], + "colorPermutations": { "11611631671255214012559511412828281": [{ "f": 2 }, { "f": 0 }] } + }, + "attrs": [{ "fill": "rgb(255, 214, 0)" }, { "fill": "rgb(0, 0, 0)" }], + "properties": { + "order": 357, + "id": 17, + "name": "transaction-batch", + "prevSize": 32, + "code": 59757, + "codes": [59757, 59758] }, - "attrs": [{}], - "properties": { "order": 41, "id": 17, "name": "wallet", "prevSize": 32, "code": 59740 }, "setIdx": 0, - "setId": 2, - "iconIdx": 86 + "setId": 0, + "iconIdx": 100 }, { "icon": { "paths": [ - "M895.996 545.707c-6.711 72.627-33.967 141.841-78.583 199.543-44.612 57.702-104.734 101.504-173.333 126.281-68.599 24.781-142.835 29.508-214.025 13.636s-136.384-51.695-187.958-103.266c-51.574-51.575-87.394-116.77-103.267-187.959s-11.144-145.425 13.634-214.025c24.779-68.599 68.581-128.722 126.283-173.335s126.916-71.869 199.543-78.581c-42.522 57.526-62.982 128.403-57.663 199.74s36.069 138.397 86.65 188.979c50.581 50.581 117.641 81.331 188.979 86.652s142.212-15.142 199.74-57.664v0z" - ], - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } + "M512.085 691.2c-101.631 0-184.234-80.213-184.234-179.2s82.517-179.2 184.319-179.2c101.717 0 184.149 80.213 184.149 179.2s-82.432 179.2-184.235 179.2zM903.168 561.664c2.133-16.384 3.755-32.768 3.755-49.664s-1.621-33.792-3.755-51.2l111.104-83.456c4.843-3.771 8.149-9.172 9.314-15.198 1.161-6.026 0.094-12.269-2.999-17.57l-105.301-177.152c-3.132-5.387-8.085-9.481-13.965-11.545-5.884-2.065-12.305-1.965-18.121 0.281l-131.072 51.2c-27.273-20.606-57.178-37.48-88.917-50.176l-19.541-135.68c-1.161-6.099-4.429-11.595-9.233-15.526s-10.842-6.047-17.050-5.978h-210.516c-6.223-0.090-12.277 2.017-17.1 5.95s-8.105 9.44-9.268 15.554l-19.456 135.68c-33.195 12.8-61.611 30.208-88.917 50.176l-131.072-51.2c-5.824-2.269-12.268-2.381-18.168-0.315s-10.866 6.173-14.003 11.579l-105.216 177.152c-3.225 5.265-4.365 11.546-3.197 17.609s4.561 11.47 9.512 15.159l111.019 83.456c-2.251 16.977-3.476 34.074-3.669 51.2 0 16.896 1.621 33.28 3.669 49.664l-111.019 84.992c-4.951 3.691-8.343 9.097-9.512 15.159s-0.028 12.343 3.197 17.609l105.216 177.152c6.315 11.264 20.565 15.36 32.171 11.264l131.072-51.712c27.307 20.48 55.723 37.888 88.917 50.688l19.456 135.68c1.164 6.114 4.445 11.622 9.268 15.552 4.823 3.934 10.877 6.042 17.1 5.952h210.516c6.208 0.068 12.245-2.048 17.050-5.978s8.073-9.429 9.233-15.526l19.541-135.68c31.761-12.873 61.662-29.918 88.917-50.688l131.072 51.712c11.605 4.096 25.771 0 32.085-11.264l105.301-177.152c3.093-5.299 4.16-11.541 2.999-17.57-1.165-6.025-4.471-11.426-9.314-15.198l-111.104-84.992z" ], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["appearance"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + "tags": ["transaction-change-settings"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } - ], - "properties": { "order": 142, "id": 16, "name": "appearance", "prevSize": 32, "code": 59741 }, + "attrs": [{}], + "properties": { "order": 358, "id": 16, "name": "transaction-change-settings", "prevSize": 32, "code": 59759 }, "setIdx": 0, - "setId": 2, - "iconIdx": 87 + "setId": 0, + "iconIdx": 101 }, { "icon": { "paths": [ - "M704 401.066l-384-221.44", - "M896 682.667v-341.334c-0.017-14.964-3.964-29.661-11.456-42.617-7.488-12.956-18.253-23.714-31.211-31.197l-298.667-170.667c-12.971-7.489-27.686-11.433-42.667-11.433s-29.696 3.943-42.667 11.433l-298.667 170.667c-12.96 7.482-23.724 18.241-31.212 31.197s-11.439 27.653-11.454 42.617v341.334c0.015 14.963 3.965 29.662 11.454 42.615 7.489 12.958 18.253 23.714 31.212 31.198l298.667 170.667c12.971 7.488 27.686 11.43 42.667 11.43s29.696-3.942 42.667-11.43l298.667-170.667c12.958-7.484 23.723-18.24 31.211-31.198 7.492-12.954 11.439-27.652 11.456-42.615z", - "M139.521 296.96l372.479 215.467 372.48-215.467", - "M512 942.080v-430.080" - ], - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } + "M384.023 810.637c-10.913 0-21.825-4.186-30.138-12.463l-256.063-255.953c-7.971-8.026-12.49-18.863-12.49-30.212 0-11.307 4.518-22.187 12.49-30.161l255.849-256.002c16.667-16.683 43.651-16.683 60.275 0 16.666 16.683 16.666 43.648 0 60.331l-225.669 225.833 225.882 225.754c16.666 16.683 16.666 43.644 0.043 60.373-8.312 8.316-19.267 12.501-30.18 12.501z", + "M640.009 810.637c-10.923 0-21.845-4.186-30.165-12.501-16.683-16.683-16.683-43.652 0-60.335l225.792-225.792-225.792-225.833c-16.683-16.683-16.683-43.648 0-60.331s43.648-16.683 60.331 0l255.962 256.002c16.678 16.678 16.678 43.648 0 60.331l-255.962 255.957c-8.32 8.316-19.238 12.501-30.165 12.501z" ], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["experimental"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }, { "s": 0 }, { "s": 0 }, { "s": 0 }] } + "tags": ["transaction-contract"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } - ], - "properties": { "order": 143, "id": 15, "name": "experimental", "prevSize": 32, "code": 59742 }, + "attrs": [{}, {}], + "properties": { "order": 359, "id": 15, "name": "transaction-contract", "prevSize": 32, "code": 59760 }, "setIdx": 0, - "setId": 2, - "iconIdx": 88 + "setId": 0, + "iconIdx": 102 }, { "icon": { "paths": [ - "M170.675 83.198c-71.871 0-130.134 58.263-130.134 130.133v426.669c0 71.868 58.263 130.129 130.134 130.129h296.533v81.071h-125.867c-24.742 0-44.8 20.058-44.8 44.8 0 24.738 20.058 44.796 44.8 44.796h341.333c24.742 0 44.8-20.058 44.8-44.796 0-24.742-20.058-44.8-44.8-44.8h-125.867v-81.071h296.533c71.872 0 130.133-58.261 130.133-130.129v-426.669c0-71.871-58.261-130.133-130.133-130.133h-682.667zM130.142 213.331c0-22.386 18.147-40.533 40.533-40.533h682.667c22.387 0 40.533 18.147 40.533 40.533v426.669c0 22.383-18.146 40.533-40.533 40.533h-682.667c-22.386 0-40.533-18.15-40.533-40.533v-426.669z" + "M541.212 54.359c240.375 14.001 430.564 211.967 430.564 456.688v11.187h-0.442c-6.035 245.036-209.306 445.74-454.684 445.753-243.412-0.013-447.798-191.936-462.5-425.19l-0.812-22.687c-2.69-269.914 212.311-468.533 464.437-466.5l23.437 0.75zM783.898 246.797c-143.603-146.78-380.79-150.589-530.748-10.375l-14.25 14.063c-141.5 146.739-122.166 357.857-38 470.188 47.114-64.858 94.135-129.594 141.125-194.188l8.125-10.5c2.674-3.169 5.381-5.966 8.063-8.375 5.455-4.897 11.597-8.618 18.875-9.812 7.292-1.189 14.301 0.376 21 3.313 6.57 2.884 13.519 7.387 21.062 12.875l93.313 68.188c49.069-64.449 98.020-128.683 148.875-195.437-27.175 5.656-52.363 10.95-77.563 15.875l0.063 0.063c-11.646 2.306-22.923 0.737-32.125-4.876-9.241-5.649-15.516-14.825-18.063-25.812l-0.063-0.062c-4.715-21.014 7.438-41.954 30.438-46.688l88.813-18.312c29.613-6.008 59.309-11.855 88.998-17.5 12-2.228 23.693-1.832 32.877 5.375 8.832 6.945 12.595 18.086 14.56 29.875l12.941 78c4.32 25.966 8.678 51.931 12.998 77.937l0.998 9.562c0.416 9.343-1.062 18.037-5.184 25.375-5.798 10.291-15.75 16.126-27.629 17.75-11.834 1.549-22.944-1.732-31.501-9.313-8.365-7.428-13.594-18.266-15.686-30.687-3.238-19.543-6.458-39.155-9.875-59.75-54.368 70.908-108.722 141.83-162.936 212.872-6.36 8.301-12.18 15.334-17.812 20.442-5.713 5.171-12.12 9.235-19.875 10.31-7.74 1.062-15.076-1.082-22.063-4.499-3.444-1.69-7.109-3.814-10.938-6.31l-12.25-8.563c-29.16-21.452-58.467-42.769-88.375-64.564-46.73 63.739-92.966 126.958-139.188 190.062 133.372 126.49 361.641 149.619 521.878-1.312l13.747-13.626c132.685-139.053 136.704-361.91 8.627-507.249l-13.254-14.312zM251.15 797.050c1.729 1.568 3.555 3.021 5.313 4.557-1.891-1.651-3.83-3.245-5.688-4.934l0.375 0.378zM181.462 712.237c3.613 5.53 7.3 10.918 11.188 16.058l8.687 10.938-8.375-10.56c-3.992-5.261-7.796-10.771-11.5-16.435zM556.588 340.047c2.835-1.724 6.084-3.063 9.75-3.812l-5.875 1.812c-1.375 0.573-2.658 1.26-3.875 2z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["desktop"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["transaction-earn"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 38, "id": 14, "name": "desktop", "prevSize": 32, "code": 59743 }, + "properties": { "order": 360, "id": 14, "name": "transaction-earn", "prevSize": 32, "code": 59761 }, "setIdx": 0, - "setId": 2, - "iconIdx": 89 + "setId": 0, + "iconIdx": 103 }, { "icon": { "paths": [ - "M853.342 170.658c44.966 0 81.843 34.833 85.103 78.968l0.23 6.365v512c0 44.962-34.833 81.839-78.967 85.099l-6.494 0.192c0.004 22.118-16.439 40.128-37.679 42.59l-4.983 0.286c-21.867 0-39.91-16.444-42.377-37.683l-0.294-5.193h-511.956c0.004 22.118-16.477 40.128-37.689 42.59l-4.974 0.286c-23.595 0-42.667-19.072-42.667-42.667-44.93-0.205-81.763-35.029-85.018-79.138l-0.234-6.362v-512c0-44.963 34.833-81.839 78.968-85.099l6.365-0.234h682.665zM853.342 255.991h-682.665v512h682.665v-512z", - "M639.885 341.367c-94.251 0-170.667 76.416-170.667 170.667s76.416 170.667 170.667 170.667c94.251 0 170.667-76.416 170.667-170.667s-76.416-170.667-170.667-170.667zM639.885 426.701c47.061 0 85.333 38.229 85.333 85.333 0 47.061-38.272 85.333-85.333 85.333s-85.333-38.272-85.333-85.333c0-47.104 38.272-85.333 85.333-85.333z", - "M298.594 341.325c21.881 0 39.915 16.471 42.38 37.691l0.287 4.976v256c0 23.565-19.102 42.667-42.667 42.667-21.881 0-39.915-16.469-42.38-37.692l-0.287-4.975v-256c0-23.564 19.103-42.667 42.667-42.667z" + "M876.556 99.153c-16.376-12.452-43.794-16.428-81.785-16.428-89.059 0-182.977 23.253-241.275 71.286l-8.995 7.909-18.629 18.704c-122.078-23.104-236.033-3.285-336.892 43.851-43.021 20.106-74.2 40.482-91.823 54.863-18.921 15.44-20.42 44.030-3.219 61.4l136.609 137.993-29.413 29.729-4.041 4.448c-39.17 47.084 1.384 129.102 75.762 204.194l7.22 7.16c74.795 72.868 155.006 110.064 199.406 65.319l29.471-29.848 136.684 138.031 3.85 3.432c17.334 13.631 42.77 11.059 56.971-6.685 14.246-17.797 34.427-49.279 54.338-92.721 45.351-98.931 65.171-210.33 45.294-329.597l-1.896-10.543 18.506-18.678 6.435-7.545c69.358-86.265 92.578-279.293 55.288-328.405-2.257-2.971-4.903-5.619-7.864-7.872zM566.87 701.26l164.086-165.642c5.059 82.842-12.075 160.698-44.503 231.436l-6.005 12.636c-4.051 8.225-8.184 16.048-12.513 23.618l-101.065-102.048zM794.771 165.433c-71.029 0-146.907 18.706-187.163 50.672l-6.373 5.449-336.346 339.499 1.029 3.826 2.098 6.078 1.384 3.404 2.501 5.517c11.216 23.376 32.218 51.823 58.882 78.742 26.676 26.931 54.846 48.144 77.987 59.474 3.792 1.86 7.298 3.371 10.436 4.542l4.42 1.503 3.793 1.008 335.704-338.928c26.477-29.211 44.896-81.041 52.543-141.297 2.634-20.728 3.785-41.26 3.527-59.102l-0.328-10.362-0.647-9.090-9.339-0.623-6.832-0.232-7.274-0.079zM198.963 311.785c7.741-4.16 15.89-8.26 24.431-12.252 70.062-32.743 147.18-50.044 229.247-44.929l-164.135 165.614-101.072-102.037 11.529-6.395z" ], - "attrs": [{}, {}, {}], + "attrs": [{}], + "width": 983, "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["safe"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + "tags": ["transaction-execute"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}, {}], - "properties": { "order": 24, "id": 13, "name": "safe", "prevSize": 32, "code": 59744 }, + "attrs": [{}], + "properties": { "order": 361, "id": 13, "name": "transaction-execute", "prevSize": 32, "code": 59762 }, "setIdx": 0, - "setId": 2, - "iconIdx": 90 + "setId": 0, + "iconIdx": 104 }, { "icon": { "paths": [ - "M725.333 369.778c0-56.579-22.477-110.842-62.485-150.849-40.004-40.008-94.268-62.484-150.848-62.484s-110.842 22.476-150.849 62.484c-40.008 40.008-62.484 94.27-62.484 150.849 0 248.889-106.667 320.001-106.667 320.001h640c0 0-106.667-71.113-106.667-320.001z", - "M573.513 832c-6.251 10.778-15.223 19.721-26.018 25.937-10.795 6.221-23.036 9.493-35.494 9.493s-24.695-3.273-35.49-9.493c-10.799-6.217-19.767-15.159-26.022-25.937" - ], - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } + "M235.766 355.071c35.865-0.046 65.146 29.234 65.1 65.097v210.851l441.38-441.36c25.323-25.321 66.756-25.321 92.079 0 25.365 25.367 25.323 66.754 0 92.075l-441.38 441.36 210.908 0.047c35.819 0 65.097 29.282 65.097 65.097 0 35.819-29.278 65.097-65.097 65.097l-368.086-0.043c-5.433 0.090-10.313-1.843-15.331-3.085-3.084-0.785-6.307-0.691-9.346-1.89-2.717-1.058-4.834-3.174-7.274-4.693-12.385-7.138-22.652-17.498-28.176-30.754-1.151-2.901-1.059-6.033-1.796-9.071-1.335-5.107-3.177-9.988-3.177-15.607v-368.022c0-35.817 29.281-65.097 65.1-65.097z" ], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["bell"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }, { "s": 0 }] } + "tags": ["transaction-incoming"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - }, - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } - ], - "properties": { "order": 144, "id": 12, "name": "bell", "prevSize": 32, "code": 59745 }, + "attrs": [{}], + "properties": { "order": 374, "id": 12, "name": "transaction-incoming", "prevSize": 32, "code": 59763 }, "setIdx": 0, - "setId": 2, - "iconIdx": 91 + "setId": 0, + "iconIdx": 105 }, { "icon": { "paths": [ - "M512.034 85.333c-148.545 0-269.399 120.837-269.399 269.383 0 50.643 14.25 100.063 41.186 142.888 9.838 15.676 21.71 38.157 25.35 50.769l37.98 131.631c3.839 13.239 11.514 25.114 21.421 34.714-2.881 5.188-4.683 11.068-4.683 17.425v127.108c0 19.887 16.141 36.032 36.032 36.032h18.268c-1.172 3.243-1.944 6.635-1.944 10.202 0 18.321 16.163 33.182 36.033 33.182h119.467c19.908 0 36.032-14.861 36.032-33.182 0-3.584-0.777-6.976-1.894-10.202h18.197c19.908 0 36.032-16.145 36.032-36.032v-127.108c0-5.709-1.459-11.063-3.819-15.851 10.829-10.018 19.187-22.613 23.113-36.898l35.959-131.324c3.494-12.736 15.117-34.987 24.777-50.355 26.97-42.825 41.22-92.259 41.22-142.976 0-148.552-120.819-269.406-269.329-269.406zM679.138 459.319c-4.198 6.686-25.532 41.438-33.28 69.67l-35.959 131.319c-0.666 2.432-5.619 6.217-8.141 6.234h-176.688c-2.683 0-7.947-3.998-8.685-6.537l-37.98-131.631c-7.963-27.58-29.332-62.37-33.549-69.111-19.729-31.347-30.159-67.505-30.159-104.547 0-108.804 88.514-197.318 197.332-197.318 108.77 0 197.265 88.514 197.265 197.318 0 37.096-10.415 73.273-30.157 104.603z" + "M756.703 648.356c-34.431 0.045-62.538-28.336-62.493-63.095v-204.366l-423.727 427.787c-24.309 24.539-64.087 24.539-88.396 0-24.353-24.588-24.309-64.705 0-89.244l423.727-427.786-202.472-0.045c-34.386 0-62.496-28.379-62.496-63.095s28.11-63.094 62.496-63.094l353.361 0.045c5.218-0.089 9.9 1.785 14.717 2.99 2.961 0.759 6.058 0.669 8.974 1.829 2.609 1.026 4.641 3.079 6.984 4.551 11.887 6.917 21.746 16.956 27.050 29.807 1.102 2.811 1.016 5.845 1.72 8.79 1.282 4.953 3.052 9.683 3.052 15.127v356.704c0 34.714-28.111 63.095-62.497 63.095z" ], "attrs": [{}], + "width": 983, "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["lightbulb"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["transaction-outgoing"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 26, "id": 11, "name": "lightbulb", "prevSize": 32, "code": 59746 }, + "properties": { "order": 373, "id": 11, "name": "transaction-outgoing", "prevSize": 32, "code": 59764 }, "setIdx": 0, - "setId": 2, - "iconIdx": 92 + "setId": 0, + "iconIdx": 106 }, { "icon": { "paths": [ - "M327.638 166.603c50.311 0 86.695 34.066 113.199 80.535 8.055 14.121 14.566 28.392 19.712 41.37h-132.911c-16.764 0-32.842-6.421-44.696-17.852s-18.513-26.935-18.513-43.1c0-16.166 6.659-31.669 18.513-43.1s27.932-17.853 44.696-17.853zM512 203.349c-32.030-54.196-89.885-118.016-184.362-118.016-39.117 0-76.631 14.984-104.291 41.656s-43.199 62.847-43.199 100.566c0 35.073 13.435 68.811 37.557 94.815h-93.584c-21.422 0-38.788 15.16-38.788 33.862v169.314c0 18.701 17.366 33.86 38.788 33.86h8.62v343.706c0 19.635 18.867 35.554 42.14 35.554h674.237c23.275 0 42.142-15.919 42.142-35.554v-343.706h8.619c21.423 0 38.788-15.159 38.788-33.86v-169.314c0-18.702-17.365-33.862-38.788-33.862h-93.585c24.124-26.004 37.559-59.742 37.559-94.815 0-37.719-15.539-73.894-43.2-100.566s-65.173-41.656-104.29-41.656c-94.477 0-152.333 63.82-184.363 118.016zM559.407 867.554v-308.147h247.573v308.147h-247.573zM464.593 559.407v308.147h-247.572v-308.147h247.572zM559.407 491.682v-101.587h301.683v101.587h-301.683zM464.593 390.095v101.587h-301.684v-101.587h301.684zM696.363 288.508h-132.911c5.146-12.978 11.657-27.249 19.712-41.37 26.505-46.469 62.886-80.535 113.199-80.535 16.764 0 32.841 6.422 44.698 17.853 11.853 11.431 18.513 26.934 18.513 43.1s-6.66 31.669-18.513 43.1c-11.857 11.431-27.934 17.852-44.698 17.852z" + "M832.499 512.32c0-176.555-143.539-320.181-320-320.32v-128c239.91 0.135 436.343 189.779 447.501 427.012v-1.557c0.384 7.573 0.576 15.198 0.576 22.865s-0.192 15.287-0.576 22.865v-1.557c-10.803 229.734-195.358 414.784-424.887 426.372h0.009c-0.9 0.047-1.805 0.090-2.709 0.128-6.682 0.299-13.402 0.448-20.156 0.448zM512.499 960v-127.424c176.461-0.141 320-143.765 320-320.256zM512.499 960h-0.499v-896c-239.909 0.135-436.344 189.779-447.5 427.012v-1.557c-0.382 7.573-0.576 15.198-0.576 22.865s0.194 15.287 0.576 22.865v-1.557c10.804 229.734 195.36 414.784 424.891 426.372h-0.013c0.055 0.004 0.111 0.004 0.166 0.009 7.518 0.375 15.087 0.567 22.699 0.567z" ], "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["what-is-new"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["transaction-partial-fill"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, "attrs": [{}], - "properties": { "order": 27, "id": 10, "name": "what-is-new", "prevSize": 32, "code": 59747 }, + "properties": { "order": 372, "id": 10, "name": "transaction-partial-fill", "prevSize": 32, "code": 59765 }, "setIdx": 0, - "setId": 2, - "iconIdx": 93 + "setId": 0, + "iconIdx": 107 }, { "icon": { "paths": [ - "M234.667 192h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z", - "M234.667 576h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z", - "M618.667 192h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z", - "M618.667 576h170.667c23.564 0 42.667 19.103 42.667 42.667v170.667c0 23.564-19.103 42.667-42.667 42.667h-170.667c-23.564 0-42.667-19.103-42.667-42.667v-170.667c0-23.564 19.103-42.667 42.667-42.667z" + "M512.009 42.671c-63.962-0.254-127.283 12.687-186.014 38.014s-111.606 62.497-155.32 109.186v-83.2c0.070-6.185-1.206-12.311-3.738-17.954s-6.262-10.668-10.929-14.726c-4.667-4.059-10.161-7.054-16.101-8.778s-12.183-2.137-18.298-1.209c-10.351 2.006-19.661 7.599-26.295 15.793s-10.164 18.466-9.972 29.007v189.867c0 11.316 4.495 22.168 12.497 30.17s18.854 12.497 30.17 12.497h192c6.185 0.070 12.311-1.206 17.954-3.738s10.668-6.262 14.726-10.929c4.059-4.667 7.054-10.161 8.779-16.101s2.137-12.183 1.208-18.298c-2.006-10.351-7.599-19.661-15.793-26.295s-18.466-10.164-29.007-9.972h-91.733c49.696-55.344 114.396-95.063 186.238-114.332 71.84-19.269 147.736-17.258 218.456 5.789s133.227 66.136 179.921 124.034c46.694 57.898 75.571 128.111 83.119 202.109 1.058 10.56 6.020 20.348 13.909 27.447s18.142 11.004 28.757 10.953c5.982 0.030 11.9-1.199 17.374-3.605 5.478-2.406 10.385-5.935 14.409-10.359 4.023-4.429 7.074-9.647 8.947-15.33 1.873-5.679 2.534-11.686 1.937-17.638-11.639-115.805-65.89-223.153-152.226-301.208-86.332-78.054-198.588-121.247-314.974-121.193z", + "M896.081 682.667h-192c-6.187-0.068-12.309 1.207-17.954 3.738-5.645 2.534-10.667 6.263-14.724 10.931-4.062 4.668-7.057 10.159-8.781 16.098-1.724 5.943-2.138 12.186-1.207 18.3 2.005 10.351 7.599 19.661 15.791 26.295 8.196 6.635 18.466 10.163 29.009 9.971h91.733c-49.698 55.343-114.398 95.061-186.24 114.334-71.842 19.268-147.733 17.254-218.455-5.79-70.721-23.049-133.227-66.138-179.922-124.036-46.695-57.894-75.571-128.111-83.117-202.108-1.061-10.56-6.021-20.348-13.91-27.447s-18.142-11.004-28.756-10.953c-5.981-0.030-11.901 1.199-17.377 3.605s-10.385 5.935-14.408 10.359c-4.023 4.429-7.071 9.647-8.946 15.33-1.875 5.679-2.534 11.686-1.936 17.638 8.961 89.579 43.494 174.686 99.483 245.18s131.070 123.401 216.292 152.405c85.223 29.009 176.986 32.892 264.355 11.191 87.373-21.696 166.656-68.066 228.403-133.577v83.2c-0.073 6.187 1.203 12.309 3.738 17.954s6.263 10.667 10.927 14.724c4.668 4.062 10.163 7.057 16.102 8.781s12.186 2.138 18.3 1.207c10.351-2.005 19.661-7.599 26.295-15.791 6.63-8.196 10.163-18.466 9.971-29.009v-189.867c0-11.315-4.497-22.17-12.497-30.17s-18.854-12.497-30.17-12.497z", + "M738.837 465.613h-180.275v-180.274c0-25.617-20.766-46.409-46.409-46.409-25.617 0-46.409 20.791-46.409 46.409v180.274h-180.225c-25.641 0-46.408 20.791-46.408 46.409s20.768 46.409 46.408 46.409h180.225v180.25c0 25.617 20.791 46.409 46.409 46.409 25.643 0 46.409-20.791 46.409-46.409v-180.25h180.275c25.617 0 46.409-20.791 46.409-46.409s-20.791-46.409-46.409-46.409z" ], - "attrs": [{}, {}, {}, {}], + "attrs": [{}, {}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["blocks"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}] } + "tags": ["transaction-recovery"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}, {}] } }, - "attrs": [{}, {}, {}, {}], - "properties": { "order": 28, "id": 9, "name": "blocks", "prevSize": 32, "code": 59748 }, + "attrs": [{}, {}, {}], + "properties": { "order": 371, "id": 9, "name": "transaction-recovery", "prevSize": 32, "code": 59766 }, "setIdx": 0, - "setId": 2, - "iconIdx": 94 + "setId": 0, + "iconIdx": 108 }, { "icon": { "paths": [ - "M128 224c0-35.346 28.654-64 64-64h640c35.345 0 64 28.654 64 64s-28.655 64-64 64h-640c-35.346 0-64-28.654-64-64z", - "M128 736c0-35.345 28.654-64 64-64h640c35.345 0 64 28.655 64 64s-28.655 64-64 64h-640c-35.346 0-64-28.655-64-64z", - "M128 480c0-35.345 28.654-64 64-64h640c35.345 0 64 28.655 64 64s-28.655 64-64 64h-640c-35.346 0-64-28.655-64-64z" + "M980.258 154.546c3.703-20.891 3.699-20.892 3.691-20.893l16.439 2.448 2.675 15.065c-0.004-0.008-0.004-0.015-22.805 3.38z", + "M70.168 151.181l-0.013 0.074-0.029 0.169-0.098 0.578-0.336 2.087c-0.28 1.795-0.663 4.384-1.1 7.688-0.873 6.605-1.961 16.079-2.86 27.772-1.793 23.348-2.842 55.745 0.164 91.934 5.942 71.526 28.060 161.951 96.514 224.671 51.195 46.903 119.207 69.956 182.265 80.905 50.973 8.849 100.099 10.035 137.33 8.9v252.002c0 26.53 23.475 48.038 52.433 48.038s52.433-21.508 52.433-48.038v-252.109c36.843 1.323 86.306 0.474 137.907-8.124 64.137-10.688 133.828-33.724 186.031-81.553 68.463-62.724 90.581-153.159 96.525-224.693 3.008-36.193 1.958-68.593 0.162-91.944-0.896-11.695-1.984-21.17-2.859-27.776-0.435-3.304-0.819-5.894-1.101-7.689l-0.337-2.087-0.094-0.578-0.030-0.169-0.013-0.074-22.805 3.38 3.691-20.893-0.9-0.129-2.278-0.308c-1.958-0.256-4.783-0.608-8.388-1.008-7.211-0.8-17.553-1.798-30.319-2.621-25.485-1.643-60.847-2.604-100.352 0.15-78.071 5.445-176.777 25.712-245.239 88.438-26.227 24.029-45.611 52.096-59.883 81.484-14.268-29.386-33.651-57.45-59.874-81.478-68.455-62.719-167.148-82.984-245.214-88.428-39.498-2.755-74.857-1.793-100.34-0.15-12.763 0.823-23.103 1.82-30.312 2.62-3.606 0.4-6.432 0.751-8.391 1.008l-2.278 0.308-0.631 0.090-0.185 0.027-0.081 0.012 3.688 20.893c-3.702-20.873-3.696-20.892-3.688-20.893l-16.442 2.448-2.672 15.065c0.002-0.008 0.020-0.012 22.802 3.38l-22.802-3.38zM849.673 225.542c19.631-1.369 38.182-1.648 54.579-1.356 0.316 15.023 0.013 32.019-1.481 50.006-5.303 63.805-24.358 125.788-66.112 164.047-41.758 38.259-109.41 55.718-179.051 60.574-19.631 1.37-38.182 1.647-54.575 1.357-0.32-15.023-0.017-32.021 1.481-50.005 5.299-63.806 24.354-125.789 66.112-164.048 41.754-38.258 109.406-55.717 179.046-60.573zM170.461 274.192c-1.494-17.981-1.799-34.972-1.481-49.99 16.392-0.292 34.937-0.012 54.562 1.356 69.631 4.855 137.271 22.312 179.020 60.564 41.747 38.252 60.802 100.224 66.101 164.020 1.493 17.98 1.801 34.974 1.481 49.993-16.393 0.29-34.935 0.013-54.562-1.357-69.63-4.855-137.27-22.315-179.020-60.565s-60.802-100.224-66.102-164.020z" ], - "attrs": [{}, {}, {}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["rows"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}] } + "tags": ["transaction-stake"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}, {}, {}], - "properties": { "order": 29, "id": 8, "name": "rows", "prevSize": 32, "code": 59749 }, + "attrs": [{}, {}], + "properties": { "order": 370, "id": 8, "name": "transaction-stake", "prevSize": 32, "code": 59767 }, "setIdx": 0, - "setId": 2, - "iconIdx": 95 + "setId": 0, + "iconIdx": 109 }, { "icon": { "paths": [ - "M384 85.333h-213.333c-47.128 0-85.333 38.205-85.333 85.333v213.333c0 47.13 38.205 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.128-38.204-85.333-85.333-85.333zM170.667 384v-213.333h213.333v213.333h-213.333z", - "M853.333 554.667h-213.333c-47.13 0-85.333 38.204-85.333 85.333v213.333c0 47.13 38.204 85.333 85.333 85.333h213.333c47.13 0 85.333-38.204 85.333-85.333v-213.333c0-47.13-38.204-85.333-85.333-85.333zM640 853.333v-213.333h213.333v213.333h-213.333z", - "M938.667 277.333c0 106.039-85.961 192-192 192s-192-85.961-192-192c0-106.039 85.961-192 192-192s192 85.961 192 192zM640 277.333c0 58.91 47.757 106.667 106.667 106.667s106.667-47.756 106.667-106.667c0-58.91-47.757-106.667-106.667-106.667s-106.667 47.756-106.667 106.667z", - "M277.393 804.651l-122.236 122.236c-15.872 15.872-41.813 15.872-57.728 0l-0.171-0.213c-15.872-15.872-15.872-41.856 0-57.771l122.197-122.197-122.18-122.197c-15.872-15.872-15.872-41.856 0-57.728l0.171-0.213c15.915-15.872 41.856-15.872 57.728 0l122.219 122.202 122.202-122.202c15.872-15.872 41.856-15.872 57.728 0l0.213 0.213c15.872 15.872 15.872 41.856 0 57.728l-122.198 122.197 122.215 122.197c15.872 15.915 15.872 41.899 0 57.771l-0.213 0.213c-15.872 15.872-41.856 15.872-57.728 0l-122.219-122.236z" + "M670.175 133.068c-12.157-11.836-31.605-11.576-43.438 0.58-11.837 12.156-11.579 31.605 0.578 43.44l73.273 71.343h-370.421c-41.68 0-81.813 16.114-111.528 45.047-29.744 28.96-46.609 68.416-46.609 109.735v62.031c0 16.966 13.754 30.72 30.72 30.72s30.72-13.754 30.72-30.72v-62.031c0-24.487 9.983-48.143 28.031-65.715 18.075-17.599 42.761-27.628 68.667-27.628h370.396l-73.249 71.321c-12.157 11.835-12.415 31.283-0.578 43.44 11.833 12.157 31.281 12.415 43.438 0.582l127.418-124.063c5.939-5.783 9.29-13.721 9.29-22.010s-3.351-16.227-9.29-22.010l-127.418-124.062zM352.178 567.869c11.836 12.153 11.576 31.605-0.58 43.438l-73.254 71.324h370.368c25.907 0 50.594-10.027 68.669-27.628 18.047-17.572 28.029-41.226 28.029-65.712v-62.034c0-16.966 13.754-30.72 30.72-30.72s30.72 13.754 30.72 30.72v62.034c0 41.316-16.867 80.773-46.608 109.732-29.716 28.934-69.849 45.048-111.53 45.048h-370.384l73.27 71.34c12.156 11.837 12.415 31.285 0.58 43.442-11.836 12.153-31.284 12.415-43.441 0.578l-127.418-124.060c-1.823-1.778-3.403-3.756-4.716-5.886-2.755-4.452-4.403-9.662-4.561-15.249-0.009-0.291-0.013-0.59-0.013-0.885 0-8.843 3.739-16.818 9.723-22.422l126.985-123.642c12.156-11.833 31.605-11.575 43.441 0.582z" ], - "attrs": [{}, {}, {}, {}], + "attrs": [{}], + "width": 983, "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["apps"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}, {}, {}] } + "tags": ["transaction-swap"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}, {}, {}], - "properties": { "order": 30, "id": 7, "name": "apps", "prevSize": 32, "code": 59750 }, + "attrs": [{}], + "properties": { "order": 369, "id": 7, "name": "transaction-swap", "prevSize": 32, "code": 59768 }, "setIdx": 0, - "setId": 2, - "iconIdx": 96 + "setId": 0, + "iconIdx": 110 }, { "icon": { "paths": [ - "M170.667 384v256h-42.667c-23.564 0-42.667 19.102-42.667 42.667s19.103 42.667 42.667 42.667h42.667v128c0 47.13 38.205 85.333 85.333 85.333h512c47.13 0 85.333-38.204 85.333-85.333v-682.667c0-47.128-38.204-85.333-85.333-85.333h-512c-47.128 0-85.333 38.205-85.333 85.333v128h-42.667c-23.564 0-42.667 19.103-42.667 42.667s19.103 42.667 42.667 42.667h42.667zM298.667 640h-42.667v-256h42.667c23.564 0 42.667-19.103 42.667-42.667s-19.103-42.667-42.667-42.667h-42.667v-128h512v682.667h-512v-128h42.667c23.564 0 42.667-19.102 42.667-42.667s-19.103-42.667-42.667-42.667z" + "M967.629 575.548c-18.27-19.029-48.141-19.029-66.411 0l-107.558 111.753v-467.835c0-26.839-21.137-48.799-46.972-48.799-25.877 0-46.967 21.959-46.967 48.799v467.835l-107.605-111.753c-18.27-18.982-48.141-18.982-66.411 0-18.274 18.987-18.274 50.022 0 69.005l187.78 195.046c2.722 2.927 6.199 4.489 9.391 6.49 1.975 1.22 3.571 2.978 5.73 3.955 1.929 0.879 4.087 0.879 6.106 1.365 9.958 2.782 20.48 2.731 30.063-1.365 2.065-0.93 3.614-2.637 5.542-3.857 3.285-2.001 6.716-3.61 9.579-6.588l187.733-195.046c18.274-18.982 18.274-50.018 0-69.005z", + "M498.295 448.452c-18.27 19.029-48.141 19.029-66.411 0l-107.559-111.751v467.834c0 26.837-21.136 48.798-46.969 48.798-25.879 0-46.969-21.961-46.969-48.798v-467.834l-107.605 111.751c-18.271 18.982-48.143 18.982-66.413 0-18.271-18.987-18.271-50.021 0-69.004l187.78-195.048c2.724-2.928 6.2-4.49 9.393-6.49 1.973-1.22 3.569-2.977 5.73-3.953 1.926-0.879 4.086-0.879 6.106-1.366 9.957-2.782 20.478-2.733 30.060 1.366 2.066 0.927 3.616 2.635 5.542 3.855 3.287 2.001 6.716 3.611 9.581 6.588l187.732 195.048c18.274 18.983 18.274 50.017 0 69.004z" ], - "attrs": [{}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["address-book"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["transactions"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}], - "properties": { "order": 31, "id": 6, "name": "address-book", "prevSize": 32, "code": 59751 }, + "attrs": [{}, {}], + "properties": { "order": 368, "id": 6, "name": "transactions", "prevSize": 32, "code": 59769 }, "setIdx": 0, - "setId": 2, - "iconIdx": 97 + "setId": 0, + "iconIdx": 111 }, { "icon": { "paths": [ - "M896 490.667c0.145 56.316-13.009 111.868-38.4 162.133-30.106 60.233-76.382 110.899-133.658 146.317-57.271 35.418-123.273 54.191-190.609 54.217-56.316 0.145-111.867-13.009-162.133-38.4l-243.2 81.067 81.067-243.2c-25.39-50.266-38.547-105.818-38.4-162.133 0.026-67.338 18.799-133.34 54.217-190.611s86.081-103.551 146.316-133.655c50.266-25.39 105.818-38.547 162.133-38.4h21.333c88.93 4.906 172.928 42.443 235.908 105.423s100.518 146.978 105.425 235.91v21.333z" - ], - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } + "M111.711 109.709l310.636 443.769-312.598 360.801h70.353l273.679-315.886 221.124 315.886h239.418l-328.116-468.728 290.964-335.842h-70.355l-252.043 290.927-203.647-290.927h-239.415zM215.171 165.077h109.988l485.689 693.828h-109.99l-485.686-693.828z" ], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["chat"], - "colorPermutations": { "11611631671181918125521401255951141": [{ "s": 0 }] } + "tags": ["twitter-x"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [ - { - "fill": "none", - "strokeLinejoin": "round", - "strokeLinecap": "round", - "strokeMiterlimit": "4", - "strokeWidth": 85.33333333333333 - } - ], - "properties": { "order": 145, "id": 5, "name": "chat", "prevSize": 32, "code": 59752 }, + "attrs": [{}], + "properties": { "order": 367, "id": 5, "name": "twitter-x", "prevSize": 32, "code": 59770 }, "setIdx": 0, - "setId": 2, - "iconIdx": 98 + "setId": 0, + "iconIdx": 112 }, { "icon": { "paths": [ - "M512.427 767.987c-141.184 0-256-114.816-256-256s114.816-256 256-256c141.184 0 256 114.816 256 256s-114.816 256-256 256zM902.443 460.318l-14.763-1.28c-29.568-3.157-40.704-20.949-44.629-30.592-8.192-32.555-21.035-63.147-37.803-91.264-4.053-9.387-9.259-30.123 9.771-53.632l9.472-11.349c20.736-23.637 12.459-38.443-4.096-55.083l-13.739-13.696c-16.597-16.597-31.445-24.832-55.083-4.139l-11.349 9.515c-24.96 20.139-46.763 13.141-55.211 9.045-26.411-15.531-55.083-27.52-85.419-35.541-7.040-1.963-31.104-11.221-34.816-46.165l-1.323-14.763c-2.091-31.36-18.347-36.053-41.856-36.053h-19.413c-23.467 0-39.808 4.693-41.856 36.053l-1.323 14.763c-4.352 40.917-36.821 46.72-36.821 46.72 0 0.085 0.043 0.171 0.043 0.256-28.075 7.68-54.656 18.987-79.317 33.152-0.085-0.128-0.128-0.341-0.256-0.469 0 0-27.051 18.859-59.051-6.955l-11.392-9.557c-23.637-20.693-38.485-12.459-55.083 4.139l-13.696 13.696c-16.597 16.64-24.832 31.445-4.181 55.083l9.557 11.349c25.856 32.043 6.955 59.093 6.955 59.093 0.213 0.171 0.469 0.256 0.683 0.384-14.165 24.704-25.387 51.243-33.024 79.317-0.213-0.043-0.427-0.128-0.597-0.171 0 0-5.76 32.469-46.677 36.864l-14.805 1.28c-31.317 2.091-36.011 18.432-36.011 41.899v19.413c0 23.424 4.693 39.765 36.011 41.813l14.805 1.323c40.917 4.352 46.677 36.864 46.677 36.864 0.171 0 0.341-0.085 0.512-0.128 7.68 28.075 18.688 54.741 32.768 79.445-0.085 0.085-0.256 0.085-0.341 0.171 0 0 18.901 27.093-6.955 59.093l-9.557 11.349c-20.651 23.637-12.459 38.528 4.181 55.083l13.696 13.739c16.597 16.597 31.445 24.832 55.083 4.096l11.392-9.472c32-25.899 59.051-6.997 59.051-6.997 0.043-0.043 0.043-0.085 0.085-0.128 24.747 14.208 51.371 25.259 79.445 32.981v0.128c0 0 32.469 5.803 36.821 46.72l1.323 14.72c2.048 31.36 18.389 36.011 41.856 36.011h19.413c23.509 0 39.765-4.651 41.856-36.011l1.323-14.72c3.413-31.915 23.723-42.411 32.597-45.483 31.019-7.936 60.203-20.267 87.125-36.011 8.021-4.011 30.165-11.861 55.723 8.789l11.349 9.515c23.637 20.693 38.485 12.459 55.083-4.139l13.739-13.739c16.555-16.555 24.832-31.445 4.096-55.083l-9.472-11.307c-20.437-25.344-13.013-47.317-8.917-55.509 16.299-27.691 29.013-57.685 37.077-89.643 4.011-9.685 15.104-27.179 44.501-30.336l14.763-1.323c31.317-2.048 35.968-18.389 35.968-41.813v-19.413c0-23.467-4.651-39.808-35.968-41.899z" + "M768 810.522c0 23.552-19.115 42.667-42.667 42.667h-426.667c-23.552 0-42.667-19.115-42.667-42.667v-298.667c0-23.552 19.115-42.667 42.667-42.667h426.667c23.552 0 42.667 19.115 42.667 42.667v298.667zM768.171 391.791v-50.432c0-141.184-114.859-256.043-256-256.043-103.125 0-195.669 61.397-235.819 156.373-9.173 21.717 0.939 46.763 22.656 55.936 21.675 9.131 46.763-1.024 55.893-22.699 26.795-63.317 88.533-104.277 157.269-104.277 94.123 0 170.667 76.587 170.667 170.709v42.496h-384.171c-70.571 0-128 57.429-128 128v298.667c0 70.571 57.429 128 128 128h426.667c70.571 0 128-57.429 128-128v-298.667c0-55.509-35.712-102.357-85.163-120.064z", + "M576 618.398c0-35.371-28.672-64-64-64s-64 28.629-64 64c0 18.901 8.363 35.755 21.419 47.445v59.051c0 23.637 19.157 42.795 42.837 42.795 23.637 0 42.795-19.157 42.795-42.795v-59.392c12.8-11.733 20.949-28.416 20.949-47.104z" ], - "attrs": [{}], + "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["settings"], - "colorPermutations": { "11611631671181918125521401255951141": [{}] } + "tags": ["unlock"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, - "attrs": [{}], - "properties": { "order": 33, "id": 4, "name": "settings", "prevSize": 32, "code": 59753 }, + "attrs": [{}, {}], + "properties": { "order": 366, "id": 4, "name": "unlock", "prevSize": 32, "code": 59771 }, "setIdx": 0, - "setId": 2, - "iconIdx": 99 + "setId": 0, + "iconIdx": 113 }, { "icon": { "paths": [ - "M967.629 575.548c-18.27-19.029-48.141-19.029-66.411 0l-107.558 111.753v-467.835c0-26.839-21.137-48.799-46.972-48.799-25.877 0-46.967 21.959-46.967 48.799v467.835l-107.605-111.753c-18.27-18.982-48.141-18.982-66.411 0-18.274 18.987-18.274 50.022 0 69.005l187.78 195.046c2.722 2.927 6.199 4.489 9.391 6.49 1.975 1.22 3.571 2.978 5.73 3.955 1.929 0.879 4.087 0.879 6.106 1.365 9.958 2.782 20.48 2.731 30.063-1.365 2.065-0.93 3.614-2.637 5.542-3.857 3.285-2.001 6.716-3.61 9.579-6.588l187.733-195.046c18.274-18.982 18.274-50.018 0-69.005z", - "M498.295 448.452c-18.27 19.029-48.141 19.029-66.411 0l-107.559-111.751v467.834c0 26.837-21.136 48.798-46.969 48.798-25.879 0-46.969-21.961-46.969-48.798v-467.834l-107.605 111.751c-18.271 18.982-48.143 18.982-66.413 0-18.271-18.987-18.271-50.021 0-69.004l187.78-195.048c2.724-2.928 6.2-4.49 9.393-6.49 1.973-1.22 3.569-2.977 5.73-3.953 1.926-0.879 4.086-0.879 6.106-1.366 9.957-2.782 20.478-2.733 30.060 1.366 2.066 0.927 3.616 2.635 5.542 3.855 3.287 2.001 6.716 3.611 9.581 6.588l187.732 195.048c18.274 18.983 18.274 50.017 0 69.004z" + "M908.809 229.248c-22.528-7.253-46.464 4.992-53.803 27.392l-16.896 52.053c-68.864-110.805-190.507-180.693-325.973-180.693-178.987 0-332.672 121.515-373.76 295.467-5.419 22.912 8.789 45.909 31.787 51.328 3.285 0.725 6.571 1.109 9.813 1.109 19.328 0 36.821-13.184 41.515-32.853 31.915-135.253 151.424-229.717 290.646-229.717 106.283 0 200.96 55.808 254.251 143.317l-66.133-20.309c-22.571-6.997-46.421 5.803-53.333 28.245-6.912 22.528 5.76 46.421 28.245 53.333l157.312 48.341c4.181 1.28 8.405 1.877 12.544 1.877 18.005 0 34.688-11.477 40.576-29.483l50.603-155.605c7.253-22.443-5.035-46.507-27.392-53.803z", + "M855.309 536.375c-23.424-4.565-45.611 10.539-50.133 33.621-27.392 139.477-150.656 240.683-293.035 240.683-105.941 0-200.704-55.765-254.037-143.104l65.237 20.096c22.613 7.040 46.421-5.76 53.333-28.245 6.912-22.571-5.717-46.421-28.245-53.333l-157.269-48.384c-22.315-6.869-45.909 5.461-53.163 27.605l-50.56 155.691c-7.296 22.357 4.992 46.464 27.435 53.76 4.352 1.408 8.789 2.048 13.141 2.048 18.005 0 34.731-11.435 40.576-29.483l17.152-52.736c68.779 111.189 190.891 181.419 326.4 181.419 183.040 0 341.504-130.176 376.789-309.504 4.565-23.125-10.539-45.611-33.621-50.133z" ], "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["transactions"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["update"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, "attrs": [{}, {}], - "properties": { "order": 34, "id": 3, "name": "transactions", "prevSize": 32, "code": 59754 }, + "properties": { "order": 364, "id": 3, "name": "update", "prevSize": 32, "code": 59772 }, "setIdx": 0, - "setId": 2, - "iconIdx": 100 + "setId": 0, + "iconIdx": 114 }, { "icon": { "paths": [ - "M879.501 335.075h-737.449v73.955h737.449v-73.955z", - "M902.886 381.44l-9.216-7.055c-9.899-7.567-11.891-21.675-4.437-31.631l6.997-9.385c11.375-14.963 6.37-33.338-6.711-48.755l-95.347-133.518c-14.336-20.082-37.491-32.029-62.182-32.029h-440.261c-24.689 0-47.843 11.947-62.179 32.029l-95.119 133.518c-13.085 15.418-18.034 33.736-6.713 48.755l6.997 9.385c7.396 10.013 5.461 24.064-4.437 31.631l-9.216 7.055c-14.962 11.377-17.92 32.71-6.543 47.671l335.076 466.547c5.005 6.541 12.745 10.411 20.992 10.411h82.658c8.247 0 15.987-3.87 20.992-10.411l335.249-466.547c11.375-14.961 8.418-36.35-6.545-47.671h-0.055zM519.454 838.37c-3.81 5.009-11.319 5.009-15.13 0l-335.191-459.604c-3.813-5.006-2.56-12.288 1.138-17.236l105.984-148.538c8.931-12.573 23.381-19.968 38.798-19.968h393.615c15.415 0 29.867 7.453 38.797 19.968l106.155 148.538c3.755 4.948 4.949 12.288 1.139 17.236l-335.305 459.604z" + "M469.312 359.087v536.875c0 23.595 19.115 42.667 42.667 42.667 23.595 0 42.667-19.072 42.667-42.667v-536.875l175.872 175.829c16.683 16.683 43.691 16.683 60.331 0 16.683-16.683 16.683-43.648 0-60.331l-241.365-241.365c-1.237-1.237-2.56-2.389-3.883-3.413-7.851-10.027-19.968-16.384-33.621-16.384-13.611 0-25.771 6.357-33.579 16.384-1.323 1.024-2.688 2.176-3.883 3.413l-241.365 241.365c-16.683 16.683-16.683 43.648 0 60.331 16.64 16.683 43.648 16.683 60.331 0l175.829-175.829z", + "M895.979 170.667h-768c-23.467 0-42.667-19.2-42.667-42.667s19.2-42.667 42.667-42.667h768c23.509 0 42.667 19.2 42.667 42.667s-19.157 42.667-42.667 42.667z" ], "attrs": [{}, {}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["nft"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["upload"], + "colorPermutations": { "11611631671255214012559511412828281": [{}, {}] } }, "attrs": [{}, {}], - "properties": { "order": 35, "id": 2, "name": "nft", "prevSize": 32, "code": 59755 }, + "properties": { "order": 365, "id": 2, "name": "upload", "prevSize": 32, "code": 59773 }, "setIdx": 0, - "setId": 2, - "iconIdx": 101 + "setId": 0, + "iconIdx": 115 }, { "icon": { "paths": [ - "M639.915 213.312c-164.907 0-298.665 133.76-298.665 298.667 0 164.949 133.758 298.667 298.665 298.667 164.949 0 298.667-133.717 298.667-298.667 0-164.907-133.717-298.667-298.667-298.667zM639.915 298.645c117.675 0 213.333 95.701 213.333 213.333 0 117.675-95.659 213.333-213.333 213.333-117.632 0-213.331-95.659-213.331-213.333 0-117.632 95.699-213.333 213.331-213.333z", - "M290.319 228.618c-121.393 39.957-204.986 153.478-204.986 283.216s83.593 243.26 204.986 283.217c22.383 7.364 46.5-4.809 53.868-27.187 7.367-22.383-4.805-46.502-27.188-53.871-86.66-28.523-146.332-109.559-146.332-202.159s59.671-173.636 146.332-202.16c22.383-7.367 34.555-31.485 27.188-53.868s-31.485-34.555-53.868-27.188z" + "M896 640v85.333c0 47.13-38.204 85.333-85.333 85.333h-640c-47.128 0-85.333-38.204-85.333-85.333v-426.667c0-47.128 38.205-85.333 85.333-85.333h640c47.13 0 85.333 38.205 85.333 85.333v85.333c23.565 0 42.667 19.103 42.667 42.667v170.667c0 23.565-19.102 42.667-42.667 42.667zM810.667 384v-85.333h-640v426.667h640v-85.333h-128c-23.565 0-42.667-19.102-42.667-42.667v-170.667c0-23.564 19.102-42.667 42.667-42.667h128zM725.333 554.667v-85.333h128v85.333h-128z" ], - "attrs": [{}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["token"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["wallet"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}], - "properties": { "order": 36, "id": 1, "name": "token", "prevSize": 32, "code": 59756 }, + "attrs": [{}], + "properties": { "order": 363, "id": 1, "name": "wallet", "prevSize": 32, "code": 59774 }, "setIdx": 0, - "setId": 2, - "iconIdx": 102 + "setId": 0, + "iconIdx": 116 }, { "icon": { "paths": [ - "M488.427 93.504c13.867-10.894 33.28-10.894 47.147 0l345.6 271.515c9.357 7.349 14.827 18.648 14.827 30.618v426.665c0 30.861-12.139 60.459-33.741 82.283-21.606 21.824-50.906 34.082-81.459 34.082h-537.6c-30.553 0-59.855-12.258-81.459-34.082s-33.741-51.422-33.741-82.283v-426.665c0-11.97 5.471-23.269 14.825-30.618l345.602-271.515zM204.8 414.607v407.695c0 10.287 4.046 20.156 11.247 27.43 7.202 7.27 16.969 11.358 27.153 11.358h537.6c10.185 0 19.951-4.087 27.153-11.358 7.202-7.275 11.247-17.143 11.247-27.43v-407.695l-307.2-241.347-307.2 241.347z", - "M341.333 508.446c0-21.602 19.103-39.113 42.667-39.113h256c23.565 0 42.667 17.51 42.667 39.113v391.108c0 21.602-19.102 39.113-42.667 39.113s-42.667-17.51-42.667-39.113v-352h-170.667v352c0 21.602-19.103 39.113-42.667 39.113s-42.667-17.51-42.667-39.113v-391.108z" + "M327.638 166.603c50.311 0 86.695 34.066 113.199 80.535 8.055 14.121 14.566 28.392 19.712 41.37h-132.911c-16.764 0-32.842-6.421-44.696-17.852s-18.513-26.935-18.513-43.1c0-16.166 6.659-31.669 18.513-43.1s27.932-17.853 44.696-17.853zM512 203.349c-32.030-54.196-89.885-118.016-184.362-118.016-39.117 0-76.631 14.984-104.291 41.656s-43.199 62.847-43.199 100.566c0 35.073 13.435 68.811 37.557 94.815h-93.584c-21.422 0-38.788 15.16-38.788 33.862v169.314c0 18.701 17.366 33.86 38.788 33.86h8.62v343.706c0 19.635 18.867 35.554 42.14 35.554h674.237c23.275 0 42.142-15.919 42.142-35.554v-343.706h8.619c21.423 0 38.788-15.159 38.788-33.86v-169.314c0-18.702-17.365-33.862-38.788-33.862h-93.585c24.124-26.004 37.559-59.742 37.559-94.815 0-37.719-15.539-73.894-43.2-100.566s-65.173-41.656-104.29-41.656c-94.477 0-152.333 63.82-184.363 118.016zM559.407 867.554v-308.147h247.573v308.147h-247.573zM464.593 559.407v308.147h-247.572v-308.147h247.572zM559.407 491.682v-101.587h301.683v101.587h-301.683zM464.593 390.095v101.587h-301.684v-101.587h301.684zM696.363 288.508h-132.911c5.146-12.978 11.657-27.249 19.712-41.37 26.505-46.469 62.886-80.535 113.199-80.535 16.764 0 32.841 6.422 44.698 17.853 11.853 11.431 18.513 26.934 18.513 43.1s-6.66 31.669-18.513 43.1c-11.857 11.431-27.934 17.852-44.698 17.852z" ], - "attrs": [{}, {}], + "attrs": [{}], "isMulticolor": false, "isMulticolor2": false, "grid": 0, - "tags": ["home"], - "colorPermutations": { "11611631671181918125521401255951141": [{}, {}] } + "tags": ["what-is-new"], + "colorPermutations": { "11611631671255214012559511412828281": [{}] } }, - "attrs": [{}, {}], - "properties": { "order": 37, "id": 0, "name": "home", "prevSize": 32, "code": 59757 }, + "attrs": [{}], + "properties": { "order": 362, "id": 0, "name": "what-is-new", "prevSize": 32, "code": 59775 }, "setIdx": 0, - "setId": 2, - "iconIdx": 103 + "setId": 0, + "iconIdx": 117 } ], "height": 1024, "metadata": { "name": "safe-icons" }, "preferences": { "showGlyphs": true, + "showCodes": true, "showQuickUse": true, "showQuickUse2": true, "showSVGs": true, @@ -2429,23 +2373,10 @@ "metrics": { "emSize": 1024, "baseline": 6.25, "whitespace": 50 }, "embed": false, "noie8": true, - "ie7": false, - "showSelector": false, - "showMetrics": false, - "showMetadata": false, - "showVersion": true - }, - "imagePref": { - "prefix": "icon-", - "png": true, - "useClassSelector": true, - "color": 0, - "bgColor": 16777215, - "classSelector": ".icon", - "name": "icomoon" + "ie7": false }, + "imagePref": { "prefix": "icon-", "png": true, "useClassSelector": true, "color": 0, "bgColor": 16777215 }, "historySize": 50, - "showCodes": true, "gridSize": 16 } } diff --git a/apps/mobile/assets/images/anywhere.png b/apps/mobile/assets/images/anywhere.png index 11fc00015b..e5d8e6d075 100644 Binary files a/apps/mobile/assets/images/anywhere.png and b/apps/mobile/assets/images/anywhere.png differ diff --git a/apps/mobile/assets/images/biometrics-dark-android.png b/apps/mobile/assets/images/biometrics-dark-android.png new file mode 100644 index 0000000000..1534ef0f41 Binary files /dev/null and b/apps/mobile/assets/images/biometrics-dark-android.png differ diff --git a/apps/mobile/assets/images/biometrics-dark.png b/apps/mobile/assets/images/biometrics-dark.png new file mode 100644 index 0000000000..7da568361a Binary files /dev/null and b/apps/mobile/assets/images/biometrics-dark.png differ diff --git a/apps/mobile/assets/images/biometrics-light-android.png b/apps/mobile/assets/images/biometrics-light-android.png new file mode 100644 index 0000000000..a4fc75c014 Binary files /dev/null and b/apps/mobile/assets/images/biometrics-light-android.png differ diff --git a/apps/mobile/assets/images/biometrics-light.png b/apps/mobile/assets/images/biometrics-light.png new file mode 100644 index 0000000000..b09828f76b Binary files /dev/null and b/apps/mobile/assets/images/biometrics-light.png differ diff --git a/apps/mobile/assets/images/favicon.png b/apps/mobile/assets/images/favicon.png deleted file mode 100644 index e75f697b18..0000000000 Binary files a/apps/mobile/assets/images/favicon.png and /dev/null differ diff --git a/apps/mobile/assets/images/ic_notification.png b/apps/mobile/assets/images/ic_notification.png new file mode 100644 index 0000000000..4bbbca5e27 Binary files /dev/null and b/apps/mobile/assets/images/ic_notification.png differ diff --git a/apps/mobile/assets/images/illustration.png b/apps/mobile/assets/images/illustration.png index a483b9d30a..2289ccfb1f 100644 Binary files a/apps/mobile/assets/images/illustration.png and b/apps/mobile/assets/images/illustration.png differ diff --git a/apps/mobile/assets/images/import-data-select-files-dark@3x.png b/apps/mobile/assets/images/import-data-select-files-dark@3x.png new file mode 100644 index 0000000000..70118c6775 Binary files /dev/null and b/apps/mobile/assets/images/import-data-select-files-dark@3x.png differ diff --git a/apps/mobile/assets/images/import-data-select-files-light@3x.png b/apps/mobile/assets/images/import-data-select-files-light@3x.png new file mode 100644 index 0000000000..901d52afa7 Binary files /dev/null and b/apps/mobile/assets/images/import-data-select-files-light@3x.png differ diff --git a/apps/mobile/assets/images/metamask.png b/apps/mobile/assets/images/metamask.png new file mode 100644 index 0000000000..01c1984d11 Binary files /dev/null and b/apps/mobile/assets/images/metamask.png differ diff --git a/apps/mobile/assets/images/notifications-dark.png b/apps/mobile/assets/images/notifications-dark.png index 78376e847f..7e58cb662c 100644 Binary files a/apps/mobile/assets/images/notifications-dark.png and b/apps/mobile/assets/images/notifications-dark.png differ diff --git a/apps/mobile/assets/images/notifications-light.png b/apps/mobile/assets/images/notifications-light.png index ac49c8c69a..c578235d63 100644 Binary files a/apps/mobile/assets/images/notifications-light.png and b/apps/mobile/assets/images/notifications-light.png differ diff --git a/apps/mobile/assets/images/personalised-updates.png b/apps/mobile/assets/images/personalised-updates.png new file mode 100644 index 0000000000..89d1dbebf9 Binary files /dev/null and b/apps/mobile/assets/images/personalised-updates.png differ diff --git a/apps/mobile/assets/images/react-logo.png b/apps/mobile/assets/images/react-logo.png deleted file mode 100644 index 9d72a9ffcb..0000000000 Binary files a/apps/mobile/assets/images/react-logo.png and /dev/null differ diff --git a/apps/mobile/assets/images/react-logo@2x.png b/apps/mobile/assets/images/react-logo@2x.png deleted file mode 100644 index 2229b130ad..0000000000 Binary files a/apps/mobile/assets/images/react-logo@2x.png and /dev/null differ diff --git a/apps/mobile/assets/images/react-logo@3x.png b/apps/mobile/assets/images/react-logo@3x.png deleted file mode 100644 index a99b203222..0000000000 Binary files a/apps/mobile/assets/images/react-logo@3x.png and /dev/null differ diff --git a/apps/mobile/assets/images/safe-wallet.png b/apps/mobile/assets/images/safe-wallet.png index 9faf2f95b5..58c19a10de 100644 Binary files a/apps/mobile/assets/images/safe-wallet.png and b/apps/mobile/assets/images/safe-wallet.png differ diff --git a/apps/mobile/assets/images/select-signer.png b/apps/mobile/assets/images/select-signer.png new file mode 100644 index 0000000000..1287abea9b Binary files /dev/null and b/apps/mobile/assets/images/select-signer.png differ diff --git a/apps/mobile/assets/images/signature.png b/apps/mobile/assets/images/signature.png new file mode 100644 index 0000000000..5bd8ca8314 Binary files /dev/null and b/apps/mobile/assets/images/signature.png differ diff --git a/apps/mobile/assets/images/transfer-old-app-dark@3x.png b/apps/mobile/assets/images/transfer-old-app-dark@3x.png new file mode 100644 index 0000000000..e310360d09 Binary files /dev/null and b/apps/mobile/assets/images/transfer-old-app-dark@3x.png differ diff --git a/apps/mobile/assets/images/transfer-old-app-light@3x.png b/apps/mobile/assets/images/transfer-old-app-light@3x.png new file mode 100644 index 0000000000..4af4dd817d Binary files /dev/null and b/apps/mobile/assets/images/transfer-old-app-light@3x.png differ diff --git a/apps/mobile/assets/images/wallet.png b/apps/mobile/assets/images/wallet.png new file mode 100644 index 0000000000..5211531712 Binary files /dev/null and b/apps/mobile/assets/images/wallet.png differ diff --git a/apps/mobile/docs/analytics.md b/apps/mobile/docs/analytics.md new file mode 100644 index 0000000000..f932e73119 --- /dev/null +++ b/apps/mobile/docs/analytics.md @@ -0,0 +1,168 @@ +# Mobile Analytics Service + +This document explains how analytics are implemented in the Safe{Wallet} mobile app using Firebase Analytics, designed to match the Google Analytics implementation in the web app. + +## Overview + +The mobile analytics service provides: + +- Automatic tracking of common parameters with every event +- Structured event tracking for key user actions +- Screen view tracking +- User property management +- Firebase Analytics integration + +## Architecture + +The analytics service consists of several modules: + +### Core Service (`firebaseAnalytics.ts`) + +- Handles Firebase Analytics integration +- Manages common parameters (appVersion, chainId, deviceType, safeAddress) +- Provides functions for tracking events, setting user properties, and managing analytics state + +### Types (`types.ts`) + +- Defines TypeScript interfaces and enums +- Includes event types, device types, transaction types, and user properties + +### Events (`events/`) + +- Modular event definitions organized by category +- Helper functions for creating events with specific labels + +### Redux Middleware (`store/middleware/analytics/`) + +We use the **Strategy Pattern** to easily subscribe to more redux events and in response to them dispatch analytics events + +### Hooks + +- `useAnalytics.ts`: Manages analytics context and common parameters +- `useScreenTracking.ts`: Tracks screen views automatically + +## Usage + +### Analytics Enablement + +Analytics are **enabled by user consent** in the GetStarted screen when users first interact with the app: + +```typescript +// In GetStarted screen +import { setAnalyticsCollectionEnabled } from '@/src/services/analytics' + +const enableCrashlytics = async () => { + await getCrashlytics().setCrashlyticsCollectionEnabled(true) + await setAnalyticsCollectionEnabled(true) // User consents to analytics +} +``` + +### Basic Setup + +The analytics system is automatically set up and requires minimal configuration: + +1. **Global Setup**: The `useAnalytics()` hook is called in `_layout.tsx` to manage global analytics state +2. **Redux Middleware**: We prefer to use the redux middleware to track actions such as safe_open, signing txs etc +3. **Manual Tracking**: Use `trackEvent` for any additional manual tracking needs + +### Manual Event Tracking + +Import and use the `trackEvent` function: + +```typescript +import { trackEvent, OVERVIEW_EVENTS } from '@/src/services/analytics' + +// Track a simple event +await trackEvent(OVERVIEW_EVENTS.SAFE_VIEWED) +``` + +### Redux middleware Tracking + +Most events should be tracked via Redux middleware. + +### Managing User Properties + +Set user properties for analytics segmentation: + +```typescript +import { setUserProperty, AnalyticsUserProperties } from '@/src/services/analytics' + +// Set wallet information +await setUserProperty(AnalyticsUserProperties.WALLET_LABEL, 'MetaMask') +await setUserProperty(AnalyticsUserProperties.WALLET_ADDRESS, 'abcd1234...') // without 0x prefix +``` + +### Component Usage + +```typescript +// In any component that displays safe information +const SafeComponent = () => { + const handleTransaction = async () => { + await trackEvent(createTxConfirmEvent(TX_TYPES.transfer_token)) + // Automatically includes correct safeAddress and chainId + } + + return +} +``` + +## Event Structure + +### Common Parameters + +Every event automatically includes: + +- `appVersion`: Current app version from package.json +- `chainId`: Current blockchain network ID from activeSafe or route params +- `deviceType`: 'ios' or 'android' +- `safeAddress`: Current safe address from activeSafe or route params (without 0x prefix) + +### Event Parameters + +Each tracked event includes: + +- `eventName`: Firebase Analytics event name +- `eventCategory`: Categorizes the event (e.g., 'overview', 'transactions') +- `eventAction`: Describes the action (e.g., 'Safe viewed', 'Confirm transaction') +- `eventLabel`: Optional label for additional context (e.g., transaction type) + +## Development + +### Adding New Events + +1. Define event constants in the appropriate events file: + +```typescript +// events/newCategory.ts +export const NEW_CATEGORY_EVENTS = { + NEW_ACTION: { + eventName: EventType.NEW_EVENT, + eventCategory: 'new-category', + eventAction: 'New action', + }, +} +``` + +2. Export from `events/index.ts`: + +```typescript +export * from './newCategory' +``` + +3. Use in components: + +```typescript +import { trackEvent, NEW_CATEGORY_EVENTS } from '@/src/services/analytics' + +const MyComponent = () => { + const handleAction = async () => { + await trackEvent(NEW_CATEGORY_EVENTS.NEW_ACTION) + } + + return + + ) } diff --git a/apps/mobile/src/components/DataRow/DataRow.test.tsx b/apps/mobile/src/components/DataRow/DataRow.test.tsx index 49609e7161..b3507116c1 100644 --- a/apps/mobile/src/components/DataRow/DataRow.test.tsx +++ b/apps/mobile/src/components/DataRow/DataRow.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { render } from '@testing-library/react-native' +import { render } from '@/src/tests/test-utils' import { DataRow } from './index' import { Text } from 'react-native' import { View } from 'tamagui' @@ -75,7 +75,9 @@ describe('DataRow.Header', () => { const { getByText } = render(Header Child) const header = getByText('Header Child') expect(header.props.style).toMatchObject({ - fontWeight: '600', + fontFamily: 'DMSans-SemiBold', + marginBottom: 8, + marginTop: 8, }) }) }) diff --git a/apps/mobile/src/components/Dropdown/Dropdown.test.tsx b/apps/mobile/src/components/Dropdown/Dropdown.test.tsx deleted file mode 100644 index c8b8d0b71c..0000000000 --- a/apps/mobile/src/components/Dropdown/Dropdown.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { render, userEvent } from '@/src/tests/test-utils' -import { Dropdown } from '.' -import { Text, View } from 'tamagui' -import * as hooks from '@gorhom/bottom-sheet' - -const mockedItems = ['Ethereum', 'Sepolia', 'Nevinha'] - -describe('Dropdown', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('should render the default markup', () => { - const { getByText, queryByText, getByTestId } = render( - Here is my leftNode} - items={mockedItems} - keyExtractor={({ item }) => item} - renderItem={() => It should not be rendered} - />, - ) - - expect(getByText('Ethereum')).toBeTruthy() - expect(getByTestId('dropdown-arrow')).toBeTruthy() - expect(getByText('Here is my leftNode')).toBeTruthy() - expect(queryByText('It should not be rendered')).not.toBeTruthy() - }) - - it('should open and close the dropdown container', async () => { - const user = userEvent.setup() - const container = render( - Here is my leftNode}> - my custom child component - , - ) - const dismissSpy = jest.fn() - - jest.spyOn(hooks, 'useBottomSheetModal').mockImplementation(() => ({ dismiss: dismissSpy, dismissAll: jest.fn() })) - - expect(container.queryByText('my custom child component')).not.toBeVisible() - - await user.press(container.getByTestId('dropdown-label-view')) - - expect(container.getByText('my custom child component')).toBeVisible() - - await user.press(container.getByTestId('dropdown-backdrop')) - - expect(dismissSpy).toHaveBeenCalled() - }) -}) diff --git a/apps/mobile/src/components/Dropdown/Dropdown.tsx b/apps/mobile/src/components/Dropdown/Dropdown.tsx deleted file mode 100644 index 9ef4ec31a9..0000000000 --- a/apps/mobile/src/components/Dropdown/Dropdown.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import React, { useCallback, useMemo, useRef } from 'react' -import { GetThemeValueForKey, H5, ScrollView, Text, View } from 'tamagui' -import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' -import { BottomSheetFooterProps, BottomSheetModal, BottomSheetModalProps, BottomSheetView } from '@gorhom/bottom-sheet' -import { StyleSheet } from 'react-native' -import { BackdropComponent, BackgroundComponent } from './sheetComponents' - -import DraggableFlatList, { DragEndParams, RenderItemParams, ScaleDecorator } from 'react-native-draggable-flatlist' - -interface DropdownProps { - label: string - leftNode?: React.ReactNode - children?: React.ReactNode - dropdownTitle?: string - sortable?: boolean - onDragEnd?: (params: DragEndParams) => void - items?: T[] - snapPoints?: BottomSheetModalProps['snapPoints'] - labelProps?: { - fontSize?: '$4' | '$5' | GetThemeValueForKey<'fontSize'> - fontWeight: 400 | 500 | 600 - } - actions?: React.ReactNode - footerComponent?: React.FC - renderItem?: React.FC<{ item: T; isDragging?: boolean; drag?: () => void; onClose: () => void }> - keyExtractor?: ({ item, index }: { item: T; index: number }) => string -} - -const defaultLabelProps = { - fontSize: '$4', - fontWeight: 400, -} as const - -export function Dropdown({ - label, - leftNode, - children, - dropdownTitle, - sortable, - items, - snapPoints = [600, '90%'], - keyExtractor, - actions, - renderItem: Render, - labelProps = defaultLabelProps, - footerComponent, - onDragEnd, -}: DropdownProps) { - const bottomSheetModalRef = useRef(null) - const handlePresentModalPress = useCallback(() => { - bottomSheetModalRef.current?.present() - }, []) - - const handleModalClose = useCallback(() => { - bottomSheetModalRef.current?.dismiss() - }, []) - - const hasCustomItems = items && Render - const isSortable = items && sortable - - const renderItem = useCallback( - ({ item, drag, isActive }: RenderItemParams) => { - return ( - - {Render && } - - ) - }, - [handleModalClose, Render], - ) - - const renderDropdownHeader = useMemo( - () => ( - -
{dropdownTitle}
- - {actions && ( - - {actions} - - )} -
- ), - [dropdownTitle, actions], - ) - - return ( - <> - - {leftNode} - - - {label} - - - - - - - {!isSortable && dropdownTitle && renderDropdownHeader} - - - {isSortable ? ( - - data={items} - containerStyle={{ height: '100%' }} - ListHeaderComponent={dropdownTitle ? renderDropdownHeader : undefined} - onDragEnd={onDragEnd} - keyExtractor={(item, index) => (keyExtractor ? keyExtractor({ item, index }) : index.toString())} - renderItem={renderItem} - /> - ) : ( - - - - {hasCustomItems - ? items.map((item, index) => ( - - )) - : children} - - - - )} - - - - ) -} - -const styles = StyleSheet.create({ - contentContainer: { - justifyContent: 'space-around', - }, -}) diff --git a/apps/mobile/src/components/Dropdown/DropdownLabel.tsx b/apps/mobile/src/components/Dropdown/DropdownLabel.tsx new file mode 100644 index 0000000000..dd6226f90f --- /dev/null +++ b/apps/mobile/src/components/Dropdown/DropdownLabel.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { GetThemeValueForKey, Text, View } from 'tamagui' +import { Pressable } from 'react-native-gesture-handler' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' + +type DropdownLabelProps = { + label: string + leftNode?: React.ReactNode + labelProps?: { + fontSize?: '$4' | '$5' | GetThemeValueForKey<'fontSize'> + fontWeight: 400 | 500 | 600 + } + displayDropDownIcon?: boolean + onPress?: () => void + hitSlop?: number +} +const defaultLabelProps = { + fontSize: '$4', + fontWeight: 400, +} as const + +export const DropdownLabel = ({ + label, + displayDropDownIcon = true, + leftNode, + onPress, + labelProps = defaultLabelProps, + hitSlop = 0, +}: DropdownLabelProps) => { + return ( + + + {leftNode} + + + + {label} + + + + {displayDropDownIcon && ( + + + + )} + + + ) +} diff --git a/apps/mobile/src/components/Dropdown/index.ts b/apps/mobile/src/components/Dropdown/index.ts index 80f50ef94c..d81db89e78 100644 --- a/apps/mobile/src/components/Dropdown/index.ts +++ b/apps/mobile/src/components/Dropdown/index.ts @@ -1,2 +1 @@ -import { Dropdown } from './Dropdown' -export { Dropdown } +export { DropdownLabel } from './DropdownLabel' diff --git a/apps/mobile/src/components/Dropdown/sheetComponents.tsx b/apps/mobile/src/components/Dropdown/sheetComponents.tsx index 8710158f35..93f18e7921 100644 --- a/apps/mobile/src/components/Dropdown/sheetComponents.tsx +++ b/apps/mobile/src/components/Dropdown/sheetComponents.tsx @@ -1,8 +1,9 @@ import React from 'react' import { View as RCView, StyleSheet } from 'react-native' import { View } from 'tamagui' -import { BottomSheetBackgroundProps, useBottomSheetModal } from '@gorhom/bottom-sheet' +import { BottomSheetBackgroundProps, useBottomSheet } from '@gorhom/bottom-sheet' import { BlurView } from 'expo-blur' +import { useRouter } from 'expo-router' const BackgroundComponent = React.memo(({ style }: BottomSheetBackgroundProps) => { return ( @@ -12,10 +13,15 @@ const BackgroundComponent = React.memo(({ style }: BottomSheetBackgroundProps) = ) }) -const BackdropComponent = React.memo(() => { - const { dismiss } = useBottomSheetModal() - - const handleClose = () => dismiss() +const BackdropComponent = React.memo(({ shouldNavigateBack = true }: { shouldNavigateBack?: boolean }) => { + const { close } = useBottomSheet() + const router = useRouter() + const handleClose = () => { + close() + if (shouldNavigateBack) { + router.back() + } + } return ( { width="100%" height="100%" > - + ) }) diff --git a/apps/mobile/src/components/EncodedData/EncodedData.tsx b/apps/mobile/src/components/EncodedData/EncodedData.tsx new file mode 100644 index 0000000000..495c68ee82 --- /dev/null +++ b/apps/mobile/src/components/EncodedData/EncodedData.tsx @@ -0,0 +1,23 @@ +import React, { useReducer } from 'react' +import { Text } from 'tamagui' +import { TouchableOpacity } from 'react-native' + +interface EncodedDataProps { + data: string +} + +export function EncodedData({ data }: EncodedDataProps) { + const [truncated, toggleTruncate] = useReducer((state: boolean) => !state, true) + + return ( + <> + + {data} + + + + {truncated ? 'Show more' : 'Show less'} + + + ) +} diff --git a/apps/mobile/src/components/EncodedData/index.ts b/apps/mobile/src/components/EncodedData/index.ts new file mode 100644 index 0000000000..16d3fd1163 --- /dev/null +++ b/apps/mobile/src/components/EncodedData/index.ts @@ -0,0 +1 @@ +export { EncodedData } from './EncodedData' diff --git a/apps/mobile/src/components/EthAddress/ETHAddress.tsx b/apps/mobile/src/components/EthAddress/ETHAddress.tsx index 08cecb995e..d24ca4e3a5 100644 --- a/apps/mobile/src/components/EthAddress/ETHAddress.tsx +++ b/apps/mobile/src/components/EthAddress/ETHAddress.tsx @@ -1,20 +1,27 @@ import { Address } from '@/src/types/address' import { shortenAddress } from '@/src/utils/formatters' -import { Text, type TextProps, View } from 'tamagui' +import { GetThemeValueForKey, Text, type TextProps, View } from 'tamagui' import { CopyButton } from '@/src/components/CopyButton' +import { OpaqueColorValue } from 'react-native' type Props = { address: Address copy?: boolean textProps?: Partial + copyProps?: Partial<{ + color: 'unset' | GetThemeValueForKey<'color'> | OpaqueColorValue | undefined + size: number + }> } -export const EthAddress = ({ address, copy, textProps }: Props) => { +export const EthAddress = ({ address, copy, textProps, copyProps }: Props) => { return ( - + {shortenAddress(address)} - {copy && } + {copy && ( + + )} ) } diff --git a/apps/mobile/src/components/Fiat/Fiat.test.tsx b/apps/mobile/src/components/Fiat/Fiat.test.tsx index f6c105a93d..e36afc13e3 100644 --- a/apps/mobile/src/components/Fiat/Fiat.test.tsx +++ b/apps/mobile/src/components/Fiat/Fiat.test.tsx @@ -2,11 +2,11 @@ import { render } from '@/src/tests/test-utils' import { Fiat } from '.' describe('Fiat', () => { - it('should render the default markup', () => { - const { getByText } = render() + it('should render the formatted value correctly', () => { + const container = render() + const fiatBalanceDisplay = container.getByTestId('fiat-balance-display') - expect(getByText('$')).toBeTruthy() - expect(getByText('215,531')).toBeTruthy() - expect(getByText('.65')).toBeTruthy() + expect(fiatBalanceDisplay).toBeVisible() + expect(fiatBalanceDisplay).toHaveTextContent('$ 215.53K') }) }) diff --git a/apps/mobile/src/components/Fiat/Fiat.tsx b/apps/mobile/src/components/Fiat/Fiat.tsx index ff233da340..c9a652c8ef 100644 --- a/apps/mobile/src/components/Fiat/Fiat.tsx +++ b/apps/mobile/src/components/Fiat/Fiat.tsx @@ -1,22 +1,45 @@ -import React from 'react' -import { H1, H3, View } from 'tamagui' +import React, { useMemo } from 'react' +import { H1, H2, View, XStack } from 'tamagui' +import { formatCurrency, formatCurrencyPrecise } from '@safe-global/utils/utils/formatNumber' interface FiatProps { - baseAmount: string + value: string + currency: string + maxLength?: number + precise?: boolean } -export const Fiat = ({ baseAmount }: FiatProps) => { - const amount = baseAmount.split('.') +export const Fiat = ({ value, currency, maxLength, precise }: FiatProps) => { + const fiat = useMemo(() => { + return formatCurrency(value, currency, maxLength) + }, [value, currency, maxLength]) - return ( - -

$

-

{amount[0]}

+ const preciseFiat = useMemo(() => { + return formatCurrencyPrecise(value, currency) + }, [value, currency]) + + const [symbol, whole, decimals, endCurrency] = useMemo(() => { + const match = (preciseFiat ?? '').match(/(\D+)?(.+)(\D\d+)(\D+)?$/) + return match ? match.slice(1) : ['', '', preciseFiat, '', ''] + }, [preciseFiat]) - {amount[1] && ( -

- .{amount[1].slice(0, 2)} -

+ return ( + + {precise ? ( + +

+ {symbol} +

+

{whole}

+ {decimals && ( +

+ {decimals} +

+ )} +

{endCurrency}

+
+ ) : ( +

{fiat}

)}
) diff --git a/apps/mobile/src/components/FloatingContainer/FloatingContainer.tsx b/apps/mobile/src/components/FloatingContainer/FloatingContainer.tsx index 40c4481c75..4294040cc3 100644 --- a/apps/mobile/src/components/FloatingContainer/FloatingContainer.tsx +++ b/apps/mobile/src/components/FloatingContainer/FloatingContainer.tsx @@ -2,7 +2,7 @@ import { Layout } from '@/src/store/constants' import React, { FC, useMemo } from 'react' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { KeyboardAvoidingView, KeyboardAvoidingViewProps, Platform, StyleSheet, View } from 'react-native' +import { KeyboardAvoidingView, KeyboardAvoidingViewProps, Platform, StyleSheet, View, ViewStyle } from 'react-native' interface FloatingContainerProps { children: React.ReactNode @@ -11,6 +11,7 @@ interface FloatingContainerProps { keyboardAvoidEnabled?: boolean onLayout?: KeyboardAvoidingViewProps['onLayout'] testID?: string + style?: ViewStyle } export const FloatingContainer: FC = ({ @@ -20,6 +21,7 @@ export const FloatingContainer: FC = ({ keyboardAvoidEnabled, onLayout, testID, + style, }: FloatingContainerProps) => { const bottomInset = useSafeAreaInsets().bottom const deviceBottom = Layout.isSmallDevice ? 10 : 20 @@ -41,14 +43,14 @@ export const FloatingContainer: FC = ({ style={[styles.floatingContainer, { paddingBottom: bottomPadding }]} onLayout={onLayout} > - {children} + {children} ) } const styles = StyleSheet.create({ floatingContainer: { - position: 'fixed', + position: 'absolute', bottom: -40, width: '100%', zIndex: 1, diff --git a/apps/mobile/src/components/GradientText/GradientText.tsx b/apps/mobile/src/components/GradientText/GradientText.tsx new file mode 100644 index 0000000000..ea71e1593f --- /dev/null +++ b/apps/mobile/src/components/GradientText/GradientText.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { Text, TextProps } from 'tamagui' +import { LinearGradient } from 'expo-linear-gradient' +// @ts-ignore - Type declarations not available for this library +import MaskedView from '@react-native-masked-view/masked-view' + +interface GradientTextProps extends TextProps { + colors: [string, string] + locations?: [number, number] + gradientStart?: { x: number; y: number } + gradientEnd?: { x: number; y: number } +} + +export const GradientText = ({ + children, + colors, + locations = [0, 1], + gradientStart = { x: 0, y: 0 }, + gradientEnd = { x: 1, y: 0 }, + ...textProps +}: GradientTextProps) => { + return ( + + {children} + + } + > + + + {children} + + + + ) +} diff --git a/apps/mobile/src/components/GradientText/index.ts b/apps/mobile/src/components/GradientText/index.ts new file mode 100644 index 0000000000..10c85d4c33 --- /dev/null +++ b/apps/mobile/src/components/GradientText/index.ts @@ -0,0 +1 @@ +export { GradientText } from './GradientText' diff --git a/apps/mobile/src/components/Identicon/Identicon.test.tsx b/apps/mobile/src/components/Identicon/Identicon.test.tsx index 73aa12e910..617ad03ba3 100644 --- a/apps/mobile/src/components/Identicon/Identicon.test.tsx +++ b/apps/mobile/src/components/Identicon/Identicon.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { render } from '@testing-library/react-native' +import { render } from '@/src/tests/test-utils' import { Identicon } from './index' describe('Identicon', () => { @@ -9,23 +9,30 @@ describe('Identicon', () => { expect(image).toBeTruthy() }) - it('applies rounded style when rounded prop is true', () => { - const { getByTestId } = render() - const image = getByTestId('identicon-image') + it('applies rounded style by default', () => { + const { getByTestId } = render() + const image = getByTestId('identicon-image-container') expect(image.props.style.borderRadius).toBe('50%') }) + it('applies not-rounded style when rounded false', () => { + const { getByTestId } = render() + const image = getByTestId('identicon-image-container') + expect(image.props.style.borderRadius).toBe(0) + }) + it('applies default size when size prop is not provided', () => { const { getByTestId } = render() const image = getByTestId('identicon-image') - expect(image.props.style.width).toBe(56) - expect(image.props.style.height).toBe(56) + expect(image.props.width).toBe(56) + expect(image.props.height).toBe(56) }) it('applies custom size when size prop is provided', () => { const { getByTestId } = render() const image = getByTestId('identicon-image') - expect(image.props.style.width).toBe(100) - expect(image.props.style.height).toBe(100) + + expect(image.props.width).toBe(100) + expect(image.props.height).toBe(100) }) }) diff --git a/apps/mobile/src/components/Identicon/Identicon.tsx b/apps/mobile/src/components/Identicon/Identicon.tsx index 1d2cdd2494..5218525a29 100644 --- a/apps/mobile/src/components/Identicon/Identicon.tsx +++ b/apps/mobile/src/components/Identicon/Identicon.tsx @@ -1,7 +1,7 @@ -import { blo } from 'blo' -import { Image } from 'expo-image' +import { bloSvg } from 'blo' import { type Address } from '@/src/types/address' import { View } from 'tamagui' +import { SvgXml } from 'react-native-svg' type Props = { address: Address @@ -10,18 +10,14 @@ type Props = { } const DEFAULT_SIZE = 56 -export const Identicon = ({ address, rounded, size }: Props) => { - const style = { - borderRadius: rounded ? '50%' : 0, - width: size ? size : DEFAULT_SIZE, - height: size ? size : DEFAULT_SIZE, - } +export const Identicon = ({ address, rounded = true, size }: Props) => { + size = size ? size : DEFAULT_SIZE - const blockie = blo(address) + const blockieSvg = bloSvg(address) return ( - - + + ) } diff --git a/apps/mobile/src/components/InfoSheet/InfoSheet.tsx b/apps/mobile/src/components/InfoSheet/InfoSheet.tsx new file mode 100644 index 0000000000..a20ea3a861 --- /dev/null +++ b/apps/mobile/src/components/InfoSheet/InfoSheet.tsx @@ -0,0 +1,67 @@ +import React, { useCallback, useRef } from 'react' +import { BottomSheetScrollView } from '@gorhom/bottom-sheet' +import { SafeFontIcon } from '../SafeFontIcon' +import { BottomSheetModal, TouchableOpacity } from '@gorhom/bottom-sheet' +import { getVariable, Text, View, useTheme, H4, YStack } from 'tamagui' +import { BackdropComponent, BackgroundComponent } from '@/src/components/Dropdown/sheetComponents' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { Badge } from '@/src/components/Badge' +import { Platform } from 'react-native' +import { FullWindowOverlay } from 'react-native-screens' + +export const InfoSheet = ({ + info, + title, + displayIcon = true, + children, +}: { + info: string + title?: string + displayIcon?: boolean + children?: string | React.ReactElement +}) => { + const bottomSheetModalRef = useRef(null) + const insets = useSafeAreaInsets() + const theme = useTheme() + + // callbacks + const handlePresentModalPress = useCallback(() => { + bottomSheetModalRef.current?.present() + }, []) + + return ( + <> + + {!children && } + {children} + + + } + topInset={insets.top} + enableDynamicSizing + handleIndicatorStyle={{ backgroundColor: getVariable(theme.borderMain) }} + > + + + {displayIcon && ( + } + /> + )} + + {title &&

{title}

} + {info} +
+
+
+
+ + ) +} diff --git a/apps/mobile/src/components/InfoSheet/index.ts b/apps/mobile/src/components/InfoSheet/index.ts new file mode 100644 index 0000000000..5260e812b7 --- /dev/null +++ b/apps/mobile/src/components/InfoSheet/index.ts @@ -0,0 +1 @@ +export { InfoSheet } from './InfoSheet' diff --git a/apps/mobile/src/components/LoadableSwitch/LoadableSwitch.tsx b/apps/mobile/src/components/LoadableSwitch/LoadableSwitch.tsx new file mode 100644 index 0000000000..04bbadf3e5 --- /dev/null +++ b/apps/mobile/src/components/LoadableSwitch/LoadableSwitch.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import { Switch, StyleSheet } from 'react-native' +import { getTokenValue, useTheme, View } from 'tamagui' +import { Loader } from '../Loader' + +interface LoadableSwitchProps { + isLoading?: boolean + value: boolean + onChange: () => void + testID?: string + trackColor?: { + true: string + false?: string + } +} + +export const LoadableSwitch: React.FC = ({ + isLoading = false, + value, + onChange, + testID, + trackColor = { true: '$primary' }, +}) => { + const theme = useTheme() + + const resolveThemeColor = (color: string) => { + if (color.startsWith('$')) { + const themeKey = color.slice(1) // remove the '$' prefix + const themeValue = theme[themeKey as keyof typeof theme] + return themeValue?.get() || getTokenValue(color as unknown as 'auto') || color + } + return color + } + + return ( + + {isLoading && ( + + + + )} + + + ) +} + +const styles = StyleSheet.create({ + loaderContainer: { + width: 51, + height: 31, + position: 'absolute', + top: 0, + left: 0, + zIndex: 10, + justifyContent: 'center', + alignItems: 'center', + }, +}) diff --git a/apps/mobile/src/components/LoadableSwitch/index.ts b/apps/mobile/src/components/LoadableSwitch/index.ts new file mode 100644 index 0000000000..a8c64e200b --- /dev/null +++ b/apps/mobile/src/components/LoadableSwitch/index.ts @@ -0,0 +1,3 @@ +import { LoadableSwitch } from './LoadableSwitch' + +export { LoadableSwitch } diff --git a/apps/mobile/src/components/Loader/Loader.tsx b/apps/mobile/src/components/Loader/Loader.tsx new file mode 100644 index 0000000000..7ec47f2ac7 --- /dev/null +++ b/apps/mobile/src/components/Loader/Loader.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { View } from 'tamagui' + +import { CircleSnail, CircleSnailPropTypes } from 'react-native-progress' +interface LoaderProps { + size?: number + color?: string + props?: CircleSnailPropTypes +} + +export function Loader({ size = 64, color = '#12FF80', ...props }: LoaderProps) { + return ( + + + + ) +} diff --git a/apps/mobile/src/components/Loader/index.ts b/apps/mobile/src/components/Loader/index.ts new file mode 100644 index 0000000000..7270b48271 --- /dev/null +++ b/apps/mobile/src/components/Loader/index.ts @@ -0,0 +1 @@ +export { Loader } from './Loader' diff --git a/apps/mobile/src/components/LoadingScreen/LoadingScreen.tsx b/apps/mobile/src/components/LoadingScreen/LoadingScreen.tsx new file mode 100644 index 0000000000..62674c8390 --- /dev/null +++ b/apps/mobile/src/components/LoadingScreen/LoadingScreen.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { H4, View } from 'tamagui' + +import { Loader } from '@/src/components/Loader' + +interface LoadingScreenProps { + title: string + description: string +} + +export function LoadingScreen({ title, description }: LoadingScreenProps) { + return ( + + +

+ {title} +

+

+ {description} +

+
+ ) +} diff --git a/apps/mobile/src/components/LoadingScreen/index.ts b/apps/mobile/src/components/LoadingScreen/index.ts new file mode 100644 index 0000000000..4fc1fce95b --- /dev/null +++ b/apps/mobile/src/components/LoadingScreen/index.ts @@ -0,0 +1 @@ +export { LoadingScreen } from './LoadingScreen' diff --git a/apps/mobile/src/components/Logo/Logo.test.tsx b/apps/mobile/src/components/Logo/Logo.test.tsx index 685288f03a..d80ba63782 100644 --- a/apps/mobile/src/components/Logo/Logo.test.tsx +++ b/apps/mobile/src/components/Logo/Logo.test.tsx @@ -14,4 +14,46 @@ describe('Logo', () => { expect(container.queryByTestId('logo-image')).not.toBeTruthy() expect(container.queryByTestId('logo-fallback-icon')).toBeTruthy() }) + + it('should show fallback when logoUri is not provided', () => { + const container = render() + + expect(container.queryByTestId('logo-image')).not.toBeTruthy() + expect(container.queryByTestId('logo-fallback-icon')).toBeTruthy() + }) + + it('should show fallback when logoUri is empty string', () => { + const container = render() + + expect(container.queryByTestId('logo-image')).not.toBeTruthy() + expect(container.queryByTestId('logo-fallback-icon')).toBeTruthy() + }) + + it('should use custom fallback icon when specified', () => { + const container = render() + + const fallbackIcon = container.getByTestId('logo-fallback-icon') + expect(fallbackIcon).toBeTruthy() + }) + + it('should render with custom size', () => { + const container = render() + + // Initially, only fallback should be visible until image loads + expect(container.queryByTestId('logo-fallback-icon')).toBeTruthy() + }) + + it('should render with badge content when provided', () => { + const badgeContent = Badge + const container = render() + + // Component should render without errors + expect(container.queryByTestId('logo-fallback-icon')).toBeTruthy() + }) + + it('should handle different image backgrounds', () => { + const container = render() + + expect(container.queryByTestId('logo-fallback-icon')).toBeTruthy() + }) }) diff --git a/apps/mobile/src/components/Logo/Logo.tsx b/apps/mobile/src/components/Logo/Logo.tsx index 15bc1a4274..aede811b68 100644 --- a/apps/mobile/src/components/Logo/Logo.tsx +++ b/apps/mobile/src/components/Logo/Logo.tsx @@ -1,13 +1,23 @@ import React from 'react' import { Avatar, Theme, View } from 'tamagui' import { IconProps, SafeFontIcon } from '../SafeFontIcon/SafeFontIcon' +import { Badge } from '../Badge/Badge' +import { badgeTheme } from '../Badge/theme' +import useValidLogoUri from '@/src/hooks/useValidLogoUri' + +type BadgeThemeKeys = keyof typeof badgeTheme +type ExtractAfterUnderscore = T extends `${string}_${infer Rest}` ? Rest : never +export type BadgeThemeTypes = ExtractAfterUnderscore interface LogoProps { logoUri?: string | null accessibilityLabel?: string fallbackIcon?: IconProps['name'] + fallbackContent?: React.ReactNode imageBackground?: string size?: string + badgeContent?: React.ReactElement + badgeThemeName?: BadgeThemeTypes } export function Logo({ @@ -16,25 +26,48 @@ export function Logo({ size = '$10', imageBackground = '$color', fallbackIcon = 'nft', + fallbackContent, + badgeContent, + badgeThemeName = 'badge_background', }: LogoProps) { + const validUri = useValidLogoUri(logoUri) + return ( - - {logoUri && ( - - )} + + + {badgeContent && ( + + )} + + + + {validUri && ( + + )} - - - - - - + + {fallbackContent || ( + + + + )} + + +
) } diff --git a/apps/mobile/src/components/NetworkBadge/NetworkBadge.stories.tsx b/apps/mobile/src/components/NetworkBadge/NetworkBadge.stories.tsx new file mode 100644 index 0000000000..a9006bdd33 --- /dev/null +++ b/apps/mobile/src/components/NetworkBadge/NetworkBadge.stories.tsx @@ -0,0 +1,46 @@ +import type { StoryObj, Meta } from '@storybook/react' +import { NetworkBadge } from '@/src/components/NetworkBadge/NetworkBadge' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { XStack, YStack } from 'tamagui' + +const meta: Meta = { + title: 'NetworkBadge', + component: NetworkBadge, + args: { + network: { + chainName: 'Ethereum', + chainLogoUri: 'https://example.com/ethereum-logo.png', + } as Chain, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + network: { + chainName: 'Ethereum', + chainLogoUri: 'https://example.com/ethereum-logo.png', + } as Chain, + }, +} + +export const Multiple: Story = { + args: { + network: { + chainName: 'Ethereum', + chainLogoUri: 'https://example.com/ethereum-logo.png', + } as Chain, + }, + render: (args) => ( + + + + + + + + ), +} diff --git a/apps/mobile/src/components/NetworkBadge/NetworkBadge.tsx b/apps/mobile/src/components/NetworkBadge/NetworkBadge.tsx new file mode 100644 index 0000000000..8771408044 --- /dev/null +++ b/apps/mobile/src/components/NetworkBadge/NetworkBadge.tsx @@ -0,0 +1,27 @@ +import { Text, Theme, View } from 'tamagui' +import { Logo } from '@/src/components/Logo' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' + +type Props = { + network: Chain +} + +export const NetworkBadge = ({ network }: Props) => { + return ( + + + + {network.chainName} + + + ) +} diff --git a/apps/mobile/src/components/NetworkBadge/index.ts b/apps/mobile/src/components/NetworkBadge/index.ts new file mode 100644 index 0000000000..6950a0d942 --- /dev/null +++ b/apps/mobile/src/components/NetworkBadge/index.ts @@ -0,0 +1 @@ +export { NetworkBadge } from './NetworkBadge' diff --git a/apps/mobile/src/components/NetworkBadge/theme.ts b/apps/mobile/src/components/NetworkBadge/theme.ts new file mode 100644 index 0000000000..23516251f4 --- /dev/null +++ b/apps/mobile/src/components/NetworkBadge/theme.ts @@ -0,0 +1,12 @@ +import { tokens } from '@/src/theme/tokens' + +export const badgeTheme = { + light_network_badge: { + background: tokens.color.backgroundSecondaryLight, + color: tokens.color.backgroundMainDark, + }, + dark_network_badge: { + color: tokens.color.textPrimaryDark, + background: tokens.color.backgroundSecondaryDark, + }, +} diff --git a/apps/mobile/src/components/OptIn/OptIn.tsx b/apps/mobile/src/components/OptIn/OptIn.tsx index 3c98f6483a..7adbd0df2b 100644 --- a/apps/mobile/src/components/OptIn/OptIn.tsx +++ b/apps/mobile/src/components/OptIn/OptIn.tsx @@ -1,9 +1,11 @@ import React from 'react' -import { ImageSourcePropType, StyleSheet } from 'react-native' -import { View, Image, Text } from 'tamagui' +import { ColorSchemeName, ImageSourcePropType, StyleSheet } from 'react-native' +import { H2, Image, Text, getTokenValue, View } from 'tamagui' import { SafeButton } from '@/src/components/SafeButton' import { WINDOW_HEIGHT } from '@/src/store/constants' -import { FloatingContainer } from '../FloatingContainer' +import { Loader } from '../Loader' +import { SafeFontIcon } from '../SafeFontIcon' +import { Container } from '../Container' interface OptInProps { title: string @@ -20,44 +22,85 @@ interface OptInProps { } testID?: string isVisible?: boolean + isLoading?: boolean + colorScheme: ColorSchemeName + infoMessage?: string } export const OptIn: React.FC = React.memo( - ({ testID, kicker, title, description, image, ctaButton, secondaryButton, isVisible }: OptInProps) => { + ({ + testID, + kicker, + title, + description, + image, + ctaButton, + secondaryButton, + isVisible, + isLoading, + colorScheme, + infoMessage, + }: OptInProps) => { if (!isVisible) { return } return ( - - {kicker && ( - - {kicker} - - )} - - {title} - - {description && ( - - {description} - - )} - {image && } + + + + {kicker && ( + + {kicker} + + )} +

+ {title} +

+ {description && ( + + {description} + + )} + {infoMessage && ( + + + + {infoMessage} + + + )} +
+ {image && } +
- - + + + {!isLoading ? ( + ctaButton.label + ) : ( + + )} + {secondaryButton && ( - + + {secondaryButton.label} + )} - +
) }, @@ -66,10 +109,13 @@ export const OptIn: React.FC = React.memo( const styles = StyleSheet.create({ wrapper: { flex: 1, + gap: getTokenValue('$4', 'space'), + justifyContent: 'space-between', }, image: { width: '100%', height: Math.abs(WINDOW_HEIGHT * 0.42), + marginBottom: 40, }, }) diff --git a/apps/mobile/src/components/ProposalBadge/ProposalBadge.stories.tsx b/apps/mobile/src/components/ProposalBadge/ProposalBadge.stories.tsx new file mode 100644 index 0000000000..cd6219131a --- /dev/null +++ b/apps/mobile/src/components/ProposalBadge/ProposalBadge.stories.tsx @@ -0,0 +1,15 @@ +import type { StoryObj, Meta } from '@storybook/react' +import { ProposalBadge } from '@/src/components/ProposalBadge/ProposalBadge' + +const meta: Meta = { + title: 'ProposalBadge', + component: ProposalBadge, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: {}, +} diff --git a/apps/mobile/src/components/ProposalBadge/ProposalBadge.tsx b/apps/mobile/src/components/ProposalBadge/ProposalBadge.tsx new file mode 100644 index 0000000000..95aeb6a9fe --- /dev/null +++ b/apps/mobile/src/components/ProposalBadge/ProposalBadge.tsx @@ -0,0 +1,21 @@ +import { Badge } from '@/src/components/Badge' +import { Text, View } from 'tamagui' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' + +export const ProposalBadge = () => { + return ( + + + + + Proposal + +
+ } + themeName="badge_background" + /> + ) +} diff --git a/apps/mobile/src/components/ProposalBadge/index.tsx b/apps/mobile/src/components/ProposalBadge/index.tsx new file mode 100644 index 0000000000..0d53de1072 --- /dev/null +++ b/apps/mobile/src/components/ProposalBadge/index.tsx @@ -0,0 +1 @@ +export { ProposalBadge } from './ProposalBadge' diff --git a/apps/mobile/src/components/SVGs/SafeWalletLogo.tsx b/apps/mobile/src/components/SVGs/SafeWalletLogo.tsx new file mode 100644 index 0000000000..fe9ac1def3 --- /dev/null +++ b/apps/mobile/src/components/SVGs/SafeWalletLogo.tsx @@ -0,0 +1,55 @@ +import * as React from 'react' +import Svg, { SvgProps, G, Path, Defs, ClipPath, Rect } from 'react-native-svg' + +export const SafeWalletLogo = (props: SvgProps) => ( + + + + + + + + + + + + + + + + + + + + + +) + +export default SafeWalletLogo diff --git a/apps/mobile/src/components/SafeAccountInput/hooks/useImportSafe.ts b/apps/mobile/src/components/SafeAccountInput/hooks/useImportSafe.ts new file mode 100644 index 0000000000..fdd3b7f000 --- /dev/null +++ b/apps/mobile/src/components/SafeAccountInput/hooks/useImportSafe.ts @@ -0,0 +1,90 @@ +import { useEffect, useCallback } from 'react' +import { useFormContext } from 'react-hook-form' +import { makeSafeId } from '@/src/utils/formatters' +import { isValidAddress } from '@safe-global/utils/utils/validation' +import { parsePrefixedAddress } from '@safe-global/utils/utils/addresses' +import { useAppSelector } from '@/src/store/hooks' +import { selectAllChainsIds } from '@/src/store/chains' +import { useLazySafesGetOverviewForManyQuery } from '@safe-global/store/gateway/safes' +import { FormValues } from '@/src/features/ImportReadOnly/types' +import debounce from 'lodash/debounce' +import { selectCurrency } from '@/src/store/settingsSlice' + +const NO_SAFE_DEPLOYMENT_ERROR = 'No Safe deployment found for this address' + +export const useImportSafe = () => { + const chainIds = useAppSelector(selectAllChainsIds) + const currency = useAppSelector(selectCurrency) + const { + watch, + getFieldState, + setValue, + setError, + clearErrors, + getValues, + trigger: triggerInput, + formState: { isValid }, + } = useFormContext() + const [trigger, result] = useLazySafesGetOverviewForManyQuery() + + const inputAddress = watch('safeAddress') + + const onSafeAddressChange = useCallback( + debounce(() => { + const { address } = parsePrefixedAddress(inputAddress) + const isValid = isValidAddress(address) + + if (isValid) { + trigger({ + safes: chainIds.map((chainId: string) => makeSafeId(chainId, address)), + currency, + trusted: true, + excludeSpam: true, + }) + } else { + setValue('importedSafeResult', undefined) + } + }, 200), + [chainIds, trigger, inputAddress, setValue], + ) + + useEffect(() => { + onSafeAddressChange() + return () => { + onSafeAddressChange.cancel() + } + }, [onSafeAddressChange]) + + useEffect(() => { + const onResultChange = () => { + setValue('importedSafeResult', { + data: result?.data, + isFetching: result?.isFetching, + error: result?.error, + }) + + const addressState = getFieldState('safeAddress') + + if (!addressState.isDirty) { + return + } + + if (result?.data?.length === 0 && !result?.isLoading) { + setError('safeAddress', { message: NO_SAFE_DEPLOYMENT_ERROR }) + } else if (addressState.invalid) { + triggerInput('name') + clearErrors('safeAddress') + } + } + + onResultChange() + }, [result, setValue, setError, clearErrors, triggerInput]) + + useEffect(() => { + const importedSafeResult = getValues('importedSafeResult') + + if (importedSafeResult?.data?.length === 0 && !importedSafeResult?.isFetching) { + setError('safeAddress', { message: NO_SAFE_DEPLOYMENT_ERROR }) + } + }, [isValid, getValues]) +} diff --git a/apps/mobile/src/components/SafeAccountInput/index.tsx b/apps/mobile/src/components/SafeAccountInput/index.tsx new file mode 100644 index 0000000000..abc71cc224 --- /dev/null +++ b/apps/mobile/src/components/SafeAccountInput/index.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { SafeInput } from '../SafeInput' +import { useFormContext } from 'react-hook-form' +import { Controller } from 'react-hook-form' +import { FormValues } from '@/src/features/ImportReadOnly/types' +import { parsePrefixedAddress } from '@safe-global/utils/utils/addresses' +import { Identicon } from '../Identicon' +import { View } from 'tamagui' +import { SafeFontIcon } from '../SafeFontIcon' +import { useImportSafe } from './hooks/useImportSafe' +function SafeAccountInput() { + const { + control, + formState: { errors, dirtyFields }, + watch, + } = useFormContext() + + useImportSafe() + + const result = watch('importedSafeResult') + + return ( + { + const addressWithoutPrefix = parsePrefixedAddress(value).address + return ( + : null} + right={ + result?.data?.length && !errors.safeAddress && !result?.isFetching ? ( + + ) : ( + + ) + } + /> + ) + }} + /> + ) +} + +export default SafeAccountInput diff --git a/apps/mobile/src/components/SafeAvatar/SafeAvatar.stories.tsx b/apps/mobile/src/components/SafeAvatar/SafeAvatar.stories.tsx new file mode 100644 index 0000000000..0d56a213fe --- /dev/null +++ b/apps/mobile/src/components/SafeAvatar/SafeAvatar.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react' +import React from 'react' +import { SafeAvatar } from '@/src/components/SafeAvatar/SafeAvatar' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' + +const meta: Meta = { + title: 'SafeAvatar', + component: SafeAvatar, + argTypes: { + src: { control: 'text' }, + size: { control: 'text' }, + label: { control: 'text' }, + delayMs: { control: 'number' }, + fallbackBackgroundColor: { control: 'color' }, + // fallbackIcon is a ReactNode, so we disable controls + fallbackIcon: { control: false }, + }, +} + +export default meta + +type Story = StoryObj + +export const Loaded: Story = { + args: { + src: 'https://safe-wallet-web.dev.5afe.dev/favicons/favicon.ico', + size: '$10', + label: 'Safe Avatar', + }, +} + +export const Fallback: Story = { + args: { + src: '', + size: '$10', + label: 'Fallback Avatar', + fallbackBackgroundColor: '$gray4', + fallbackIcon: , + }, +} diff --git a/apps/mobile/src/components/SafeAvatar/SafeAvatar.tsx b/apps/mobile/src/components/SafeAvatar/SafeAvatar.tsx new file mode 100644 index 0000000000..9efeabb92c --- /dev/null +++ b/apps/mobile/src/components/SafeAvatar/SafeAvatar.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { Avatar } from '@tamagui/avatar' +import type { AvatarProps } from '@tamagui/avatar' +import { View } from 'tamagui' + +// Local loading status type +type LoadingStatus = 'idle' | 'loading' | 'loaded' | 'error' + +interface SafeAvatarProps extends Omit { + /** The image URL to display */ + src: string + /** Optional delay for showing the fallback (in ms) */ + delayMs?: number + /** Background color for the fallback layer, e.g. '$gray4' */ + fallbackBackgroundColor?: string + /** Optional content to render inside the fallback, e.g. an icon */ + fallbackIcon?: React.ReactNode + /** The label for the avatar */ + label?: string +} + +/** + * Wrapper around the Tamagui Avatar component. Ads support for displaying the fallback when the image fails to load. + * + */ +export function SafeAvatar({ + src, + size = '$true', + label, + delayMs, + fallbackBackgroundColor, + fallbackIcon, + circular = true, + ...avatarProps +}: SafeAvatarProps) { + const [status, setStatus] = React.useState('idle') + + return ( + + {/* Always render the image but hide on error so fallback is visible underneath */} + {src && status !== 'error' && ( + setStatus(st)} + backgroundColor="$color" + accessibilityLabel={label} + /> + )} + {/* Fallback shows until status becomes 'loaded' */} + + + {fallbackIcon} + + + + ) +} + +SafeAvatar.displayName = 'SafeAvatar' diff --git a/apps/mobile/src/components/SafeBottomSheet/SafeBottomSheet.tsx b/apps/mobile/src/components/SafeBottomSheet/SafeBottomSheet.tsx new file mode 100644 index 0000000000..cb36b973a8 --- /dev/null +++ b/apps/mobile/src/components/SafeBottomSheet/SafeBottomSheet.tsx @@ -0,0 +1,182 @@ +import { BackdropComponent, BackgroundComponent } from '@/src/components/Dropdown/sheetComponents' +import { getTokenValue, getVariable, H5, useTheme, View } from 'tamagui' +import React, { useCallback, useEffect, useRef } from 'react' +import BottomSheet, { + BottomSheetFooterProps, + BottomSheetModalProps, + BottomSheetView, + BottomSheetScrollView, + BottomSheetFooter, +} from '@gorhom/bottom-sheet' +import DraggableFlatList, { DragEndParams, RenderItemParams, ScaleDecorator } from 'react-native-draggable-flatlist' +import { StyleSheet } from 'react-native' +import { useRouter } from 'expo-router' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { LoadingTx } from '@/src/features/ConfirmTx/components/LoadingTx' + +interface SafeBottomSheetProps { + children?: React.ReactNode + title?: string + sortable?: boolean + onDragEnd?: (params: DragEndParams) => void + items?: T[] + snapPoints?: BottomSheetModalProps['snapPoints'] + actions?: React.ReactNode + FooterComponent?: React.FC + renderItem?: React.FC<{ item: T; isDragging?: boolean; drag?: () => void; onClose: () => void }> + keyExtractor?: ({ item, index }: { item: T; index: number }) => string + loading?: boolean +} + +export function SafeBottomSheet({ + children, + title, + sortable, + items, + loading, + snapPoints = [600, '100%'], + keyExtractor, + actions, + renderItem: Render, + FooterComponent, + onDragEnd, +}: SafeBottomSheetProps) { + const ref = useRef(null) + const router = useRouter() + const insets = useSafeAreaInsets() + const [footerHeight, setFooterHeight] = React.useState(0) + const hasCustomItems = items?.length && Render + const isSortable = items?.length && sortable + const theme = useTheme() + + const onClose = useCallback(() => { + router.back() + }, []) + + const renderItem = useCallback( + ({ item, drag, isActive }: RenderItemParams) => { + return ( + + {Render && } + + ) + }, + [Render], + ) + + const TitleHeader = useCallback( + () => ( + +
{title}
+ + {actions && ( + + {actions} + + )} +
+ ), + [title, actions], + ) + + // callbacks + const handleSheetChanges = useCallback((index: number) => { + if (index === -1) { + router.back() + } + }, []) + + // Auto-expand when sorting is enabled + useEffect(() => { + if (sortable && ref.current) { + ref.current.expand() + } + }, [sortable]) + + // Wrapping the footer component with a function to get the height of the footer + const renderFooter: React.FC = useCallback( + (props) => { + return ( + + { + setFooterHeight(e.nativeEvent.layout.height) + }} + > + {FooterComponent && } + + + ) + }, + [FooterComponent, setFooterHeight], + ) + + return ( + } + footerComponent={isSortable ? undefined : renderFooter} + topInset={insets.top} + handleIndicatorStyle={{ backgroundColor: getVariable(theme.borderMain) }} + > + + {title && } + {isSortable ? ( + + data={items} + style={{ marginBottom: insets.bottom }} + containerStyle={{ height: '100%' }} + contentContainerStyle={{ paddingBottom: 50 }} + onDragEnd={onDragEnd} + keyExtractor={(item, index) => (keyExtractor ? keyExtractor({ item, index }) : index.toString())} + renderItem={renderItem} + /> + ) : ( + + + + {loading ? ( + + ) : hasCustomItems ? ( + items.map((item, index) => ( + + )) + ) : ( + children + )} + + + + )} + + + ) +} + +const styles = StyleSheet.create({ + contentContainer: { + justifyContent: 'space-around', + }, + scrollInnerContainer: { + paddingHorizontal: getTokenValue('$2'), + }, +}) diff --git a/apps/mobile/src/components/SafeBottomSheet/index.ts b/apps/mobile/src/components/SafeBottomSheet/index.ts new file mode 100644 index 0000000000..0e6f05da31 --- /dev/null +++ b/apps/mobile/src/components/SafeBottomSheet/index.ts @@ -0,0 +1 @@ +export { SafeBottomSheet } from './SafeBottomSheet' diff --git a/apps/mobile/src/components/SafeButton/SafeButton.stories.tsx b/apps/mobile/src/components/SafeButton/SafeButton.stories.tsx index fc79031e47..6227978578 100644 --- a/apps/mobile/src/components/SafeButton/SafeButton.stories.tsx +++ b/apps/mobile/src/components/SafeButton/SafeButton.stories.tsx @@ -1,12 +1,14 @@ import type { Meta, StoryObj } from '@storybook/react' import { SafeButton } from '@/src/components/SafeButton' import { action } from '@storybook/addon-actions' +import { YStack, Text, XStack, ScrollView } from 'tamagui' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import React from 'react' const meta: Meta = { title: 'SafeButton', component: SafeButton, args: { - label: 'Get started', onPress: action('onPress'), }, } @@ -15,4 +17,119 @@ export default meta type Story = StoryObj -export const Default: Story = {} +export const Primary: Story = { + render: (args) => { + return + }, +} + +type RenderScreenProps = { + type: 'primary' | 'secondary' | 'danger' | 'text' + args: Meta['args'] +} +const RenderScreen = ({ type, args }: RenderScreenProps) => { + return ( + + + {type.charAt(0).toUpperCase() + type.slice(1)} + + + Play + + With Symbol + }> + Play + + Disabled + + Play + + Disabled with symbol + }> + Play + + + + Fullscreen + {type.charAt(0).toUpperCase() + type.slice(1)} fullscreen in YStack + + Play + + {type.charAt(0).toUpperCase() + type.slice(1)} fullscreen in XStack + + + Play + + + + Small + + {type.charAt(0).toUpperCase() + type.slice(1)} + + + Play + + With Symbol + } size={'$sm'}> + Play + + Disabled + + Play + + Disabled with symbol + } size={'$sm'}> + Play + + + + Fullscreen + {type.charAt(0).toUpperCase() + type.slice(1)} fullscreen in YStack + + Play + + {type.charAt(0).toUpperCase() + type.slice(1)} fullscreen in XStack + + + Play + + + + + + + ) +} + +export const Secondary: Story = { + render: (args) => { + return + }, +} + +export const Danger: Story = { + render: (args) => { + return + }, +} + +export const OnlyText: Story = { + render: (args) => { + return + }, +} + +export const Circle: Story = { + render: (args) => { + return ( + + Primary round + } /> + Secondary round + } /> + Disabled + } /> + + ) + }, +} diff --git a/apps/mobile/src/components/SafeButton/SafeButton.tsx b/apps/mobile/src/components/SafeButton/SafeButton.tsx index 56820a923b..d4ae19344a 100644 --- a/apps/mobile/src/components/SafeButton/SafeButton.tsx +++ b/apps/mobile/src/components/SafeButton/SafeButton.tsx @@ -1,32 +1,118 @@ -import React from 'react' -import { TouchableOpacity } from 'react-native' -import { styled, Text, View } from 'tamagui' - -interface SafeButtonProps { - onPress: () => void - label: string - variant?: 'primary' | 'secondary' -} - -export const StyledButtonWrapper = styled(View, { - height: 48, - alignItems: 'center', - justifyContent: 'center', - borderRadius: 8, -}) +import { styled, Button } from 'tamagui' + +export const SafeButton = styled(Button, { + variants: { + rounded: { + true: { + borderRadius: 8, + }, + }, + + circle: { + true: { + borderRadius: 100, + height: 50, + width: 50, + padding: 17, + }, + }, + + danger: { + true: { + backgroundColor: '$errorBackground', + color: '$error', + }, + }, + + success: { + true: { + backgroundColor: '$success', + color: '$color', + }, + }, + + primary: { + true: { + backgroundColor: '$primary', + color: '$contrast', + }, + }, -export function SafeButton({ onPress, label, variant = 'primary' }: SafeButtonProps) { - const variantStyles = - variant === 'primary' - ? { backgroundColor: '$primary', fontColor: '$background' } - : { backgroundColor: 'inherit', fontColor: '$primary' } - return ( - - - - {label} - - - - ) -} + secondary: { + true: { + backgroundColor: '$backgroundSecondary', + color: '$color', + }, + }, + + outlined: { + true: { + backgroundColor: 'transparent', + borderWidth: 2, + borderColor: '$color', + color: '$color', + }, + }, + + text: { + true: { + backgroundColor: 'transparent', + color: '$primary', + }, + }, + + disabled: { + true: (_, allProps) => { + // @ts-expect-error + const isText = allProps.props?.text === true + return { + backgroundColor: isText ? 'transparent' : '$backgroundDisabled', + color: '$colorLight', + } + }, + }, + + size: { + $xl: () => ({ + fontSize: 14, + fontWeight: 700, + height: 'auto', + margin: 0, + paddingVertical: '$3', + textProps: { + lineHeight: 24, + }, + }), + $md: () => ({ + height: 'auto', + paddingVertical: 14, + paddingHorizontal: 20, + margin: 0, + fontWeight: 700, + letterSpacing: -0.1, + fontSize: 14, + scaleIcon: 0.9, + scaleSpace: 0.3, + textProps: { + marginBottom: -2.5, + }, + }), + $sm: () => ({ + height: 36, + paddingVertical: '$2', + paddingHorizontal: '$3', + fontWeight: 600, + scaleIcon: 0.8, + scaleSpace: 0.2, + textProps: { + marginBottom: -2.5, + }, + }), + }, + } as const, + defaultVariants: { + size: '$md', + rounded: true, + primary: true, + }, +}) diff --git a/apps/mobile/src/components/SafeCard/SafeCard.stories.tsx b/apps/mobile/src/components/SafeCard/SafeCard.stories.tsx index c02b58c9f8..79d997ddb0 100644 --- a/apps/mobile/src/components/SafeCard/SafeCard.stories.tsx +++ b/apps/mobile/src/components/SafeCard/SafeCard.stories.tsx @@ -21,9 +21,8 @@ export const Default: Story = { args: { title: 'Welcome to Safe', description: 'Add a new owner to your Safe', - icon: , - image: Seed, }, + render: (args) => } image={Seed} />, } export const OnlyText: Story = { @@ -37,6 +36,10 @@ export const withChildren: Story = { args: { title: 'Welcome to Safe', description: 'Add a new owner to your Safe', - children: Hello from children, }, + render: (args) => ( + + Hello from children + + ), } diff --git a/apps/mobile/src/components/SafeCard/SafeCard.tsx b/apps/mobile/src/components/SafeCard/SafeCard.tsx index 6a3e2a50c8..d353f4e68d 100644 --- a/apps/mobile/src/components/SafeCard/SafeCard.tsx +++ b/apps/mobile/src/components/SafeCard/SafeCard.tsx @@ -1,4 +1,4 @@ -import { H5, Image, Text, View } from 'tamagui' +import { H5, Image, ImageProps, Text, View, XStack } from 'tamagui' import { Badge } from '../Badge' import { Container } from '../Container' import { ImageSourcePropType } from 'react-native' @@ -9,13 +9,30 @@ interface SafeCardProps { description: string image?: ImageSourcePropType icon?: ReactElement + tag?: ReactElement children?: React.ReactNode + onPress?: () => void + imageProps?: ImageProps + testID?: string } -export function SafeCard({ title, description, image, icon, children }: SafeCardProps) { +export function SafeCard({ + title, + description, + imageProps, + image, + icon, + children, + onPress, + testID, + tag, +}: SafeCardProps) { return ( - - {icon && } + + + {icon && } + {tag} +
{title} @@ -30,11 +47,12 @@ export function SafeCard({ title, description, image, icon, children }: SafeCard {image && ( , 'name' | 'size' | 'color'> { name: IconName size?: number color?: string diff --git a/apps/mobile/src/components/SafeInput/SafeInput.test.tsx b/apps/mobile/src/components/SafeInput/SafeInput.test.tsx index a4a94ad647..434eadfedf 100644 --- a/apps/mobile/src/components/SafeInput/SafeInput.test.tsx +++ b/apps/mobile/src/components/SafeInput/SafeInput.test.tsx @@ -4,26 +4,18 @@ import { Text } from 'tamagui' describe('SafeInput', () => { it('should render the default component', () => { - const { getByTestId } = render() + const { getByTestId, getByPlaceholderText } = render() const input = getByTestId('safe-input') expect(input).toBeDefined() - - expect(input.children[0].props.placeholder).toBe('Please enter something...') - expect(input.props.style.borderTopColor).toBe('#DCDEE0') - expect(input.props.style.borderBottomColor).toBe('#DCDEE0') - expect(input.props.style.borderLeftColor).toBe('#DCDEE0') - expect(input.props.style.borderRightColor).toBe('#DCDEE0') + expect(getByPlaceholderText('Please enter something...')).toBeDefined() }) it('should render an error message when an error message is provided', () => { const { getByTestId, getByText } = render() const input = getByTestId('safe-input') - expect(input.props.style.borderTopColor).toBe('#FF5F72') - expect(input.props.style.borderBottomColor).toBe('#FF5F72') - expect(input.props.style.borderLeftColor).toBe('#FF5F72') - expect(input.props.style.borderRightColor).toBe('#FF5F72') + expect(input).toBeDefined() expect(getByText('This field is required')).toBeDefined() }) @@ -31,20 +23,14 @@ describe('SafeInput', () => { const { getByTestId, getByText } = render(This field is required} />) const input = getByTestId('safe-input') - expect(input.props.style.borderTopColor).toBe('#FF5F72') - expect(input.props.style.borderBottomColor).toBe('#FF5F72') - expect(input.props.style.borderLeftColor).toBe('#FF5F72') - expect(input.props.style.borderRightColor).toBe('#FF5F72') + expect(input).toBeDefined() expect(getByText('This field is required')).toBeDefined() }) - it('should change the color when a success prop is provided', () => { + it('should render successfully when success prop is provided', () => { const { getByTestId } = render() const input = getByTestId('safe-input') - expect(input.props.style.borderTopColor).toBe('#12FF80') - expect(input.props.style.borderBottomColor).toBe('#12FF80') - expect(input.props.style.borderLeftColor).toBe('#12FF80') - expect(input.props.style.borderRightColor).toBe('#12FF80') + expect(input).toBeDefined() }) }) diff --git a/apps/mobile/src/components/SafeInput/SafeInput.tsx b/apps/mobile/src/components/SafeInput/SafeInput.tsx index 21081b04f9..748cafa43f 100644 --- a/apps/mobile/src/components/SafeInput/SafeInput.tsx +++ b/apps/mobile/src/components/SafeInput/SafeInput.tsx @@ -41,18 +41,20 @@ export function SafeInput({ return ( - {left} + {left ? {left} : null} - {right} + {right ? {right} : null} {hasError && } diff --git a/apps/mobile/src/components/SafeInput/SafeInputWithLabel.tsx b/apps/mobile/src/components/SafeInput/SafeInputWithLabel.tsx new file mode 100644 index 0000000000..0622b6555a --- /dev/null +++ b/apps/mobile/src/components/SafeInput/SafeInputWithLabel.tsx @@ -0,0 +1,89 @@ +import { Input, InputProps, styled, View, Text, Theme } from 'tamagui' +import React from 'react' +import { Platform } from 'react-native' + +interface Props { + label: string + error?: boolean + placeholder?: string + success?: boolean + left?: React.ReactNode + right?: React.ReactNode + testID?: string +} + +const StyledInputContainer = styled(View, { + borderWidth: 2, + borderRadius: '$4', + borderColor: 'transparent', + flex: 1, + paddingHorizontal: '$3', + alignItems: 'flex-start', + justifyContent: 'flex-start', + marginBottom: '$3', + padding: '$3', + backgroundColor: '$background', + + variants: { + error: { + true: { + borderWidth: 2, + borderColor: '$error', + }, + }, + success: { + true: { + borderWidth: 2, + borderColor: '$success', + }, + }, + }, +}) + +const StyledInput = styled(Input, { + color: '$inputTextColor', + placeholderTextColor: '$placeholderColor', + borderWidth: 0, + padding: 0, + + style: { + boxSizing: Platform.OS === 'android' ? 'content-box' : undefined, + borderWidth: 0, + backgroundColor: '$borderColorHover', + paddingLeft: 0, + }, +}) +export const SafeInputWithLabel = ({ + label, + testID, + error, + success, + placeholder, + left, + right, + ...props +}: Props & Omit) => { + return ( + + + + {left ? {left} : null} + + + {label} + + + + + + {right ? {right} : null} + + + + ) +} diff --git a/apps/mobile/src/components/SafeInput/index.ts b/apps/mobile/src/components/SafeInput/index.ts new file mode 100644 index 0000000000..96f3f05999 --- /dev/null +++ b/apps/mobile/src/components/SafeInput/index.ts @@ -0,0 +1 @@ +export { SafeInput } from './SafeInput' diff --git a/apps/mobile/src/components/SafeInput/styled.ts b/apps/mobile/src/components/SafeInput/styled.ts index 159289b5d2..8546f26691 100644 --- a/apps/mobile/src/components/SafeInput/styled.ts +++ b/apps/mobile/src/components/SafeInput/styled.ts @@ -1,20 +1,20 @@ import { Input, styled, View } from 'tamagui' export const StyledInputContainer = styled(View, { - borderWidth: 2, + borderWidth: 1, borderRadius: '$4', borderColor: '$borderColor', flex: 1, flexDirection: 'row', - paddingHorizontal: '$3', alignItems: 'center', justifyContent: 'center', marginBottom: '$3', + backgroundColor: '$containerBackgroundColor', variants: { error: { true: { - borderWidth: 2, + borderWidth: 1, }, }, }, @@ -23,5 +23,11 @@ export const StyledInputContainer = styled(View, { export const StyledInput = styled(Input, { color: '$inputTextColor', placeholderTextColor: '$placeholderColor', + backgroundColor: '$inputBackgroundColor', borderWidth: 0, + + style: { + padding: 0, + lineHeight: 20.5, + }, }) diff --git a/apps/mobile/src/components/SafeInput/theme.ts b/apps/mobile/src/components/SafeInput/theme.ts index 93d206d323..049b87804b 100644 --- a/apps/mobile/src/components/SafeInput/theme.ts +++ b/apps/mobile/src/components/SafeInput/theme.ts @@ -6,35 +6,56 @@ export const inputTheme = { textColor: tokens.color.textPrimaryLight, placeholderColor: tokens.color.textSecondaryLight, inputTextColor: tokens.color.textPrimaryLight, + inputBackgroundColor: tokens.color.backgroundDefaultLight, + containerBackgroundColor: tokens.color.backgroundDefaultLight, }, dark_input_default: { borderColor: tokens.color.borderLightDark, textColor: tokens.color.textPrimaryDark, placeholderColor: tokens.color.textSecondaryDark, inputTextColor: tokens.color.textPrimaryDark, + inputBackgroundColor: tokens.color.backgroundPaperDark, + containerBackgroundColor: tokens.color.backgroundPaperDark, }, light_input_success: { borderColor: tokens.color.primaryMainDark, textColor: tokens.color.textPrimaryLight, placeholderColor: tokens.color.textSecondaryLight, inputTextColor: tokens.color.textPrimaryLight, + inputBackgroundColor: tokens.color.backgroundDefaultLight, + containerBackgroundColor: tokens.color.backgroundDefaultLight, }, dark_input_success: { borderColor: tokens.color.primaryMainDark, textColor: tokens.color.textPrimaryDark, placeholderColor: tokens.color.textSecondaryDark, inputTextColor: tokens.color.textPrimaryDark, + inputBackgroundColor: tokens.color.backgroundPaperDark, + containerBackgroundColor: tokens.color.backgroundPaperDark, }, light_input_error: { borderColor: tokens.color.errorMainLight, textColor: tokens.color.errorMainLight, placeholderColor: tokens.color.errorMainLight, inputTextColor: tokens.color.textPrimaryLight, + inputBackgroundColor: tokens.color.backgroundDefaultLight, + containerBackgroundColor: tokens.color.backgroundDefaultLight, }, dark_input_error: { borderColor: tokens.color.errorMainDark, textColor: tokens.color.errorMainDark, placeholderColor: tokens.color.errorMainDark, inputTextColor: tokens.color.textPrimaryDark, + inputBackgroundColor: tokens.color.backgroundPaperDark, + containerBackgroundColor: tokens.color.backgroundPaperDark, + }, +} + +export const inputWithLabelTheme = { + light_input_with_label: { + background: tokens.color.backgroundDefaultLight, + }, + dark_input_with_label: { + background: tokens.color.backgroundPaperDark, }, } diff --git a/apps/mobile/src/components/SafeListItem/SafeListItem.test.tsx b/apps/mobile/src/components/SafeListItem/SafeListItem.test.tsx index 53599f7c9d..0626d5fdbd 100644 --- a/apps/mobile/src/components/SafeListItem/SafeListItem.test.tsx +++ b/apps/mobile/src/components/SafeListItem/SafeListItem.test.tsx @@ -2,6 +2,7 @@ import { render } from '@/src/tests/test-utils' import { SafeListItem } from '.' import { Text, View } from 'tamagui' import { ellipsis } from '@/src/utils/formatters' +import { Alert } from '../Alert' describe('SafeListItem', () => { it('should render the default markup', () => { @@ -65,6 +66,66 @@ describe('SafeListItem', () => { expect(container).toMatchSnapshot() }) + + it('should render bottomContent when provided', () => { + const { getByText } = render( + Left node} + bottomContent={ + + Bottom content text + + } + />, + ) + + expect(getByText('A label')).toBeTruthy() + expect(getByText('Left node')).toBeTruthy() + expect(getByText('Bottom content text')).toBeTruthy() + }) + + it('should not render bottomContent when not provided', () => { + const { queryByTestId } = render(Left node} />) + + // Since bottomContent is not provided, there should be no bottom content container + expect(queryByTestId('bottom-content')).toBeNull() + }) + + it('should render bottomContent with proper styling and layout', () => { + const container = render( + Left node} + bottomContent={ + + Transaction warning message + + } + />, + ) + + expect(container.getByText('A label')).toBeTruthy() + expect(container.getByTestId('bottom-content-container')).toBeTruthy() + expect(container.getByText('Transaction warning message')).toBeTruthy() + + expect(container).toMatchSnapshot() + }) + + it('should render bottomContent with Alert component', () => { + const { getByText } = render( + Shield icon} + bottomContent={} + />, + ) + + expect(getByText('Transaction checks')).toBeTruthy() + expect(getByText('Shield icon')).toBeTruthy() + expect(getByText('Contract Changes Detected!')).toBeTruthy() + expect(getByText('Review Details First')).toBeTruthy() + }) }) describe('SafeListItem.Header', () => { diff --git a/apps/mobile/src/components/SafeListItem/SafeListItem.tsx b/apps/mobile/src/components/SafeListItem/SafeListItem.tsx index b0361a0051..513ddd5b54 100644 --- a/apps/mobile/src/components/SafeListItem/SafeListItem.tsx +++ b/apps/mobile/src/components/SafeListItem/SafeListItem.tsx @@ -1,13 +1,15 @@ import React from 'react' import { Container } from '../Container' -import { Text, Theme, ThemeName, View } from 'tamagui' +import { Text, Theme, ThemeName, View, ViewProps, YStackProps } from 'tamagui' import { IconProps, SafeFontIcon } from '../SafeFontIcon/SafeFontIcon' import { ellipsis } from '@/src/utils/formatters' import { isMultisigExecutionInfo } from '@/src/utils/transaction-guards' import { Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' import { Badge } from '../Badge' +import { Tag } from '../Tag' +import { ProposalBadge } from '../ProposalBadge' -interface SafeListItemProps { +export interface SafeListItemProps { type?: string label: string | React.ReactNode icon?: IconProps['name'] @@ -20,6 +22,11 @@ interface SafeListItemProps { inQueue?: boolean executionInfo?: Transaction['executionInfo'] themeName?: ThemeName + onPress?: () => void + tag?: string + paddingVertical?: YStackProps['paddingVertical'] + bottomContent?: React.ReactNode + pressStyle?: ViewProps['pressStyle'] } export function SafeListItem({ @@ -35,66 +42,94 @@ export function SafeListItem({ inQueue, executionInfo, themeName, + onPress, + tag, + paddingVertical = '$4', + bottomContent, + pressStyle, }: SafeListItemProps) { + // TODO: Replace this with proposedByDelegate once EN-149 is implemented + const isProposedTx = isMultisigExecutionInfo(executionInfo) ? executionInfo.confirmationsSubmitted === 0 : false + return ( - - {leftNode} + + + {leftNode} - - {type && ( - - {icon && } - - {type} - - - )} + + {type && ( + + {icon && ( + + )} + + {type} + + + )} - {typeof label === 'string' ? ( - - {ellipsis(label, rightNode ? 21 : 30)} - - ) : ( - label - )} + {typeof label === 'string' ? ( + + {ellipsis(label, rightNode || inQueue ? 21 : 30)} + + ) : ( + label + )} + + {tag && {tag}} - - {inQueue && executionInfo && isMultisigExecutionInfo(executionInfo) ? ( - - - + {inQueue && executionInfo && isMultisigExecutionInfo(executionInfo) ? ( + + {isProposedTx ? ( + + ) : ( + + - - {executionInfo?.confirmationsSubmitted}/{executionInfo?.confirmationsRequired} - - - } - themeName={ - executionInfo?.confirmationsRequired === executionInfo?.confirmationsSubmitted - ? 'badge_success_variant1' - : 'badge_warning_variant1' - } - /> + + {executionInfo?.confirmationsSubmitted}/{executionInfo?.confirmationsRequired} + + + } + themeName={ + executionInfo?.confirmationsRequired === executionInfo?.confirmationsSubmitted + ? 'badge_success_variant1' + : 'badge_warning' + } + /> + )} + + + + ) : ( + rightNode + )} + - + {bottomContent && ( + + {bottomContent} - ) : ( - rightNode )} {children} @@ -105,7 +140,7 @@ export function SafeListItem({ SafeListItem.Header = function Header({ title }: { title: string }) { return ( - + {title} diff --git a/apps/mobile/src/components/SafeListItem/__snapshots__/SafeListItem.test.tsx.snap b/apps/mobile/src/components/SafeListItem/__snapshots__/SafeListItem.test.tsx.snap index e88f8e9361..8b610b1d9a 100644 --- a/apps/mobile/src/components/SafeListItem/__snapshots__/SafeListItem.test.tsx.snap +++ b/apps/mobile/src/components/SafeListItem/__snapshots__/SafeListItem.test.tsx.snap @@ -5,16 +5,16 @@ exports[`SafeListItem should render a list item with a custom label template 1`] - - Left node - - - - -  - - + + - some type - + +  + + + some type + + + + + Here is my label + + + + + + +`; + +exports[`SafeListItem should render bottomContent with proper styling and layout 1`] = ` + + + + + + Left node + - Here is my label + A label + + + + Transaction warning message + + + `; diff --git a/apps/mobile/src/components/SafeListItem/theme.ts b/apps/mobile/src/components/SafeListItem/theme.ts new file mode 100644 index 0000000000..11427a8be6 --- /dev/null +++ b/apps/mobile/src/components/SafeListItem/theme.ts @@ -0,0 +1,12 @@ +import { tokens } from '@/src/theme/tokens' + +export const SafeListItemTheme = { + light_safe_list: { + background: tokens.color.backgroundPaperLight, + colorSecondary: tokens.color.textSecondaryLight, + }, + dark_safe_list: { + background: tokens.color.backgroundDefaultDark, + colorSecondary: tokens.color.primaryLightDark, + }, +} diff --git a/apps/mobile/src/components/SafeSearchBar/SafeSearchBar.tsx b/apps/mobile/src/components/SafeSearchBar/SafeSearchBar.tsx new file mode 100644 index 0000000000..bd7f01a0ca --- /dev/null +++ b/apps/mobile/src/components/SafeSearchBar/SafeSearchBar.tsx @@ -0,0 +1,105 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { View, TextInput, StyleSheet, TouchableOpacity, Platform, KeyboardAvoidingView } from 'react-native' +import { SafeFontIcon } from '../SafeFontIcon' + +interface SafeSearchBarProps { + placeholder: string + onSearch: (query: string) => void + throttleTime?: number +} + +const SafeSearchBar: React.FC = ({ placeholder, onSearch, throttleTime = 300 }) => { + const [searchQuery, setSearchQuery] = useState('') + const [timer, setTimer] = useState | null>(null) + + const throttleSearch = useCallback( + (query: string) => { + if (timer) { + clearTimeout(timer) + } + + const newTimer = setTimeout(() => { + onSearch(query) + }, throttleTime) + + setTimer(newTimer) + }, + [onSearch, throttleTime, timer], + ) + + useEffect(() => { + return () => { + if (timer) { + clearTimeout(timer) + } + } + }, [timer]) + + const handleSearchChange = (text: string) => { + setSearchQuery(text) + throttleSearch(text) + } + + const handleClearSearch = () => { + setSearchQuery('') + onSearch('') + } + + return ( + + + + + + + {searchQuery.length > 0 && ( + + + + )} + + + ) +} + +const styles = StyleSheet.create({ + container: { + width: '100%', + marginVertical: 8, + }, + searchBar: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 10, + backgroundColor: '#7676801F', + paddingHorizontal: 8, + height: 36, + }, + logo: { + width: 24, + height: 24, + marginRight: 8, + }, + + input: { + flex: 1, + height: '100%', + fontSize: 16, + color: '#999', + marginLeft: 8, + }, + clearButton: { + padding: 4, + }, +}) + +export default SafeSearchBar diff --git a/apps/mobile/src/components/SafeTab/SafeTab.tsx b/apps/mobile/src/components/SafeTab/SafeTab.tsx index 60024a8136..49ca2ccb1c 100644 --- a/apps/mobile/src/components/SafeTab/SafeTab.tsx +++ b/apps/mobile/src/components/SafeTab/SafeTab.tsx @@ -2,35 +2,51 @@ import React, { ReactElement, useState } from 'react' import { TabBarProps, Tabs } from 'react-native-collapsible-tab-view' import { safeTabItem } from './types' import { SafeTabBar } from './SafeTabBar' +import { Theme } from 'tamagui' +import { StyleProp, ViewStyle } from 'react-native' -interface SafeTabProps { +interface SafeTabProps { renderHeader?: (props: TabBarProps) => ReactElement headerHeight?: number - items: safeTabItem[] + items: safeTabItem[] + containerProps?: T + containerStyle?: StyleProp + onIndexChange?: (index: number) => void } -export function SafeTab({ renderHeader, headerHeight, items }: SafeTabProps) { +export function SafeTab({ + renderHeader, + headerHeight, + items, + containerProps, + containerStyle, + onIndexChange, +}: SafeTabProps) { const [activeTab, setActiveTab] = useState(items[0].label) return ( - } - onTabChange={(event) => setActiveTab(event.tabName)} - initialTabName={items[0].label} - > - {items.map(({ label, Component }, index) => ( - - - - ))} - + + } + onTabChange={(event) => setActiveTab(event.tabName)} + onIndexChange={onIndexChange} + initialTabName={items[0].label} + > + {items.map(({ label, Component }, index) => ( + + + + ))} + + ) } -const headerContainerStyle = { backgroundColor: '$background' } +const headerContainerStyle = { backgroundColor: '$background', shadowColor: 'transparent' } SafeTab.FlashList = Tabs.FlashList SafeTab.FlatList = Tabs.FlatList diff --git a/apps/mobile/src/components/SafeTab/SafeTabBar.tsx b/apps/mobile/src/components/SafeTab/SafeTabBar.tsx index 7e1cea7887..8f1de870a0 100644 --- a/apps/mobile/src/components/SafeTab/SafeTabBar.tsx +++ b/apps/mobile/src/components/SafeTab/SafeTabBar.tsx @@ -33,10 +33,17 @@ export const SafeTabBar = ({ } return ( - + {tabNames.map((name) => ( - + {name} diff --git a/apps/mobile/src/components/SafeTab/theme.ts b/apps/mobile/src/components/SafeTab/theme.ts new file mode 100644 index 0000000000..9ac39ca74b --- /dev/null +++ b/apps/mobile/src/components/SafeTab/theme.ts @@ -0,0 +1,10 @@ +import { tokens } from '@/src/theme/tokens' + +export const safeTabTheme = { + light_tab: { + background: tokens.color.backgroundMainLight, + }, + dark_tab: { + background: tokens.color.backgroundDefaultDark, + }, +} diff --git a/apps/mobile/src/components/SafeTab/types.ts b/apps/mobile/src/components/SafeTab/types.ts index 8177417942..9089a28809 100644 --- a/apps/mobile/src/components/SafeTab/types.ts +++ b/apps/mobile/src/components/SafeTab/types.ts @@ -1,4 +1,4 @@ -export interface safeTabItem { +export interface safeTabItem { label: string - Component: React.FC + Component: React.FC } diff --git a/apps/mobile/src/components/SelectSigner/SelectSigner.tsx b/apps/mobile/src/components/SelectSigner/SelectSigner.tsx new file mode 100644 index 0000000000..c75d94c392 --- /dev/null +++ b/apps/mobile/src/components/SelectSigner/SelectSigner.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { Text, View, Image } from 'tamagui' +import Signature from '@/assets/images/signature.png' + +import { Identicon } from '@/src/components/Identicon' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { router } from 'expo-router' +import { ContactDisplayNameContainer } from '@/src/features/AddressBook' +import { Address } from '@/src/types/address' + +type Props = { + address: Address + txId: string +} + +export function SelectSigner({ address, txId }: Props) { + return ( + router.push({ pathname: '/change-signer-sheet', params: { txId } })} + flexDirection="row" + justifyContent="center" + alignItems="center" + gap={'$2'} + > + + Sign with + + + + + + + + ) +} diff --git a/apps/mobile/src/components/SelectSigner/index.ts b/apps/mobile/src/components/SelectSigner/index.ts new file mode 100644 index 0000000000..a9543d729d --- /dev/null +++ b/apps/mobile/src/components/SelectSigner/index.ts @@ -0,0 +1,2 @@ +import { SelectSigner } from './SelectSigner' +export { SelectSigner } diff --git a/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.test.tsx b/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.test.tsx index ba945664d5..11810b2c8c 100644 --- a/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.test.tsx +++ b/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.test.tsx @@ -14,7 +14,7 @@ describe('PendingTransactions', () => { expect(getByText('2')).toBeTruthy() - await user.press(getByText('Pending Transactions')) + await user.press(getByText('Pending transactions')) expect(mockedFn).toHaveBeenCalled() }) diff --git a/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.tsx b/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.tsx index bfcd6b5669..8d19930d84 100644 --- a/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.tsx +++ b/apps/mobile/src/components/StatusBanners/PendingTransactions/PendingTransactions.tsx @@ -1,9 +1,8 @@ import React from 'react' -import { Spinner } from 'tamagui' import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' import { Badge } from '@/src/components/Badge' - +import { Loader } from '@/src/components/Loader' import { Alert } from '../../Alert' interface Props { @@ -14,8 +13,12 @@ interface Props { } export const PendingTransactions = ({ number, isLoading, fullWidth, onPress }: Props) => { - const startIcon = isLoading ? : - const endIcon = + const startIcon = isLoading ? ( + + ) : ( + + ) + const endIcon = return ( diff --git a/apps/mobile/src/components/StatusBanners/PendingTransactions/__snapshots__/PendingTransactions.test.tsx.snap b/apps/mobile/src/components/StatusBanners/PendingTransactions/__snapshots__/PendingTransactions.test.tsx.snap index edb96da7f8..16c9cde35f 100644 --- a/apps/mobile/src/components/StatusBanners/PendingTransactions/__snapshots__/PendingTransactions.test.tsx.snap +++ b/apps/mobile/src/components/StatusBanners/PendingTransactions/__snapshots__/PendingTransactions.test.tsx.snap @@ -50,7 +50,7 @@ exports[`PendingTransactions should render the pending transactions in fullWidth style={ { "alignItems": "center", - "backgroundColor": "#fff0e0", + "backgroundColor": "#FFECC2", "borderBottomLeftRadius": 5, "borderBottomRightRadius": 5, "borderTopLeftRadius": 5, @@ -58,10 +58,10 @@ exports[`PendingTransactions should render the pending transactions in fullWidth "flexDirection": "row", "gap": 12, "justifyContent": "center", - "paddingBottom": 12, - "paddingLeft": 28, - "paddingRight": 28, - "paddingTop": 12, + "paddingBottom": 8, + "paddingLeft": 8, + "paddingRight": 8, + "paddingTop": 8, "width": "100%", } } @@ -73,30 +73,31 @@ exports[`PendingTransactions should render the pending transactions in fullWidth style={ { "alignItems": "center", - "backgroundColor": "#FFF1E0", + "backgroundColor": "#FF8C00", "borderBottomLeftRadius": 100000, "borderBottomRightRadius": 100000, "borderTopLeftRadius": 100000, "borderTopRightRadius": 100000, "flexDirection": "column", - "height": 28, + "height": 24, "justifyContent": "center", - "maxHeight": 28, - "maxWidth": 28, - "minHeight": 28, - "minWidth": 28, + "maxHeight": 24, + "maxWidth": 24, + "minHeight": 24, + "minWidth": 24, "paddingBottom": 0, "paddingLeft": 0, "paddingRight": 0, "paddingTop": 0, - "width": 28, + "width": 24, } } > - Pending Transactions + Pending transactions -  +  diff --git a/apps/mobile/src/components/SwapHeader/SwapHeader.test.tsx b/apps/mobile/src/components/SwapHeader/SwapHeader.test.tsx new file mode 100644 index 0000000000..8d97dda579 --- /dev/null +++ b/apps/mobile/src/components/SwapHeader/SwapHeader.test.tsx @@ -0,0 +1,157 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { SwapHeader } from './SwapHeader' + +const mockTokenA = { + logoUri: 'https://example.com/token-a.png', + symbol: 'TOKENA', +} + +const mockTokenB = { + logoUri: 'https://example.com/token-b.png', + symbol: 'TOKENB', +} + +describe('SwapHeader', () => { + it('should render basic swap header with required props', () => { + const { getByText } = render( + , + ) + + expect(getByText('Sell')).toBeVisible() + expect(getByText('For')).toBeVisible() + expect(getByText('100.5 TOKENA')).toBeVisible() + expect(getByText('200.25 TOKENB')).toBeVisible() + }) + + it('should render date and time when provided', () => { + const { getByText } = render( + , + ) + + expect(getByText('Dec 15 2023 at 2:30 PM')).toBeVisible() + }) + + it('should not render date section when date/time are not provided', () => { + const { queryByText } = render( + , + ) + + expect(queryByText(/at/)).toBeNull() + }) + + it('should render custom labels when provided', () => { + const { getByText } = render( + , + ) + + expect(getByText('Custom From')).toBeVisible() + expect(getByText('Custom To')).toBeVisible() + }) + + it('should render with default labels when not provided', () => { + const { getByText } = render( + , + ) + + expect(getByText('Sell')).toBeVisible() + expect(getByText('For')).toBeVisible() + }) + + it('should handle tokens with null logoUri', () => { + const tokenWithoutLogo = { + logoUri: null, + symbol: 'NOLOGO', + } + + const { getByText } = render( + , + ) + + expect(getByText('100.5 NOLOGO')).toBeVisible() + }) + + it('should handle tokens with undefined logoUri', () => { + const tokenWithoutLogo = { + logoUri: undefined, + symbol: 'UNDEFINED', + } + + const { getByText } = render( + , + ) + + expect(getByText('100.5 UNDEFINED')).toBeVisible() + }) + + it('should ellipsize long amounts', () => { + const { getByText } = render( + , + ) + + // The ellipsis utility should truncate long amounts + expect(getByText(/TOKENA/)).toBeVisible() + expect(getByText(/TOKENB/)).toBeVisible() + }) + + it('should render TokenIcons with correct props', () => { + const { getAllByTestId } = render( + , + ) + + // TokenIcons should be rendered with testID - there should be 2 images + const images = getAllByTestId('logo-image') + expect(images).toHaveLength(2) + expect(images[0]).toBeVisible() + expect(images[1]).toBeVisible() + expect(images[0]).toHaveProp('accessibilityLabel', 'TOKENA') + expect(images[1]).toHaveProp('accessibilityLabel', 'TOKENB') + }) + + it('should render chevron-right arrow between tokens', () => { + render() + + // Badge with chevron should be present + // Note: Exact test depends on how SafeFontIcon is implemented + }) + + it('should handle edge case with very short amounts', () => { + const { getByText } = render( + , + ) + + expect(getByText('0 TOKENA')).toBeVisible() + expect(getByText('0.1 TOKENB')).toBeVisible() + }) + + it('should handle multiple SwapHeaders in same component', () => { + const { getAllByText } = render( + <> + + + , + ) + + expect(getAllByText('Sell')).toHaveLength(2) + expect(getAllByText('For')).toHaveLength(2) + }) +}) diff --git a/apps/mobile/src/components/SwapHeader/SwapHeader.tsx b/apps/mobile/src/components/SwapHeader/SwapHeader.tsx new file mode 100644 index 0000000000..f5f8cda3ef --- /dev/null +++ b/apps/mobile/src/components/SwapHeader/SwapHeader.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { Text, View, H5 } from 'tamagui' +import { Container } from '@/src/components/Container' +import { TokenIcon } from '@/src/components/TokenIcon' +import { ellipsis } from '@/src/utils/formatters' +import { Badge } from '@/src/components/Badge' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' + +interface TokenInfo { + logoUri?: string | null + symbol: string +} + +interface SwapHeaderProps { + date?: string + time?: string + fromToken: TokenInfo + toToken: TokenInfo + fromAmount: string + toAmount: string + fromLabel?: string + toLabel?: string +} + +export function SwapHeader({ + date, + time, + fromToken, + toToken, + fromAmount, + toAmount, + fromLabel = 'Sell', + toLabel = 'For', +}: SwapHeaderProps) { + return ( + <> + {date && time && ( + + + {date} at {time} + + + )} + + + + + + {fromLabel} +
+ {ellipsis(fromAmount, 9)} {fromToken.symbol} +
+
+
+ + + } + themeName="badge_background" + /> + + + + + + {toLabel} +
+ {ellipsis(toAmount, 9)} {toToken.symbol} +
+
+
+
+ + ) +} diff --git a/apps/mobile/src/components/SwapHeader/index.ts b/apps/mobile/src/components/SwapHeader/index.ts new file mode 100644 index 0000000000..438466daa8 --- /dev/null +++ b/apps/mobile/src/components/SwapHeader/index.ts @@ -0,0 +1 @@ +export { SwapHeader } from './SwapHeader' diff --git a/apps/mobile/src/components/Tag/Tag.stories.tsx b/apps/mobile/src/components/Tag/Tag.stories.tsx new file mode 100644 index 0000000000..aec1a3dd95 --- /dev/null +++ b/apps/mobile/src/components/Tag/Tag.stories.tsx @@ -0,0 +1,31 @@ +import type { StoryObj, Meta } from '@storybook/react' +import { Tag } from '@/src/components/Tag' + +const meta: Meta = { + title: 'Tag', + component: Tag, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => default, +} + +export const Error: Story = { + render: () => error, +} + +export const Warning: Story = { + render: () => warning, +} + +export const Success: Story = { + render: () => success, +} + +export const Outline: Story = { + render: () => outline, +} diff --git a/apps/mobile/src/components/Tag/Tag.tsx b/apps/mobile/src/components/Tag/Tag.tsx new file mode 100644 index 0000000000..d569899535 --- /dev/null +++ b/apps/mobile/src/components/Tag/Tag.tsx @@ -0,0 +1,40 @@ +import { styled, Text } from 'tamagui' + +export const Tag = styled(Text, { + paddingHorizontal: '$2', + paddingVertical: '$1', + alignSelf: 'flex-start', + backgroundColor: '$backgroundSecondary', + borderRadius: '$4', + + color: '$color', + + variants: { + success: { + true: { + backgroundColor: '$backgroundSuccess', + color: '$success', + }, + }, + warning: { + true: { + backgroundColor: '$backgroundWarning', + color: '$warning', + }, + }, + error: { + true: { + backgroundColor: '$backgroundError', + color: '$error', + }, + }, + outlined: { + true: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: '$colorOutline', + color: '$colorOutline', + }, + }, + }, +}) diff --git a/apps/mobile/src/components/Tag/index.ts b/apps/mobile/src/components/Tag/index.ts new file mode 100644 index 0000000000..b9d13fdf9b --- /dev/null +++ b/apps/mobile/src/components/Tag/index.ts @@ -0,0 +1 @@ +export { Tag } from './Tag' diff --git a/apps/mobile/src/components/Title/SectionTitle.tsx b/apps/mobile/src/components/Title/SectionTitle.tsx index 4e18691166..f28ab65507 100644 --- a/apps/mobile/src/components/Title/SectionTitle.tsx +++ b/apps/mobile/src/components/Title/SectionTitle.tsx @@ -4,7 +4,7 @@ import { LargeHeaderTitle } from './LargeHeaderTitle' interface SectionTitleProps { title: string - description: string + description?: string paddingHorizontal?: GetThemeValueForKey<'paddingHorizontal'> } @@ -15,7 +15,7 @@ export function SectionTitle({ title, description, paddingHorizontal = '$3' }: S {title} - {description} + {description && {description}} ) } diff --git a/apps/mobile/src/components/TokenAmount/TokenAmount.tsx b/apps/mobile/src/components/TokenAmount/TokenAmount.tsx new file mode 100644 index 0000000000..25a6b5db97 --- /dev/null +++ b/apps/mobile/src/components/TokenAmount/TokenAmount.tsx @@ -0,0 +1,56 @@ +import { type ReactElement } from 'react' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' +import { TransferDirection } from '@safe-global/store/gateway/types' +import { Text, TextProps } from 'tamagui' +import { TransferTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ellipsis } from '@/src/utils/formatters' + +const PRECISION = 20 + +interface TokenAmountProps { + value: string + decimals?: number | null + tokenSymbol?: string + direction?: TransferTransactionInfo['direction'] + preciseAmount?: boolean + textProps?: TextProps + displayPositiveSign?: boolean + testID?: string +} + +export const TokenAmount = ({ + value, + decimals, + tokenSymbol, + direction, + preciseAmount, + textProps, + displayPositiveSign, + testID, +}: TokenAmountProps): ReactElement => { + const getSign = (): string => { + if (direction === TransferDirection.OUTGOING) { + return '-' + } + return displayPositiveSign ? '+' : '' + } + + const formatAmount = (): string => { + if (decimals === undefined || decimals === null) { + return ellipsis(value, 10) + } + + const formattedAmount = formatVisualAmount(value, decimals, preciseAmount ? PRECISION : undefined) + + return ellipsis(formattedAmount, 10) + } + + return ( + + {getSign()} + {formatAmount()} {tokenSymbol} + + ) +} + +export default TokenAmount diff --git a/apps/mobile/src/components/TokenAmount/index.ts b/apps/mobile/src/components/TokenAmount/index.ts new file mode 100644 index 0000000000..b795a6941e --- /dev/null +++ b/apps/mobile/src/components/TokenAmount/index.ts @@ -0,0 +1 @@ +export { TokenAmount } from './TokenAmount' diff --git a/apps/mobile/src/components/TokenIcon/TokenIcon.test.tsx b/apps/mobile/src/components/TokenIcon/TokenIcon.test.tsx new file mode 100644 index 0000000000..d1a89654ce --- /dev/null +++ b/apps/mobile/src/components/TokenIcon/TokenIcon.test.tsx @@ -0,0 +1,48 @@ +import { render } from '@/src/tests/test-utils' +import { TokenIcon } from './TokenIcon' + +describe('TokenIcon', () => { + it('should render with optimized CoinGecko URLs', () => { + const thumbnailUrl = 'https://coin-images.coingecko.com/coins/images/25244/thumb/Optimism.png' + const container = render() + + const logoImage = container.getByTestId('logo-image') + expect(logoImage.props.source.uri).toBe('https://coin-images.coingecko.com/coins/images/25244/large/Optimism.png') + }) + + it('should pass through non-CoinGecko URLs unchanged', () => { + const regularUrl = 'https://example.com/token-logo.png' + const container = render() + + const logoImage = container.getByTestId('logo-image') + expect(logoImage.props.source.uri).toBe(regularUrl) + }) + + it('should render fallback icon when no logoUri is provided', () => { + const container = render() + + expect(container.queryByTestId('logo-image')).not.toBeTruthy() + expect(container.queryByTestId('logo-fallback-icon')).toBeTruthy() + }) + + it('should use token icon as default fallback', () => { + const container = render() + + // Just verify the fallback icon is rendered - the default fallback for TokenIcon should be 'token' + expect(container.getByTestId('logo-fallback-icon')).toBeTruthy() + }) + + it('should pass all props to Logo component', () => { + const props = { + logoUri: 'https://example.com/logo.png', + accessibilityLabel: 'Custom token', + size: '$8', + imageBackground: '$blue', + fallbackIcon: 'nft' as const, + } + + const container = render() + + expect(container.getByLabelText('Custom token')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/TokenIcon/TokenIcon.tsx b/apps/mobile/src/components/TokenIcon/TokenIcon.tsx new file mode 100644 index 0000000000..e4a4c628ae --- /dev/null +++ b/apps/mobile/src/components/TokenIcon/TokenIcon.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { Logo } from '../Logo/Logo' +import { BadgeThemeTypes } from '../Logo/Logo' +import { IconProps } from '../SafeFontIcon/SafeFontIcon' +import { upgradeCoinGeckoThumbToQuality } from '@safe-global/utils/utils/image' + +interface TokenIconProps { + logoUri?: string | null + accessibilityLabel?: string + fallbackIcon?: IconProps['name'] + imageBackground?: string + size?: string + badgeContent?: React.ReactElement + badgeThemeName?: BadgeThemeTypes +} + +export function TokenIcon({ + logoUri, + accessibilityLabel, + size = '$10', + imageBackground = '$color', + fallbackIcon = 'token', + badgeContent, + badgeThemeName = 'badge_background', +}: TokenIconProps) { + const optimizedLogoUri = React.useMemo(() => { + return upgradeCoinGeckoThumbToQuality(logoUri, 'large') + }, [logoUri]) + + return ( + + ) +} diff --git a/apps/mobile/src/components/TokenIcon/index.ts b/apps/mobile/src/components/TokenIcon/index.ts new file mode 100644 index 0000000000..bb0ee0e495 --- /dev/null +++ b/apps/mobile/src/components/TokenIcon/index.ts @@ -0,0 +1,2 @@ +import { TokenIcon } from './TokenIcon' +export { TokenIcon } diff --git a/apps/mobile/src/components/TxInfo/TxInfo.tsx b/apps/mobile/src/components/TxInfo/TxInfo.tsx index 8921e3865d..e3cdd134fb 100644 --- a/apps/mobile/src/components/TxInfo/TxInfo.tsx +++ b/apps/mobile/src/components/TxInfo/TxInfo.tsx @@ -1,5 +1,4 @@ -import React from 'react' -import { TransactionInfoType } from '@safe-global/store/gateway/types' +import React, { useCallback } from 'react' import { type Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' import { useTransactionType } from '@/src/hooks/useTransactionType' import { TxTokenCard } from '@/src/components/transactions-list/Card/TxTokenCard' @@ -9,93 +8,142 @@ import { isCreationTxInfo, isCustomTxInfo, isMultiSendTxInfo, + isOrderTxInfo, isSettingsChangeTxInfo, - isSwapOrderTxInfo, + isStakingTxDepositInfo, + isStakingTxExitInfo, + isStakingTxWithdrawInfo, isTransferTxInfo, + isVaultDepositTxInfo, + isVaultRedeemTxInfo, + isBridgeOrderTxInfo, + isLifiSwapTxInfo, } from '@/src/utils/transaction-guards' import { TxBatchCard } from '@/src/components/transactions-list/Card/TxBatchCard' import { TxSafeAppCard } from '@/src/components/transactions-list/Card/TxSafeAppCard' import { TxRejectionCard } from '@/src/components/transactions-list/Card/TxRejectionCard' import { TxContractInteractionCard } from '@/src/components/transactions-list/Card/TxContractInteractionCard' -import { TxSwapCard } from '@/src/components/transactions-list/Card/TxSwapCard' +import { TxOrderCard } from '@/src/components/transactions-list/Card/TxOrderCard' import { TxCreationCard } from '@/src/components/transactions-list/Card/TxCreationCard' +import { TxCardPress } from './types' +import { StakingTxWithdrawCard } from '@/src/components/transactions-list/Card/StakingTxWithdrawCard' +import { StakingTxDepositCard } from '../transactions-list/Card/StakingTxDepositCard' +import { StakingTxExitCard } from '../transactions-list/Card/StakingTxExitCard' +import { VaultTxDepositCard } from '@/src/components/transactions-list/Card/VaultTxDepositCard' +import { VaultTxRedeemCard } from '@/src/components/transactions-list/Card/VaultTxRedeemCard' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' +import { TxBridgeCard } from '@/src/components/transactions-list/Card/TxBridgeCard' +import { TxLifiSwapCard } from '@/src/components/transactions-list/Card/TxLifiSwapCard' -interface TxInfoProps { +type TxInfoProps = { tx: Transaction - bordered?: boolean - inQueue?: boolean -} + onPress?: (tx: TxCardPress) => void +} & Partial -function TxInfoComponent({ tx, bordered, inQueue }: TxInfoProps) { +function TxInfoComponent({ tx, onPress, ...rest }: TxInfoProps) { const txType = useTransactionType(tx) - const txInfo = tx.txInfo + + const onCardPress = useCallback(() => { + if (onPress) { + onPress({ + tx, + type: txType, + }) + } + }, [onPress, tx, txType]) + if (isTransferTxInfo(txInfo)) { return ( ) } if (isSettingsChangeTxInfo(txInfo)) { - return + return } - if (isMultiSendTxInfo(txInfo) && tx.txInfo.type === TransactionInfoType.CUSTOM) { - return ( - - ) + if (isMultiSendTxInfo(txInfo) && !tx.safeAppInfo) { + return } if (isMultiSendTxInfo(txInfo) && tx.safeAppInfo) { return ( ) } if (isCreationTxInfo(txInfo)) { - return + return } if (isCancellationTxInfo(txInfo)) { - return + return } if (isMultiSendTxInfo(txInfo) || isCustomTxInfo(txInfo)) { return ( ) } - if (isSwapOrderTxInfo(txInfo)) { - return + if (isOrderTxInfo(txInfo)) { + return + } + + if (isStakingTxDepositInfo(txInfo)) { + return + } + + if (isStakingTxExitInfo(txInfo)) { + return + } + + if (isStakingTxWithdrawInfo(txInfo)) { + return + } + + if (isVaultDepositTxInfo(txInfo)) { + return + } + + if (isVaultRedeemTxInfo(txInfo)) { + return + } + + if (isBridgeOrderTxInfo(txInfo)) { + return + } + + if (isLifiSwapTxInfo(txInfo)) { + return } return <> } export const TxInfo = React.memo(TxInfoComponent, (prevProps, nextProps) => { - return prevProps.tx.txHash === nextProps.tx.txHash + return ( + prevProps.tx.txHash === nextProps.tx.txHash && + prevProps.bordered === nextProps.bordered && + prevProps.inQueue === nextProps.inQueue + ) }) diff --git a/apps/mobile/src/components/TxInfo/types.ts b/apps/mobile/src/components/TxInfo/types.ts new file mode 100644 index 0000000000..7db6e9a50c --- /dev/null +++ b/apps/mobile/src/components/TxInfo/types.ts @@ -0,0 +1,4 @@ +import { Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TxType } from '@/src/hooks/useTransactionType' + +export type TxCardPress = { tx: Transaction; type?: TxType } diff --git a/apps/mobile/src/components/navigation/TabBarIcon.tsx b/apps/mobile/src/components/navigation/TabBarIcon.tsx index 61c74d38b0..fd3426643c 100644 --- a/apps/mobile/src/components/navigation/TabBarIcon.tsx +++ b/apps/mobile/src/components/navigation/TabBarIcon.tsx @@ -1,5 +1,5 @@ import { SafeFontIcon, IconProps } from '@/src/components/SafeFontIcon/SafeFontIcon' export function TabBarIcon({ name, ...rest }: IconProps) { - return + return } diff --git a/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.test.tsx index 3a5243a62e..d9a03ae954 100644 --- a/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.test.tsx +++ b/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.test.tsx @@ -8,19 +8,20 @@ import { ellipsis } from '@/src/utils/formatters' describe('AccountCard', () => { it('should render the account card with only one chain provided', () => { const accountName = 'This is my account' + const balance = '758.932' const container = render( , ) expect(container.getByTestId('threshold-info-badge')).toBeVisible() expect(container.getByText('2/5')).toBeDefined() - expect(container.getByText(`$${mockedActiveSafeInfo.fiatTotal}`)).toBeDefined() + expect(container.getByText(`$ 758.93`)).toBeDefined() expect(container.getByText(accountName)).toBeDefined() }) @@ -39,7 +40,7 @@ describe('AccountCard', () => { ) expect(container.getByTestId('threshold-info-badge')).toBeVisible() expect(container.getByText('2/5')).toBeDefined() - expect(container.getByText(`$${ellipsis(longBalance, 14)}`)).toBeDefined() + expect(container.getByText(`$ 21,312,321,3...`)).toBeDefined() expect(container.getByText(ellipsis(longAccountName, 18))).toBeDefined() }) }) diff --git a/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx b/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx index 533a306711..462cb4f928 100644 --- a/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx @@ -6,6 +6,10 @@ import { IdenticonWithBadge } from '@/src/features/Settings/components/Identicon import { Address } from '@/src/types/address' import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' import { ChainsDisplay } from '@/src/components/ChainsDisplay' +import { shouldDisplayPreciseBalance } from '@/src/utils/balance' +import { formatCurrency, formatCurrencyPrecise } from '@safe-global/utils/utils/formatNumber' +import { useAppSelector } from '@/src/store/hooks' +import { selectCurrency } from '@/src/store/settingsSlice' interface AccountCardProps { name: string | Address @@ -30,6 +34,11 @@ export function AccountCard({ threshold, rightNode, }: AccountCardProps) { + const currency = useAppSelector(selectCurrency) + const formattedBalance = shouldDisplayPreciseBalance(balance, 8) + ? formatCurrencyPrecise(balance, currency) + : formatCurrency(balance, currency) + return ( - ${ellipsis(balance, 14)} + {ellipsis(formattedBalance, 14)} } @@ -49,7 +58,8 @@ export function AccountCard({ 9 ? 8 : 12} + badgeSize={24} + fontSize={owners > 9 ? 9 : 12} address={address} badgeContent={`${threshold}/${owners}`} /> diff --git a/apps/mobile/src/components/transactions-list/Card/AssetsCard/AssetsCard.tsx b/apps/mobile/src/components/transactions-list/Card/AssetsCard/AssetsCard.tsx index 0ea50ba34d..623533f058 100644 --- a/apps/mobile/src/components/transactions-list/Card/AssetsCard/AssetsCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/AssetsCard/AssetsCard.tsx @@ -1,17 +1,18 @@ import React from 'react' import { Text, View } from 'tamagui' import { SafeListItem } from '@/src/components/SafeListItem' -import { Logo } from '@/src/components/Logo' +import { TokenIcon } from '@/src/components/TokenIcon' import { ellipsis } from '@/src/utils/formatters' interface AssetsCardProps { name: string - description: string + description?: string logoUri?: string | null rightNode?: string | React.ReactNode accessibilityLabel?: string imageBackground?: string transparent?: boolean + onPress?: () => void } export function AssetsCard({ @@ -22,21 +23,32 @@ export function AssetsCard({ accessibilityLabel, rightNode, transparent = true, + onPress, }: AssetsCardProps) { return ( - + {name} - - {description} - + {description && ( + + {description} + + )} } transparent={transparent} - leftNode={} + leftNode={ + + } rightNode={ typeof rightNode === 'string' ? ( @@ -46,6 +58,7 @@ export function AssetsCard({ rightNode ) } + paddingVertical={'$2'} /> ) } diff --git a/apps/mobile/src/components/transactions-list/Card/SignersCard/SignersCard.tsx b/apps/mobile/src/components/transactions-list/Card/SignersCard/SignersCard.tsx index b29e7c6bd3..67dc588b98 100644 --- a/apps/mobile/src/components/transactions-list/Card/SignersCard/SignersCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/SignersCard/SignersCard.tsx @@ -1,31 +1,77 @@ -import React from 'react' -import { Text, View } from 'tamagui' +import React, { useMemo } from 'react' +import { Text, View, type TextProps } from 'tamagui' import { Identicon } from '@/src/components/Identicon' import { SafeListItem } from '@/src/components/SafeListItem' import { EthAddress } from '@/src/components/EthAddress' +import { Address } from '@/src/types/address' type SignersCardProps = { - name: string + name?: string | React.ReactNode address: `0x${string}` rightNode?: React.ReactNode + transparent?: boolean + onPress?: () => void + getSignerTag?: (address: Address) => string | undefined } -export function SignersCard({ name, address, rightNode }: SignersCardProps) { +const descriptionStyle: Partial = { + fontSize: '$4', + color: '$backgroundPress', + fontWeight: 400, +} + +const titleStyle: Partial = { + fontSize: '$4', + fontWeight: 600, +} + +export function SignersCard({ onPress, name, transparent = true, address, rightNode, getSignerTag }: SignersCardProps) { + const textProps = useMemo(() => { + return name ? descriptionStyle : titleStyle + }, [name]) + return ( - - {name} - + {name && ( + + {typeof name === 'string' ? ( + + {name} + + ) : React.isValidElement(name) ? ( + React.cloneElement(name as React.ReactElement<{ textProps?: Partial }>, { + textProps: titleStyle, + }) + ) : ( + name + )} + {getSignerTag?.(address) && ( + + + {getSignerTag?.(address)} + + + )} + + )} - + } leftNode={ - + } rightNode={rightNode} diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/StakingTxDepositCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/StakingTxDepositCard.stories.tsx new file mode 100644 index 0000000000..6956de9ae8 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/StakingTxDepositCard.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { StakingTxDepositCard } from '@/src/components/transactions-list/Card/StakingTxDepositCard' +import { NativeStakingDepositTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TransactionInfoType } from '@safe-global/store/gateway/types' + +// Mock data for NativeStakingDepositTransactionInfo +const mockStakingTxDepositInfo: NativeStakingDepositTransactionInfo = { + type: 'NativeStakingDeposit' as TransactionInfoType.NATIVE_STAKING_DEPOSIT, + humanDescription: 'Deposit tokens for staking', + status: 'ACTIVE', + estimatedEntryTime: Date.now() + 86400000, // 1 day from now + estimatedExitTime: Date.now() + 30 * 86400000, // 30 days from now + estimatedWithdrawalTime: Date.now() + 32 * 86400000, // 32 days from now + fee: 5, // 5% fee + monthlyNrr: 4.2, // 4.2% monthly return + annualNrr: 50.4, // 50.4% annual return + value: '1000000000000000000', // 1 ETH in wei + numValidators: 1, + expectedAnnualReward: '50400000000000000', // 0.0504 ETH + expectedMonthlyReward: '4200000000000000', // 0.0042 ETH + expectedFiatAnnualReward: 151.2, // $151.2 assuming 1 ETH = $300 + expectedFiatMonthlyReward: 12.6, // $12.6 + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', // Common ETH placeholder address + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, + validators: ['0xvalidator1', '0xvalidator2'], +} + +const meta: Meta = { + title: 'TransactionsList/StakingTxDepositCard', + component: StakingTxDepositCard, + argTypes: {}, + args: { + info: mockStakingTxDepositInfo, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const CustomAmount: Story = { + args: { + info: { + ...mockStakingTxDepositInfo, + value: '5000000000000000000', // 5 ETH in wei + }, + }, +} + +export const DifferentToken: Story = { + args: { + info: { + ...mockStakingTxDepositInfo, + tokenInfo: { + address: '0x1111111111', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x5aFE3855358E112B5647B952709E6165e1c1eEEe.png', + name: 'SafeToken', + symbol: 'SAFE', + trusted: true, + }, + }, + }, +} diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/StakingTxDepositCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/StakingTxDepositCard.test.tsx new file mode 100644 index 0000000000..a2acd02654 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/StakingTxDepositCard.test.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { StakingTxDepositCard } from './StakingTxDepositCard' +import { NativeStakingDepositTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +describe('StakingTxDepositCard', () => { + const mockOnPress = jest.fn() + + const mockInfo = { + value: '1000000000000000000', + tokenInfo: { + symbol: 'ETH', + decimals: 18, + logoUri: 'https://example.com/eth-logo.png', + name: 'Ethereum', + address: '0x0000000000000000000000000000000000000000', + trusted: true, + }, + type: 'NativeStakingDeposit', + humanDescription: 'Deposit tokens for staking', + status: 'ACTIVE', + estimatedEntryTime: Date.now() + 86400000, + estimatedExitTime: Date.now() + 30 * 86400000, + estimatedWithdrawalTime: Date.now() + 32 * 86400000, + fee: 5, + monthlyNrr: 4.2, + annualNrr: 50.4, + numValidators: 1, + expectedAnnualReward: '50400000000000000', + expectedMonthlyReward: '4200000000000000', + expectedFiatAnnualReward: 151.2, + expectedFiatMonthlyReward: 12.6, + validators: ['0xvalidator1'], + } as NativeStakingDepositTransactionInfo + + it('renders correctly', () => { + const { toJSON } = render() + expect(toJSON()).toMatchSnapshot() + }) + + it('renders correctly with given info', () => { + const screen = render() + + // Check that important props are passed correctly + expect(screen.getByText('Deposit')).toBeTruthy() + expect(screen.getByText('1 ETH')).toBeTruthy() + expect(screen.getByTestId('logo-image')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/StakingTxDepositCard.tsx b/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/StakingTxDepositCard.tsx new file mode 100644 index 0000000000..363abbcf78 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/StakingTxDepositCard.tsx @@ -0,0 +1,25 @@ +import type { NativeStakingDepositTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TokenAmount } from '@/src/components/TokenAmount' +import { SafeListItem } from '@/src/components/SafeListItem' +import { TokenIcon } from '@/src/components/TokenIcon' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' + +export const StakingTxDepositCard = ({ + info, + ...rest +}: { + info: NativeStakingDepositTransactionInfo +} & Partial) => { + return ( + + } + leftNode={} + {...rest} + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/__snapshots__/StakingTxDepositCard.test.tsx.snap b/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/__snapshots__/StakingTxDepositCard.test.tsx.snap new file mode 100644 index 0000000000..9aa2e0a340 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/__snapshots__/StakingTxDepositCard.test.tsx.snap @@ -0,0 +1,263 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StakingTxDepositCard renders correctly 1`] = ` + + + + + + + + + + + + + +  + + + + + + + + +  + + + Stake + + + + Deposit + + + + + 1 + + ETH + + + + +`; diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/index.tsx new file mode 100644 index 0000000000..e93597362f --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxDepositCard/index.tsx @@ -0,0 +1 @@ +export { StakingTxDepositCard } from './StakingTxDepositCard' diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/StakingTxExitCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/StakingTxExitCard.stories.tsx new file mode 100644 index 0000000000..86c1ed310f --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/StakingTxExitCard.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { StakingTxExitCard } from '@/src/components/transactions-list/Card/StakingTxExitCard' +import { NativeStakingValidatorsExitTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TransactionInfoType } from '@safe-global/store/gateway/types' + +// Mock data for NativeStakingValidatorsExitTransactionInfo +const mockStakingTxExitInfo: NativeStakingValidatorsExitTransactionInfo = { + type: 'NativeStakingValidatorsExit' as TransactionInfoType.NATIVE_STAKING_VALIDATORS_EXIT, + humanDescription: 'Exit staking validators', + numValidators: 2, + status: 'EXITING', + estimatedExitTime: 1703980800, // Unix timestamp for 2023-12-31 + estimatedWithdrawalTime: 1704585600, // Unix timestamp for 2024-01-07 + value: '32000000000000000000', // 32 ETH in wei + validators: ['0xvalidator1', '0xvalidator2'], + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', // Common ETH placeholder address + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, +} + +const meta: Meta = { + title: 'TransactionsList/StakingTxExitCard', + component: StakingTxExitCard, + argTypes: {}, + args: { + info: mockStakingTxExitInfo, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const SingleValidator: Story = { + args: { + info: { + ...mockStakingTxExitInfo, + numValidators: 1, + validators: ['0xvalidator1'], + }, + }, +} + +export const MultipleValidators: Story = { + args: { + info: { + ...mockStakingTxExitInfo, + numValidators: 5, + validators: ['0xvalidator1', '0xvalidator2', '0xvalidator3', '0xvalidator4', '0xvalidator5'], + value: '160000000000000000000', // 160 ETH in wei (5 validators * 32 ETH) + }, + }, +} + +export const DifferentToken: Story = { + args: { + info: { + ...mockStakingTxExitInfo, + tokenInfo: { + address: '0x1111111111', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x5aFE3855358E112B5647B952709E6165e1c1eEEe.png', + name: 'SafeToken', + symbol: 'SAFE', + trusted: true, + }, + }, + }, +} diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/StakingTxExitCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/StakingTxExitCard.test.tsx new file mode 100644 index 0000000000..b78a39e275 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/StakingTxExitCard.test.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { render, fireEvent } from '@/src/tests/test-utils' +import { StakingTxExitCard } from './StakingTxExitCard' +import { NativeStakingValidatorsExitTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +const mockInfo: NativeStakingValidatorsExitTransactionInfo = { + type: 'NativeStakingValidatorsExit', + humanDescription: null, + status: 'ACTIVE', + estimatedExitTime: 1234567890, + estimatedWithdrawalTime: 1234567890, + value: '32000000000000000000', + numValidators: 1, + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, + validators: ['0x123...abc'], +} + +const mockOnPress = jest.fn() + +describe('StakingTxExitCard', () => { + beforeEach(() => { + mockOnPress.mockClear() + }) + + it('matches snapshot', () => { + const { toJSON } = render() + expect(toJSON()).toMatchSnapshot() + }) + + it('renders correctly', () => { + const screen = render() + expect(screen.getByText('Withdraw')).toBeTruthy() + expect(screen.getByText('1 Validator')).toBeTruthy() + }) + + it('renders multiple validators correctly', () => { + const singleValidatorInfo = { + ...mockInfo, + numValidators: 3, + } + + const screen = render() + expect(screen.getByText('Withdraw')).toBeTruthy() + expect(screen.getByText('3 Validators')).toBeTruthy() + }) + + it('calls onPress when pressed', () => { + const screen = render() + + const card = screen.getByText('Withdraw') + + fireEvent.press(card) + + expect(mockOnPress).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/StakingTxExitCard.tsx b/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/StakingTxExitCard.tsx new file mode 100644 index 0000000000..f1a7e15093 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/StakingTxExitCard.tsx @@ -0,0 +1,27 @@ +import type { NativeStakingValidatorsExitTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { SafeListItem } from '@/src/components/SafeListItem' +import { TokenIcon } from '@/src/components/TokenIcon' +import { maybePlural } from '@safe-global/utils/utils/formatters' +import { Text } from 'tamagui' + +interface StakingTxExitCardProps { + info: NativeStakingValidatorsExitTransactionInfo + onPress: () => void +} + +export const StakingTxExitCard = ({ info, onPress }: StakingTxExitCardProps) => { + return ( + + {info.numValidators} Validator{maybePlural(info.numValidators)} + + } + leftNode={} + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/__snapshots__/StakingTxExitCard.test.tsx.snap b/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/__snapshots__/StakingTxExitCard.test.tsx.snap new file mode 100644 index 0000000000..f82794b4f8 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/__snapshots__/StakingTxExitCard.test.tsx.snap @@ -0,0 +1,263 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StakingTxExitCard matches snapshot 1`] = ` + + + + + + + + + + + + + +  + + + + + + + + +  + + + Stake + + + + Withdraw + + + + + 1 + Validator + + + + +`; diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/index.tsx new file mode 100644 index 0000000000..2d8ad9734d --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxExitCard/index.tsx @@ -0,0 +1 @@ +export { StakingTxExitCard } from './StakingTxExitCard' diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/StakingTxWithdrawCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/StakingTxWithdrawCard.stories.tsx new file mode 100644 index 0000000000..8d34e00e5b --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/StakingTxWithdrawCard.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { StakingTxWithdrawCard } from '@/src/components/transactions-list/Card/StakingTxWithdrawCard' +import { NativeStakingWithdrawTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TransactionInfoType } from '@safe-global/store/gateway/types' + +// Mock data for NativeStakingWithdrawTransactionInfo +const mockStakingTxWithdrawInfo: NativeStakingWithdrawTransactionInfo = { + type: 'NativeStakingWithdraw' as TransactionInfoType.NATIVE_STAKING_WITHDRAW, + humanDescription: 'Withdraw staked tokens', + value: '1000000000000000000', // 1 ETH in wei + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', // Common ETH placeholder address + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, + validators: ['0xvalidator1', '0xvalidator2'], +} + +const meta: Meta = { + title: 'TransactionsList/StakingTxWithdrawCard', + component: StakingTxWithdrawCard, + argTypes: {}, + args: { + info: mockStakingTxWithdrawInfo, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const CustomAmount: Story = { + args: { + info: { + ...mockStakingTxWithdrawInfo, + value: '5000000000000000000', // 5 ETH in wei + }, + }, +} + +export const DifferentToken: Story = { + args: { + info: { + ...mockStakingTxWithdrawInfo, + tokenInfo: { + address: '0x1111111111', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x5aFE3855358E112B5647B952709E6165e1c1eEEe.png', + name: 'SafeToken', + symbol: 'SAFE', + trusted: true, + }, + }, + }, +} diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/StakingTxWithdrawCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/StakingTxWithdrawCard.test.tsx new file mode 100644 index 0000000000..cfbdd6624c --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/StakingTxWithdrawCard.test.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { render, fireEvent } from '@/src/tests/test-utils' +import { StakingTxWithdrawCard } from './StakingTxWithdrawCard' +import { NativeStakingWithdrawTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +const mockInfo: NativeStakingWithdrawTransactionInfo = { + type: 'NativeStakingWithdraw', + humanDescription: null, + value: '32000000000000000000', + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, + validators: ['0x123...abc'], +} + +const mockOnPress = jest.fn() + +describe('StakingTxWithdrawCard', () => { + beforeEach(() => { + mockOnPress.mockClear() + }) + + it('matches snapshot', () => { + const { toJSON } = render() + expect(toJSON()).toMatchSnapshot() + }) + + it('renders correctly', () => { + const screen = render() + expect(screen.getByText('Claim')).toBeTruthy() + expect(screen.getByTestId('token-amount')).toBeTruthy() + }) + + it('calls onPress when pressed', () => { + const screen = render() + const card = screen.getByText('Claim') + + fireEvent.press(card) + + expect(mockOnPress).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/StakingTxWithdrawCard.tsx b/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/StakingTxWithdrawCard.tsx new file mode 100644 index 0000000000..a7fafe8991 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/StakingTxWithdrawCard.tsx @@ -0,0 +1,29 @@ +import { SafeListItem } from '@/src/components/SafeListItem' +import { TokenAmount } from '@/src/components/TokenAmount' +import { NativeStakingWithdrawTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TokenIcon } from '@/src/components/TokenIcon' + +interface StakingTxWithdrawCardProps { + info: NativeStakingWithdrawTransactionInfo + onPress: () => void +} + +export const StakingTxWithdrawCard = ({ info, onPress }: StakingTxWithdrawCardProps) => { + return ( + + } + leftNode={} + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/__snapshots__/StakingTxWithdrawCard.test.tsx.snap b/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/__snapshots__/StakingTxWithdrawCard.test.tsx.snap new file mode 100644 index 0000000000..ddceb5cc9c --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/__snapshots__/StakingTxWithdrawCard.test.tsx.snap @@ -0,0 +1,264 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StakingTxWithdrawCard matches snapshot 1`] = ` + + + + + + + + + + + + + +  + + + + + + + + +  + + + Stake + + + + Claim + + + + + 32 + + ETH + + + + +`; diff --git a/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/index.tsx new file mode 100644 index 0000000000..03af84ee93 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/StakingTxWithdrawCard/index.tsx @@ -0,0 +1 @@ +export { StakingTxWithdrawCard } from './StakingTxWithdrawCard' diff --git a/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.test.tsx index 6031fc4d82..667015ff41 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.test.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.test.tsx @@ -7,6 +7,7 @@ describe('TxBatchCard', () => { it('should render the default markup', () => { const container = render( null} txInfo={ mockTransferWithInfo({ type: TransactionInfoType.CUSTOM, diff --git a/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.tsx index c82326cd32..d451a4be57 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/TxBatchCard.tsx @@ -1,40 +1,28 @@ import React from 'react' -import { Avatar, View } from 'tamagui' +import { Theme, View } from 'tamagui' import { SafeListItem } from '@/src/components/SafeListItem' import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' import type { MultiSend } from '@safe-global/store/gateway/types' -import type { Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' -interface TxBatchCardProps { +type TxBatchCardProps = { txInfo: MultiSend - bordered?: boolean - label?: string - inQueue?: boolean - executionInfo?: Transaction['executionInfo'] -} - -export function TxBatchCard({ txInfo, bordered, executionInfo, inQueue, label }: TxBatchCardProps) { - const logoUri = txInfo.to.logoUri +} & Partial +export function TxBatchCard({ txInfo, ...rest }: TxBatchCardProps) { return ( - {logoUri && } - - - - - - - + + + + + } + {...rest} /> ) } diff --git a/apps/mobile/src/components/transactions-list/Card/TxBatchCard/__snapshots__/TxBatchCard.test.tsx.snap b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/__snapshots__/TxBatchCard.test.tsx.snap index b1905cb637..808fd2c755 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxBatchCard/__snapshots__/TxBatchCard.test.tsx.snap +++ b/apps/mobile/src/components/transactions-list/Card/TxBatchCard/__snapshots__/TxBatchCard.test.tsx.snap @@ -3,18 +3,27 @@ exports[`TxBatchCard should render the default markup 1`] = ` @@ -36,84 +45,56 @@ exports[`TxBatchCard should render the default markup 1`] = ` style={ { "alignItems": "center", - "borderBottomLeftRadius": 100000, - "borderBottomRightRadius": 100000, - "borderTopLeftRadius": 100000, - "borderTopRightRadius": 100000, - "flexDirection": "column", - "height": 40, - "justifyContent": "center", - "maxHeight": 40, - "maxWidth": 40, - "minHeight": 40, - "minWidth": 40, - "overflow": "hidden", - "paddingBottom": 0, - "paddingLeft": 0, - "paddingRight": 0, - "paddingTop": 0, - "position": "relative", - "width": 40, + "flexDirection": "row", + "gap": 12, + "maxWidth": "100%", } } > - + > +  + - + @@ -123,8 +104,8 @@ exports[`TxBatchCard should render the default markup 1`] = ` style={ [ { - "color": "#121312", - "fontSize": 24, + "color": "#A1A3A7", + "fontSize": 10, }, undefined, { @@ -135,70 +116,39 @@ exports[`TxBatchCard should render the default markup 1`] = ` {}, ] } + testID="safe-list-batch-icon" > -  +  - - - - - - -  - + "fontFamily": "DM Sans", + "fontSize": 12, + "lineHeight": 20, + } + } + suppressHighlighting={true} + > + Batch + + - Batch + 2 actions - - 2 actions - diff --git a/apps/mobile/src/components/transactions-list/Card/TxBridgeCard/TxBridgeCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxBridgeCard/TxBridgeCard.tsx new file mode 100644 index 0000000000..2f9c5ebcb2 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxBridgeCard/TxBridgeCard.tsx @@ -0,0 +1,98 @@ +import { SafeListItem } from '@/src/components/SafeListItem' +import { Text, Theme, View } from 'tamagui' +import { ellipsis } from '@/src/utils/formatters' +import { TokenIcon } from '@/src/components/TokenIcon' +import React from 'react' +import { BridgeAndSwapTransactionInfo, Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { formatUnits } from 'ethers' +import { ChainIndicator } from '@/src/components/ChainIndicator' + +interface TxBridgeCardProps { + txInfo: BridgeAndSwapTransactionInfo + bordered?: boolean + inQueue?: boolean + executionInfo?: Transaction['executionInfo'] + onPress: () => void +} + +export function TxBridgeCard({ txInfo, bordered, executionInfo, inQueue, onPress }: TxBridgeCardProps) { + const actualFromAmount = + BigInt(txInfo.fromAmount) + BigInt(txInfo.fees?.integratorFee ?? 0n) + BigInt(txInfo.fees?.lifiFee ?? 0n) + + const fromAmountFormatted = formatUnits(actualFromAmount, txInfo.fromToken.decimals) + const toAmountFormatted = + txInfo.toAmount && txInfo.toToken ? formatUnits(txInfo.toAmount, txInfo.toToken.decimals) : '' + + const statusText = (() => { + switch (txInfo.status) { + case 'PENDING': + case 'AWAITING_EXECUTION': + return 'Pending' + case 'FAILED': + return 'Failed' + case 'DONE': + return 'Completed' + default: + return 'Bridge' + } + })() + + return ( + + + {txInfo.fromToken.symbol} + + + + {txInfo.toToken?.symbol && {txInfo.toToken?.symbol}} + + + + } + icon="transaction-swap" + type={`${statusText} bridge`} + executionInfo={executionInfo} + bordered={bordered} + onPress={onPress} + inQueue={inQueue} + leftNode={ + + + + + + {txInfo.toToken && ( + + + + )} + + + } + rightNode={ + + {(txInfo.toAmount || txInfo.toToken) && ( + + +{ellipsis(toAmountFormatted, 10)} {txInfo.toToken?.symbol ?? ''} + + )} + + −{ellipsis(fromAmountFormatted, 10)} {txInfo.fromToken.symbol} + + + } + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/TxBridgeCard/index.ts b/apps/mobile/src/components/transactions-list/Card/TxBridgeCard/index.ts new file mode 100644 index 0000000000..52aae60040 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxBridgeCard/index.ts @@ -0,0 +1 @@ +export { TxBridgeCard } from './TxBridgeCard' diff --git a/apps/mobile/src/components/transactions-list/Card/TxConflictingCard/TxConflictingCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxConflictingCard/TxConflictingCard.tsx index a0ca3f53d9..92f4e4aa22 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxConflictingCard/TxConflictingCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxConflictingCard/TxConflictingCard.tsx @@ -1,29 +1,59 @@ -import React from 'react' +import React, { useCallback } from 'react' import { Theme, View } from 'tamagui' import { TxInfo } from '@/src/components/TxInfo' import { Alert } from '@/src/components/Alert' import { TransactionQueuedItem } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TxCardPress } from '@/src/components/TxInfo/types' +import { TouchableOpacity, useColorScheme } from 'react-native' interface TxConflictingCard { transactions: TransactionQueuedItem[] inQueue?: boolean + onPress: (transaction?: TxCardPress) => void } -function TxConflictingComponent({ transactions, inQueue }: TxConflictingCard) { +function TxConflictingComponent({ transactions, inQueue, onPress }: TxConflictingCard) { + const colorScheme = useColorScheme() + const isDark = colorScheme === 'dark' + + const handleConflictTxPress = useCallback( + (transaction?: TransactionQueuedItem) => { + if (transaction) { + onPress({ + tx: transaction.transaction, + }) + } + }, + [onPress], + ) + return ( - <> - - - + + onPress()}> + + + + {transactions.map((item, index) => ( - - + + handleConflictTxPress(item)} + bordered={false} + /> ))} - + ) } diff --git a/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.test.tsx index 99725f1fd7..4d4fde383a 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.test.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.test.tsx @@ -8,6 +8,7 @@ describe('TxContractInteractionCard', () => { it('should render the default markup', () => { const { getByText, getByLabelText } = render( null} txInfo={ mockTransferWithInfo({ type: TransactionInfoType.CUSTOM, @@ -28,6 +29,7 @@ describe('TxContractInteractionCard', () => { it('should render a fallback in the label and icon if the contract is missing name and logoUri', () => { const { getByText } = render( null} txInfo={ mockTransferWithInfo({ type: TransactionInfoType.CUSTOM, diff --git a/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.tsx index 75c08f7dd7..1bfa9a0b1a 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxContractInteractionCard/TxContractInteractionCard.tsx @@ -1,47 +1,38 @@ import React from 'react' -import { Avatar, Text, Theme, View } from 'tamagui' +import { Text, Theme } from 'tamagui' import { SafeListItem } from '@/src/components/SafeListItem' -import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' import { MultiSend } from '@safe-global/store/gateway/types' -import { Transaction, CustomTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { CustomTransactionInfo, SafeAppInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' +import { Logo } from '@/src/components/Logo' -interface TxContractInteractionCardProps { - bordered?: boolean +type TxContractInteractionCardProps = { txInfo: CustomTransactionInfo | MultiSend - inQueue?: boolean - executionInfo?: Transaction['executionInfo'] -} + safeAppInfo?: SafeAppInfo | null +} & Partial + +export function TxContractInteractionCard({ txInfo, safeAppInfo, ...rest }: TxContractInteractionCardProps) { + const logoUri = safeAppInfo?.logoUri || txInfo.to.logoUri + const label = safeAppInfo?.name || txInfo.to.name || 'Contract interaction' -export function TxContractInteractionCard({ - bordered, - executionInfo, - txInfo, - inQueue, -}: TxContractInteractionCardProps) { - const logoUri = txInfo.to.logoUri - const label = txInfo.to.name || 'Contract interaction' return ( - - {logoUri && } - - - - - - - - + + + } rightNode={{txInfo.methodName}} + {...rest} /> ) } diff --git a/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.test.tsx index 7d20459925..984d73e416 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.test.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.test.tsx @@ -8,6 +8,7 @@ describe('TxCreationCard', () => { it('should render the default markup', () => { const { getByText } = render( null} txInfo={ mockTransferWithInfo({ type: TransactionInfoType.CREATION, diff --git a/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.tsx index 4a052a937b..ab60b6a41c 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxCreationCard/TxCreationCard.tsx @@ -3,30 +3,26 @@ import { Theme, View } from 'tamagui' import { SafeListItem } from '@/src/components/SafeListItem' import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' import { shortenAddress } from '@/src/utils/formatters' -import type { Transaction, CreationTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import type { CreationTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' -interface TxCreationCardProps { +type TxCreationCardProps = { txInfo: CreationTransactionInfo - bordered?: boolean - inQueue?: boolean - executionInfo?: Transaction['executionInfo'] -} +} & Partial -export function TxCreationCard({ txInfo, executionInfo, bordered, inQueue }: TxCreationCardProps) { +export function TxCreationCard({ txInfo, ...rest }: TxCreationCardProps) { return ( - + } + {...rest} /> ) } diff --git a/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.tsx index 0cebc6e7c5..01930eb0b4 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.tsx @@ -40,7 +40,6 @@ function TxGroupedCardComponent({ transactions, inQueue }: TxGroupedCard) { } - rightNode={} > {transactions.map((item, index) => ( diff --git a/apps/mobile/src/components/transactions-list/Card/TxLifiSwapCard/TxLifiSwapCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxLifiSwapCard/TxLifiSwapCard.tsx new file mode 100644 index 0000000000..d75cf01148 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxLifiSwapCard/TxLifiSwapCard.tsx @@ -0,0 +1,64 @@ +import { SafeListItem } from '@/src/components/SafeListItem' +import { Text, Theme, View } from 'tamagui' +import { ellipsis, formatValue } from '@/src/utils/formatters' +import { TokenIcon } from '@/src/components/TokenIcon' +import React from 'react' +import { SwapTransactionInfo, Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +interface TxLifiSwapCardProps { + txInfo: SwapTransactionInfo + bordered?: boolean + inQueue?: boolean + executionInfo?: Transaction['executionInfo'] + onPress: () => void +} + +export function TxLifiSwapCard({ txInfo, bordered, executionInfo, inQueue, onPress }: TxLifiSwapCardProps) { + const fromAmountFormatted = formatValue(txInfo.fromAmount, txInfo.fromToken.decimals) + const toAmountFormatted = formatValue(txInfo.toAmount, txInfo.toToken.decimals) + + return ( + + + + + + + + + + + + } + rightNode={ + + + +{ellipsis(toAmountFormatted, 10)} {txInfo.toToken.symbol} + + + −{ellipsis(fromAmountFormatted, 10)} {txInfo.fromToken.symbol} + + + } + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/TxLifiSwapCard/index.ts b/apps/mobile/src/components/transactions-list/Card/TxLifiSwapCard/index.ts new file mode 100644 index 0000000000..fc359da694 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxLifiSwapCard/index.ts @@ -0,0 +1 @@ +export { TxLifiSwapCard } from './TxLifiSwapCard' diff --git a/apps/mobile/src/components/transactions-list/Card/TxOrderCard/SellOrder.tsx b/apps/mobile/src/components/transactions-list/Card/TxOrderCard/SellOrder.tsx new file mode 100644 index 0000000000..0d0f0a6c09 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxOrderCard/SellOrder.tsx @@ -0,0 +1,59 @@ +import { SafeListItem } from '@/src/components/SafeListItem' +import { Text, Theme, View } from 'tamagui' +import { ellipsis, formatValue } from '@/src/utils/formatters' +import { TokenIcon } from '@/src/components/TokenIcon' +import React from 'react' +import { + SwapOrderTransactionInfo, + SwapTransferTransactionInfo, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' + +type TxSellOrderCardProps = { + order: SwapOrderTransactionInfo | SwapTransferTransactionInfo + type: string +} & Partial + +export function SellOrder({ order, type, ...rest }: TxSellOrderCardProps) { + return ( + ${order.buyToken.symbol}`} + icon="transaction-swap" + type={type} + leftNode={ + + + + + + + + + + + + } + rightNode={ + + + ~{ellipsis(formatValue(order.buyAmount, order.buyToken.decimals), 10)} {order.buyToken.symbol} + + + −{ellipsis(formatValue(order.sellAmount, order.sellToken.decimals), 10)} {order.sellToken.symbol} + + + } + {...rest} + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/TxOrderCard/TwapOrder.tsx b/apps/mobile/src/components/transactions-list/Card/TxOrderCard/TwapOrder.tsx new file mode 100644 index 0000000000..07617954d4 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxOrderCard/TwapOrder.tsx @@ -0,0 +1,55 @@ +import { TwapOrderTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { SafeListItem } from '@/src/components/SafeListItem' +import { Text, Theme, View } from 'tamagui' +import { ellipsis, formatValue } from '@/src/utils/formatters' +import { TokenIcon } from '@/src/components/TokenIcon' +import React from 'react' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' + +type TxTwappOrderCardProps = { + order: TwapOrderTransactionInfo +} & Partial + +export const TwapOrder = ({ order, ...rest }: TxTwappOrderCardProps) => { + return ( + ${order.buyToken.symbol}`} + icon="transaction-swap" + type="Twap order" + leftNode={ + + + + + + + + + + + + } + rightNode={ + + + ~{ellipsis(formatValue(order.buyAmount, order.buyToken.decimals), 10)} {order.buyToken.symbol} + + + −{ellipsis(formatValue(order.sellAmount, order.sellToken.decimals), 10)} {order.sellToken.symbol} + + + } + {...rest} + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.stories.tsx b/apps/mobile/src/components/transactions-list/Card/TxOrderCard/TxOrderCard.stories.tsx similarity index 65% rename from apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.stories.tsx rename to apps/mobile/src/components/transactions-list/Card/TxOrderCard/TxOrderCard.stories.tsx index 47ab945cba..8d954804bf 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.stories.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxOrderCard/TxOrderCard.stories.tsx @@ -1,11 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react' -import { TxSwapCard } from '@/src/components/transactions-list/Card/TxSwapCard' +import { TxOrderCard } from './TxOrderCard' import { mockSwapTransfer } from '@/src/tests/mocks' import { OrderTransactionInfo } from '@safe-global/store/gateway/types' -const meta: Meta = { +const meta: Meta = { title: 'TransactionsList/TxSwapCard', - component: TxSwapCard, + component: TxOrderCard, args: { txInfo: mockSwapTransfer as OrderTransactionInfo, }, @@ -13,6 +13,6 @@ const meta: Meta = { export default meta -type Story = StoryObj +type Story = StoryObj export const Default: Story = {} diff --git a/apps/mobile/src/components/transactions-list/Card/TxOrderCard/TxOrderCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/TxOrderCard/TxOrderCard.test.tsx new file mode 100644 index 0000000000..b4dd184462 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxOrderCard/TxOrderCard.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from '@/src/tests/test-utils' +import { TxOrderCard } from '.' +import { OrderTransactionInfo } from '@safe-global/store/gateway/types' +import { mockSwapOrder, mockSwapTransfer, mockTwapOrder } from '@/src/tests/mocks' +import { DetailedExecutionInfoType } from '@safe-global/store/gateway/types' +import { MultisigExecutionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TransactionInfoType } from '@safe-global/store/gateway/types' + +describe('TxOrderCard', () => { + it('should render SwapTransfer correctly', () => { + render( null} txInfo={mockSwapTransfer as OrderTransactionInfo} />) + + expect(screen.getByText(`${mockSwapTransfer.sellToken.symbol} > ${mockSwapTransfer.buyToken.symbol}`)).toBeTruthy() + expect(screen.getByText('Swap order')).toBeTruthy() + expect(screen.getByTestId('safe-list-transaction-swap-icon')).toBeTruthy() + }) + + it('should render TwapOrder correctly', () => { + render( null} txInfo={mockTwapOrder as OrderTransactionInfo} />) + + expect(screen.getByText(`${mockTwapOrder.sellToken.symbol} > ${mockTwapOrder.buyToken.symbol}`)).toBeTruthy() + expect(screen.getByText('Twap order')).toBeTruthy() + expect(screen.getByTestId('safe-list-transaction-swap-icon')).toBeTruthy() + }) + + it('should render SwapOrder with market order class correctly', () => { + render( null} txInfo={mockSwapOrder as OrderTransactionInfo} />) + + expect(screen.getByText(`${mockSwapOrder.sellToken.symbol} > ${mockSwapOrder.buyToken.symbol}`)).toBeTruthy() + expect(screen.getByText('Swap order')).toBeTruthy() + expect(screen.getByTestId('safe-list-transaction-swap-icon')).toBeTruthy() + }) + + it('should render Limit order class correctly', () => { + const limitOrder = { + ...mockSwapOrder, + type: TransactionInfoType.SWAP_ORDER, + fullAppData: { + metadata: { + orderClass: { + orderClass: 'limit', + }, + }, + }, + } + + render( null} txInfo={limitOrder as OrderTransactionInfo} />) + + expect(screen.getByText(`${limitOrder.sellToken.symbol} > ${limitOrder.buyToken.symbol}`)).toBeTruthy() + expect(screen.getByText('Limit order')).toBeTruthy() + expect(screen.getByTestId('safe-list-transaction-swap-icon')).toBeTruthy() + }) + + it('should handle bordered prop correctly', () => { + render( null} txInfo={mockSwapTransfer as OrderTransactionInfo} bordered />) + + expect(screen.getByText(`${mockSwapTransfer.sellToken.symbol} > ${mockSwapTransfer.buyToken.symbol}`)).toBeTruthy() + expect(screen.getByText('Swap order')).toBeTruthy() + }) + + it('should handle inQueue prop correctly', () => { + render( null} txInfo={mockSwapTransfer as OrderTransactionInfo} inQueue />) + + expect(screen.getByText(`${mockSwapTransfer.sellToken.symbol} > ${mockSwapTransfer.buyToken.symbol}`)).toBeTruthy() + expect(screen.getByText('Swap order')).toBeTruthy() + }) + + it('should handle executionInfo prop correctly', () => { + const executionInfo: MultisigExecutionInfo = { + type: DetailedExecutionInfoType.MULTISIG, + nonce: 123, + confirmationsRequired: 2, + confirmationsSubmitted: 1, + } + + render( + null} + txInfo={mockSwapTransfer as OrderTransactionInfo} + executionInfo={executionInfo} + inQueue + />, + ) + + expect(screen.getByText('1/2')).toBeTruthy() + }) + + it('should return null when txInfo is not provided', () => { + render( null} txInfo={null as unknown as OrderTransactionInfo} />) + + expect(screen.queryByText(/SAFE|ETH|Twap|Limit|Swap/)).toBeNull() + }) +}) diff --git a/apps/mobile/src/components/transactions-list/Card/TxOrderCard/TxOrderCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxOrderCard/TxOrderCard.tsx new file mode 100644 index 0000000000..a99f9dfcb1 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxOrderCard/TxOrderCard.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { OrderTransactionInfo } from '@safe-global/store/gateway/types' +import { isSwapOrderTxInfo, isSwapTransferOrderTxInfo, isTwapOrderTxInfo } from '@/src/utils/transaction-guards' +import { SellOrder } from '@/src/components/transactions-list/Card/TxOrderCard/SellOrder' +import { TwapOrder } from '@/src/components/transactions-list/Card/TxOrderCard/TwapOrder' +import { getOrderClass } from '@/src/hooks/useTransactionType' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' + +type TxSwapCardProps = { + txInfo: OrderTransactionInfo +} & Partial + +export function TxOrderCard({ txInfo, ...rest }: TxSwapCardProps) { + if (!txInfo) { + return null + } + + if (isTwapOrderTxInfo(txInfo)) { + return + } + + if (isSwapOrderTxInfo(txInfo) || isSwapTransferOrderTxInfo(txInfo)) { + const orderClass = getOrderClass(txInfo) + const type = orderClass === 'limit' ? 'Limit order' : 'Swap order' + + return + } + return null +} diff --git a/apps/mobile/src/components/transactions-list/Card/TxOrderCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/TxOrderCard/index.tsx new file mode 100644 index 0000000000..19c190f435 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/TxOrderCard/index.tsx @@ -0,0 +1,2 @@ +import { TxOrderCard } from './TxOrderCard' +export { TxOrderCard } diff --git a/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/TxRejectionCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/TxRejectionCard.test.tsx index c11f4f3bee..792327ae64 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/TxRejectionCard.test.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/TxRejectionCard.test.tsx @@ -5,7 +5,9 @@ import { Cancellation } from '@safe-global/store/gateway/types' describe('TxRejectionCard', () => { it('should render the default markup', () => { - const { getByText } = render() + const { getByText } = render( + null} txInfo={mockTransferWithInfo({}) as Cancellation} />, + ) expect(getByText('Rejected')).toBeTruthy() }) diff --git a/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/TxRejectionCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/TxRejectionCard.tsx index afff4d9f7e..d59ef5455a 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/TxRejectionCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxRejectionCard/TxRejectionCard.tsx @@ -4,27 +4,24 @@ import { SafeListItem } from '@/src/components/SafeListItem' import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' import type { Cancellation } from '@safe-global/store/gateway/types' import type { Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' -interface TxRejectionCardProps { - bordered?: boolean +type TxRejectionCardProps = { txInfo: Cancellation - inQueue?: boolean executionInfo?: Transaction['executionInfo'] -} +} & Partial -export function TxRejectionCard({ bordered, executionInfo, txInfo, inQueue }: TxRejectionCardProps) { +export function TxRejectionCard({ txInfo, ...rest }: TxRejectionCardProps) { return ( - + } + {...rest} /> ) } diff --git a/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/TxSafeAppCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/TxSafeAppCard.test.tsx index 051785e262..6eb4d9e774 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/TxSafeAppCard.test.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/TxSafeAppCard.test.tsx @@ -7,6 +7,7 @@ describe('TxSafeAppCard', () => { it('should render the default markup', () => { const { getByText } = render( null} safeAppInfo={{ name: 'Transaction Builder', url: 'http://something.com', @@ -23,6 +24,7 @@ describe('TxSafeAppCard', () => { it('should render a fallback if no image url is provided', () => { const { getByText, getByTestId, queryByTestId } = render( null} safeAppInfo={{ name: 'Transaction Builder', url: 'http://something.com', @@ -33,7 +35,7 @@ describe('TxSafeAppCard', () => { expect(getByText('Transaction Builder')).toBeTruthy() expect(getByText('Safe app')).toBeTruthy() - expect(queryByTestId('safe-app-image')).not.toBeTruthy() - expect(getByTestId('safe-app-fallback')).toBeTruthy() + expect(queryByTestId('logo-image')).not.toBeTruthy() + expect(getByTestId('logo-fallback-icon')).toBeTruthy() }) }) diff --git a/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/TxSafeAppCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/TxSafeAppCard.tsx index 6b4850f8fc..ee4aba60c2 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/TxSafeAppCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxSafeAppCard/TxSafeAppCard.tsx @@ -1,41 +1,33 @@ import React from 'react' -import { Avatar, Text, View } from 'tamagui' +import { Text } from 'tamagui' import { SafeListItem } from '@/src/components/SafeListItem' -import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' import type { MultiSend } from '@safe-global/store/gateway/types' -import type { SafeAppInfo, Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import type { SafeAppInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' +import { Logo } from '@/src/components/Logo' -interface TxSafeAppCardProps { +type TxSafeAppCardProps = { safeAppInfo: SafeAppInfo txInfo: MultiSend - bordered?: boolean - inQueue?: boolean - executionInfo?: Transaction['executionInfo'] -} +} & Partial -export function TxSafeAppCard({ safeAppInfo, executionInfo, txInfo, inQueue, bordered }: TxSafeAppCardProps) { +export function TxSafeAppCard({ safeAppInfo, txInfo, ...rest }: TxSafeAppCardProps) { return ( - {safeAppInfo.logoUri && ( - - )} - - - - - - - + } rightNode={{txInfo.methodName}} + {...rest} /> ) } diff --git a/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingCard.test.tsx index d6e9a98a87..d71eeb7715 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingCard.test.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingCard.test.tsx @@ -8,6 +8,7 @@ describe('TxSettingCard', () => { it('should render the default markup', () => { const container = render( null} txInfo={ mockTransferWithInfo({ type: TransactionInfoType.SETTINGS_CHANGE, diff --git a/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingsCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingsCard.tsx index d0dbab637e..591b24012e 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingsCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/TxSettingsCard.tsx @@ -1,35 +1,37 @@ -import React from 'react' +import React, { useCallback } from 'react' import { Theme, View } from 'tamagui' import { SafeListItem } from '@/src/components/SafeListItem' import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' import { SettingsInfoType } from '@safe-global/store/gateway/types' import { SettingsChangeTransaction, Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' -interface TxSettingsCardProps { +type TxSettingsCardProps = { txInfo: SettingsChangeTransaction - bordered?: boolean - inQueue?: boolean - executionInfo?: Transaction['executionInfo'] -} + onPress: (tx: Transaction) => void +} & Partial -export function TxSettingsCard({ txInfo, bordered, executionInfo, inQueue }: TxSettingsCardProps) { +export function TxSettingsCard({ txInfo, onPress, ...rest }: TxSettingsCardProps) { const isDeleteGuard = txInfo.settingsInfo?.type === SettingsInfoType.DELETE_GUARD const label = isDeleteGuard ? 'deleteGuard' : txInfo.dataDecoded.method + const handleOnPress = useCallback(() => { + onPress({ txInfo } as Transaction) + }, [onPress, txInfo]) + return ( - + } + {...rest} /> ) } diff --git a/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/__snapshots__/TxSettingCard.test.tsx.snap b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/__snapshots__/TxSettingCard.test.tsx.snap index 700971ad3c..1b952b57e5 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/__snapshots__/TxSettingCard.test.tsx.snap +++ b/apps/mobile/src/components/transactions-list/Card/TxSettingsCard/__snapshots__/TxSettingCard.test.tsx.snap @@ -3,18 +3,27 @@ exports[`TxSettingCard should render the default markup 1`] = ` - -  - - - +  + + + + + + Settings change + + + - Settings change + mockMethod - - mockMethod - diff --git a/apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.test.tsx deleted file mode 100644 index 94e4414277..0000000000 --- a/apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.test.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { render } from '@/src/tests/test-utils' -import { TxSwapCard } from '.' -import { OrderTransactionInfo } from '@safe-global/store/gateway/types' -import { mockSwapTransfer } from '@/src/tests/mocks' - -describe('TxSwapCard', () => { - it('should render the default markup', () => { - const container = render() - - expect(container).toMatchSnapshot() - }) -}) diff --git a/apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.tsx deleted file mode 100644 index bc01210024..0000000000 --- a/apps/mobile/src/components/transactions-list/Card/TxSwapCard/TxSwapCard.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react' -import { Avatar, Text, Theme, View } from 'tamagui' -import { SafeListItem } from '@/src/components/SafeListItem' -import { formatValue } from '@/src/utils/formatters' -import { OrderTransactionInfo } from '@safe-global/store/gateway/types' -import { Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' - -interface TxSwapCardProps { - txInfo: OrderTransactionInfo - bordered?: boolean - inQueue?: boolean - executionInfo?: Transaction['executionInfo'] -} - -export function TxSwapCard({ txInfo, bordered, executionInfo, inQueue }: TxSwapCardProps) { - return ( - ${txInfo.buyToken.symbol}`} - icon="transaction-swap" - type="Swap order" - executionInfo={executionInfo} - bordered={bordered} - inQueue={inQueue} - leftNode={ - - - - {txInfo.sellToken.logoUri && ( - - )} - - - - - {txInfo.buyToken.logoUri && ( - - )} - - - - - } - rightNode={ - - - +{formatValue(txInfo.buyAmount, txInfo.buyToken.decimals)} {txInfo.buyToken.symbol} - - - −{formatValue(txInfo.sellAmount, txInfo.sellToken.decimals)} {txInfo.sellToken.symbol} - - - } - /> - ) -} diff --git a/apps/mobile/src/components/transactions-list/Card/TxSwapCard/__snapshots__/TxSwapCard.test.tsx.snap b/apps/mobile/src/components/transactions-list/Card/TxSwapCard/__snapshots__/TxSwapCard.test.tsx.snap deleted file mode 100644 index ceb175feaf..0000000000 --- a/apps/mobile/src/components/transactions-list/Card/TxSwapCard/__snapshots__/TxSwapCard.test.tsx.snap +++ /dev/null @@ -1,290 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TxSwapCard should render the default markup 1`] = ` - - - - - - - - - - - - - - - - - - - - -  - - - Swap order - - - - SAFE > ETH - - - - - - + - 0.05 - - ETH - - - − - 0.05 - - SAFE - - - - -`; diff --git a/apps/mobile/src/components/transactions-list/Card/TxSwapCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/TxSwapCard/index.tsx deleted file mode 100644 index 4c95c2e35b..0000000000 --- a/apps/mobile/src/components/transactions-list/Card/TxSwapCard/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { TxSwapCard } from './TxSwapCard' -export { TxSwapCard } diff --git a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx index c66158c3dd..e693b28d6a 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx @@ -1,102 +1,45 @@ import React from 'react' -import { Text, View } from 'tamagui' import { SafeListItem } from '@/src/components/SafeListItem' -import { - isERC20Transfer, - isERC721Transfer, - isNativeTokenTransfer, - isOutgoingTransfer, - isTxQueued, -} from '@/src/utils/transaction-guards' -import { ellipsis, formatValue } from '@/src/utils/formatters' +import { isERC721Transfer, isOutgoingTransfer, isTxQueued } from '@/src/utils/transaction-guards' import { TransferDirection } from '@safe-global/store/gateway/types' import { TransferTransactionInfo, Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' -import { Logo } from '@/src/components/Logo' -import { selectActiveChainCurrency } from '@/src/store/chains' -import { useAppSelector } from '@/src/store/hooks' +import { TokenIcon } from '@/src/components/TokenIcon' +import { useTokenDetails } from '@/src/hooks/useTokenDetails' +import { TokenAmount } from '@/src/components/TokenAmount' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' -interface TxTokenCardProps { - bordered?: boolean +type TxTokenCardProps = { txStatus: Transaction['txStatus'] - inQueue?: boolean txInfo: TransferTransactionInfo - executionInfo?: Transaction['executionInfo'] -} - -interface tokenDetails { - value: string - decimals?: number - tokenSymbol?: string - name: string - logoUri?: string -} - -const getTokenDetails = (txInfo: TransferTransactionInfo): tokenDetails => { - const transfer = txInfo.transferInfo - const unnamedToken = 'Unnamed token' - const nativeCurrency = useAppSelector(selectActiveChainCurrency) - - if (isNativeTokenTransfer(transfer)) { - return { - value: formatValue(transfer.value || '0', nativeCurrency.decimals), - // take it from the native currency slice - decimals: nativeCurrency.decimals, - tokenSymbol: nativeCurrency.symbol, - name: nativeCurrency.name, - logoUri: nativeCurrency.logoUri, - } - } - - if (isERC20Transfer(transfer)) { - return { - value: formatValue(transfer.value, transfer.decimals || 18), - decimals: transfer.decimals || undefined, - logoUri: transfer.logoUri || undefined, - tokenSymbol: ellipsis((transfer.tokenSymbol || 'Unknown Token').trim(), 6), - name: transfer.tokenName || unnamedToken, - } - } - - if (isERC721Transfer(transfer)) { - return { - name: transfer.tokenName || unnamedToken, - tokenSymbol: ellipsis(`${transfer.tokenSymbol || 'Unknown NFT'} #${transfer.tokenId}`, 8), - value: '1', - decimals: 0, - logoUri: transfer?.logoUri || undefined, - } - } - - return { - name: unnamedToken, - value: '', - } -} +} & Partial -export function TxTokenCard({ bordered, inQueue, txStatus, executionInfo, txInfo }: TxTokenCardProps) { +export function TxTokenCard({ inQueue, txStatus, txInfo, ...rest }: TxTokenCardProps) { const isSendTx = isOutgoingTransfer(txInfo) const icon = isSendTx ? 'transaction-outgoing' : 'transaction-incoming' const type = isSendTx ? (isTxQueued(txStatus) ? 'Send' : 'Sent') : 'Received' - const { logoUri, name, value, tokenSymbol } = getTokenDetails(txInfo) + const { logoUri, name, value, tokenSymbol, decimals } = useTokenDetails(txInfo) const isERC721 = isERC721Transfer(txInfo.transferInfo) const isOutgoing = txInfo.direction === TransferDirection.OUTGOING return ( : name} icon={icon} type={type} - bordered={bordered} - leftNode={} + leftNode={} rightNode={ - - - {isOutgoing ? '-' : '+'} {ellipsis(value, 8)} {!isERC721 && tokenSymbol} - - + } + {...rest} /> ) } diff --git a/apps/mobile/src/components/transactions-list/Card/VaultTxDepositCard/VaultTxDepositCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/VaultTxDepositCard/VaultTxDepositCard.test.tsx new file mode 100644 index 0000000000..4d5d8c4db1 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/VaultTxDepositCard/VaultTxDepositCard.test.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { VaultTxDepositCard } from './VaultTxDepositCard' +import { VaultDepositTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +describe('VaultTxDepositCard', () => { + const mockInfo = { + type: 'VaultDeposit', + humanDescription: null, + value: '1000000', + baseNrr: 4.02541446685791, + fee: 0.15000000596046448, + tokenInfo: { + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + decimals: 6, + logoUri: 'https://example.com/eth-logo.png', + name: 'USD Coin', + symbol: 'USDC', + trusted: true, + }, + vaultInfo: { + address: '0x390D077f8E60ffb58805420edc635670AA4f34C3', + name: 'Morpho Steakhouse', + description: + 'The Steakhouse Morpho vault aims to optimize yields by lending USDC against blue chip crypto and real world asset (RWA) collateral markets. Performance Fees: 15%.', + dashboardUri: 'https://app.morpho.org/base/vault/0xbeeF010f9cb27031ad51e3333f9aF9C6B1228183/steakhouse-usdc', + logoUri: 'https://example.com/eth-logo.png', + }, + currentReward: '0', + additionalRewardsNrr: 1.2086049318313599, + additionalRewards: [ + { + tokenInfo: { + address: '0xBAa5CC21fd487B8Fcc2F632f3F4E8D37262a0842', + decimals: 18, + logoUri: 'https://example.com/eth-logo.png', + name: 'Morpho Token', + symbol: 'MORPHO', + trusted: true, + }, + nrr: 1.2086049318313599, + claimable: '0', + claimableNext: '0', + }, + ], + expectedMonthlyReward: '436.16828322410583', + expectedAnnualReward: '5234.01939868927', + } as VaultDepositTransactionInfo + + it('renders correctly', () => { + const { toJSON } = render() + expect(toJSON()).toMatchSnapshot() + }) + + it('renders correctly with given info', () => { + const screen = render() + + // Check that important props are passed correctly + expect(screen.getByText('Deposit')).toBeTruthy() + expect(screen.getByText('1 USDC')).toBeTruthy() + expect(screen.getByTestId('logo-image')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/transactions-list/Card/VaultTxDepositCard/VaultTxDepositCard.tsx b/apps/mobile/src/components/transactions-list/Card/VaultTxDepositCard/VaultTxDepositCard.tsx new file mode 100644 index 0000000000..40540d33c3 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/VaultTxDepositCard/VaultTxDepositCard.tsx @@ -0,0 +1,31 @@ +import { VaultDepositTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TokenAmount } from '@/src/components/TokenAmount' +import { SafeListItem } from '@/src/components/SafeListItem' +import { TokenIcon } from '@/src/components/TokenIcon' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' + +type VaultTxDepositCardProps = { + info: VaultDepositTransactionInfo +} & Partial + +export const VaultTxDepositCard = ({ info, ...rest }: VaultTxDepositCardProps) => { + return ( + + } + leftNode={ + + } + {...rest} + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/VaultTxDepositCard/__snapshots__/VaultTxDepositCard.test.tsx.snap b/apps/mobile/src/components/transactions-list/Card/VaultTxDepositCard/__snapshots__/VaultTxDepositCard.test.tsx.snap new file mode 100644 index 0000000000..62a365add6 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/VaultTxDepositCard/__snapshots__/VaultTxDepositCard.test.tsx.snap @@ -0,0 +1,254 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VaultTxDepositCard renders correctly 1`] = ` + + + + + + + + + + + + + +  + + + + + + + + +  + + + Earn + + + + Deposit + + + + + 1 + + USDC + + + + +`; diff --git a/apps/mobile/src/components/transactions-list/Card/VaultTxDepositCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/VaultTxDepositCard/index.tsx new file mode 100644 index 0000000000..3c10cc481e --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/VaultTxDepositCard/index.tsx @@ -0,0 +1 @@ +export { VaultTxDepositCard } from './VaultTxDepositCard' diff --git a/apps/mobile/src/components/transactions-list/Card/VaultTxRedeemCard/VaultTxRedeemCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/VaultTxRedeemCard/VaultTxRedeemCard.test.tsx new file mode 100644 index 0000000000..77c88058f9 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/VaultTxRedeemCard/VaultTxRedeemCard.test.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { VaultTxRedeemCard } from './VaultTxRedeemCard' +import { VaultRedeemTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +describe('VaultTxRedeemCard', () => { + const mockInfo = { + type: 'VaultRedeem', + humanDescription: null, + value: '1000000', + baseNrr: 4.02541446685791, + fee: 0.15000000596046448, + tokenInfo: { + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + decimals: 6, + logoUri: 'https://example.com/eth-logo.png', + name: 'USD Coin', + symbol: 'USDC', + trusted: true, + }, + vaultInfo: { + address: '0x390D077f8E60ffb58805420edc635670AA4f34C3', + name: 'Morpho Steakhouse', + description: + 'The Steakhouse Morpho vault aims to optimize yields by lending USDC against blue chip crypto and real world asset (RWA) collateral markets. Performance Fees: 15%.', + dashboardUri: 'https://app.morpho.org/base/vault/0xbeeF010f9cb27031ad51e3333f9aF9C6B1228183/steakhouse-usdc', + logoUri: 'https://example.com/eth-logo.png', + }, + currentReward: '9978', + additionalRewardsNrr: 1.2086049318313599, + additionalRewards: [ + { + tokenInfo: { + address: '0xBAa5CC21fd487B8Fcc2F632f3F4E8D37262a0842', + decimals: 18, + logoUri: 'https://example.com/eth-logo.png', + name: 'Morpho Token', + symbol: 'MORPHO', + trusted: true, + }, + nrr: 1.2086049318313599, + claimable: '0', + claimableNext: '0', + }, + ], + } as VaultRedeemTransactionInfo + + it('renders correctly', () => { + const { toJSON } = render() + expect(toJSON()).toMatchSnapshot() + }) + + it('renders correctly with given info', () => { + const screen = render() + + // Check that important props are passed correctly + expect(screen.getByText('Withdraw')).toBeTruthy() + expect(screen.getByText('1 USDC')).toBeTruthy() + expect(screen.getByTestId('logo-image')).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/transactions-list/Card/VaultTxRedeemCard/VaultTxRedeemCard.tsx b/apps/mobile/src/components/transactions-list/Card/VaultTxRedeemCard/VaultTxRedeemCard.tsx new file mode 100644 index 0000000000..8603be94ce --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/VaultTxRedeemCard/VaultTxRedeemCard.tsx @@ -0,0 +1,24 @@ +import { VaultRedeemTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TokenAmount } from '@/src/components/TokenAmount' +import { SafeListItem } from '@/src/components/SafeListItem' +import { TokenIcon } from '@/src/components/TokenIcon' +import { SafeListItemProps } from '@/src/components/SafeListItem/SafeListItem' + +type VaultTxRedeemCardProps = { + info: VaultRedeemTransactionInfo +} & Partial + +export const VaultTxRedeemCard = ({ info, ...rest }: VaultTxRedeemCardProps) => { + return ( + + } + leftNode={} + {...rest} + /> + ) +} diff --git a/apps/mobile/src/components/transactions-list/Card/VaultTxRedeemCard/__snapshots__/VaultTxRedeemCard.test.tsx.snap b/apps/mobile/src/components/transactions-list/Card/VaultTxRedeemCard/__snapshots__/VaultTxRedeemCard.test.tsx.snap new file mode 100644 index 0000000000..5232a1f2f7 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/VaultTxRedeemCard/__snapshots__/VaultTxRedeemCard.test.tsx.snap @@ -0,0 +1,254 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VaultTxRedeemCard renders correctly 1`] = ` + + + + + + + + + + + + + +  + + + + + + + + +  + + + Earn + + + + Withdraw + + + + + 1 + + USDC + + + + +`; diff --git a/apps/mobile/src/components/transactions-list/Card/VaultTxRedeemCard/index.tsx b/apps/mobile/src/components/transactions-list/Card/VaultTxRedeemCard/index.tsx new file mode 100644 index 0000000000..3bf9c998c6 --- /dev/null +++ b/apps/mobile/src/components/transactions-list/Card/VaultTxRedeemCard/index.tsx @@ -0,0 +1 @@ +export { VaultTxRedeemCard } from './VaultTxRedeemCard' diff --git a/apps/mobile/src/config/constants.ts b/apps/mobile/src/config/constants.ts index 158774b6b7..b825899026 100644 --- a/apps/mobile/src/config/constants.ts +++ b/apps/mobile/src/config/constants.ts @@ -3,13 +3,31 @@ import { Platform } from 'react-native' // export const isProduction = process.env.NODE_ENV === 'production' // TODO: put it to get from process.env.NODE_ENV once we remove the mocks for the user account. -export const isProduction = true +export const isProduction = process.env.EXPO_PUBLIC_APP_VARIANT === 'production' export const isAndroid = Platform.OS === 'android' export const isTestingEnv = process.env.NODE_ENV === 'test' export const isStorybookEnv = Constants?.expoConfig?.extra?.storybookEnabled === 'true' export const POLLING_INTERVAL = 15_000 +export const COMING_SOON_MESSAGE = 'This feature is coming soon.' +export const COMING_SOON_TITLE = 'Coming soon' + export const GATEWAY_URL_PRODUCTION = - process.env.NEXT_PUBLIC_GATEWAY_URL_PRODUCTION || 'https://safe-client.safe.global' -export const GATEWAY_URL_STAGING = process.env.NEXT_PUBLIC_GATEWAY_URL_STAGING || 'https://safe-client.staging.5afe.dev' + process.env.EXPO_PUBLIC_GATEWAY_URL_PRODUCTION || 'https://safe-client.safe.global' +export const GATEWAY_URL_STAGING = process.env.EXPO_PUBLIC_GATEWAY_URL_STAGING || 'https://safe-client.staging.5afe.dev' export const GATEWAY_URL = isProduction ? GATEWAY_URL_PRODUCTION : GATEWAY_URL_STAGING + +export const SECURITY_CERTIFICATE_HASH_BASE64 = process.env.EXPO_PUBLIC_SECURITY_SERTIFICATE_HASH_BASE64 +export const SECURITY_WATCHER_MAIL = process.env.EXPO_PUBLIC_SECURITY_WATCHER_MAIL +export const SECURITY_RASP_ENABLED = process.env.EXPO_PUBLIC_SECURITY_RASP_ENABLED === 'true' + +/** + * The version of the onboarding flow. + * If we change it and need all users to see it again, we can bump the version here. + */ +export const ONBOARDING_VERSION = 'v1' + +export const SAFE_WEB_URL = 'https://app.safe.global' +export const SAFE_WEB_TRANSACTIONS_URL = `${SAFE_WEB_URL}/transactions/tx?safe=:safeAddressWithChainPrefix&id=:txId` +export const SAFE_WEB_FEEDBACK_URL = + 'https://docs.google.com/forms/d/e/1FAIpQLSfJXkNNsZqVtg3w3dwk-YrTNutQ00n3MMfLtH-dN8zSHaJu5Q/viewform?usp=dialog' diff --git a/apps/mobile/src/context/GuardProvider.tsx b/apps/mobile/src/context/GuardProvider.tsx new file mode 100644 index 0000000000..9af7d14f97 --- /dev/null +++ b/apps/mobile/src/context/GuardProvider.tsx @@ -0,0 +1,49 @@ +import React, { createContext, useContext, useState, ReactNode } from 'react' + +interface GuardContextType { + guards: Record + setGuard: (guardType: string, value: boolean) => void + getGuard: (guardType: string) => boolean + resetGuard: (guardType: string) => void + resetAllGuards: () => void +} + +const GuardContext = createContext(undefined) + +export const useGuard = () => { + const context = useContext(GuardContext) + if (!context) { + throw new Error('useGuard must be used within a GuardProvider') + } + return context +} + +interface GuardProviderProps { + children: ReactNode +} + +export const GuardProvider: React.FC = ({ children }) => { + const [guards, setGuards] = useState>({}) + + const setGuard = (guardType: string, value: boolean) => { + setGuards((prev) => ({ ...prev, [guardType]: value })) + } + + const getGuard = (guardType: string) => { + return guards[guardType] || false + } + + const resetGuard = (guardType: string) => { + setGuards((prev) => ({ ...prev, [guardType]: false })) + } + + const resetAllGuards = () => { + setGuards({}) + } + + return ( + + {children} + + ) +} diff --git a/apps/mobile/src/context/NotificationsContext.tsx b/apps/mobile/src/context/NotificationsContext.tsx index e002d79ba8..f7f3d27fdf 100644 --- a/apps/mobile/src/context/NotificationsContext.tsx +++ b/apps/mobile/src/context/NotificationsContext.tsx @@ -1,12 +1,9 @@ import React, { createContext, useContext, ReactNode } from 'react' -import useNotifications from '@/src/hooks/useNotifications' -import { FirebaseMessagingTypes } from '@react-native-firebase/messaging' - +import { selectAppNotificationStatus } from '../store/notificationsSlice' +import { useAppSelector } from '../store/hooks' interface NotificationContextType { isAppNotificationEnabled: boolean - fcmToken: string | null - remoteMessages: FirebaseMessagingTypes.RemoteMessage[] | [] } const NotificationContext = createContext(undefined) @@ -24,14 +21,7 @@ interface NotificationProviderProps { } export const NotificationsProvider: React.FC = ({ children }) => { - /** - * Enables notifications for the app if the user has enabled them - */ - const { isAppNotificationEnabled, fcmToken, remoteMessages } = useNotifications() + const isAppNotificationEnabled = useAppSelector(selectAppNotificationStatus) - return ( - - {children} - - ) + return {children} } diff --git a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.stories.tsx b/apps/mobile/src/features/AccountsSheet/AccountItem/AccountItem.stories.tsx similarity index 96% rename from apps/mobile/src/features/Assets/components/AccountItem/AccountItem.stories.tsx rename to apps/mobile/src/features/AccountsSheet/AccountItem/AccountItem.stories.tsx index 26b24209d4..a94dc424e9 100644 --- a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.stories.tsx +++ b/apps/mobile/src/features/AccountsSheet/AccountItem/AccountItem.stories.tsx @@ -1,5 +1,5 @@ import { AccountItem } from './AccountItem' -import { Meta, StoryObj } from '@storybook/react/*' +import { Meta, StoryObj } from '@storybook/react' import { mockedActiveSafeInfo, mockedChains } from '@/src/store/constants' import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' import { action } from '@storybook/addon-actions' diff --git a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx b/apps/mobile/src/features/AccountsSheet/AccountItem/AccountItem.test.tsx similarity index 69% rename from apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx rename to apps/mobile/src/features/AccountsSheet/AccountItem/AccountItem.test.tsx index 70b970a342..4359fbcb14 100644 --- a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx +++ b/apps/mobile/src/features/AccountsSheet/AccountItem/AccountItem.test.tsx @@ -2,11 +2,21 @@ import React from 'react' import { render, screen, fireEvent } from '@/src/tests/test-utils' import { AccountItem } from './AccountItem' import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { faker } from '@faker-js/faker' +import { shortenAddress } from '@/src/utils/formatters' + +jest.mock('expo-router', () => ({ + useNavigation: () => ({ + navigate: jest.fn(), + dispatch: jest.fn(), + }), + useSegments: () => ['test'], // if you use useSegments anywhere +})) const mockAccount = { - address: { value: '0x123' as `0x${string}`, name: 'Test Account' }, + address: { value: faker.finance.ethereumAddress() as `0x${string}`, name: 'Test Account' }, threshold: 1, - owners: [{ value: '0x456' as `0x${string}` }], + owners: [{ value: faker.finance.ethereumAddress() as `0x${string}` }], fiatTotal: '1000', chainId: '1', queued: 0, @@ -55,9 +65,34 @@ describe('AccountItem', () => { />, ) - expect(screen.getByText('Test Account')).toBeTruthy() + expect(screen.getByText(shortenAddress(mockAccount.address.value))).toBeTruthy() + expect(screen.getByText('1/1')).toBeTruthy() + expect(screen.getByText('$ 1,000.00')).toBeTruthy() + }) + + it('renders account details correctly when a contact for the address exists', () => { + render( + , + { + initialStore: { + addressBook: { + contacts: { + [mockAccount.address.value]: { name: 'Test Safe', value: mockAccount.address.value, chainIds: [] }, + }, + selectedContact: null, + }, + }, + }, + ) + + expect(screen.getByText('Test Safe')).toBeTruthy() expect(screen.getByText('1/1')).toBeTruthy() - expect(screen.getByText('$1000')).toBeTruthy() + expect(screen.getByText('$ 1,000.00')).toBeTruthy() }) it('shows active state when account is selected', () => { diff --git a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx b/apps/mobile/src/features/AccountsSheet/AccountItem/AccountItem.tsx similarity index 71% rename from apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx rename to apps/mobile/src/features/AccountsSheet/AccountItem/AccountItem.tsx index 7961fe3a5a..4ac038dae7 100644 --- a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx +++ b/apps/mobile/src/features/AccountsSheet/AccountItem/AccountItem.tsx @@ -1,5 +1,5 @@ -import React, { useMemo } from 'react' -import { StyleSheet, TouchableOpacity } from 'react-native' +import React, { useCallback, useMemo } from 'react' +import { StyleSheet, TouchableOpacity, Alert } from 'react-native' import { View } from 'tamagui' import { SafeFontIcon } from '@/src/components/SafeFontIcon' import { AccountCard } from '@/src/components/transactions-list/Card/AccountCard' @@ -9,6 +9,8 @@ import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { shortenAddress } from '@/src/utils/formatters' import { RenderItemParams } from 'react-native-draggable-flatlist' import { useEditAccountItem } from './hooks/useEditAccountItem' +import { useAppSelector } from '@/src/store/hooks' +import { selectContactByAddress } from '@/src/store/addressBookSlice' interface AccountItemProps { chains: Chain[] @@ -28,15 +30,31 @@ const getRightNodeLayout = (isEdit: boolean, isActive: boolean) => { } export function AccountItem({ account, drag, chains, isDragging, activeAccount, onSelect }: AccountItemProps) { - const { isEdit, onSafeDeleted } = useEditAccountItem() + const { isEdit, deleteSafe } = useEditAccountItem() const isActive = activeAccount === account.address.value - + const contact = useAppSelector(selectContactByAddress(account.address.value)) const handleChainSelect = () => { onSelect(account.address.value) } const rightNode = useMemo(() => getRightNodeLayout(isEdit, isActive), [isEdit, isActive]) + const onDeleteSafePress = useCallback(() => { + Alert.alert('Delete Safe', 'Are you sure you want to delete this safe?', [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + deleteSafe(account.address.value as Address) + }, + }, + ]) + }, [account.address.value, deleteSafe]) + return ( + ) } threshold={account.threshold} owners={account.owners.length} - name={account.address.name || shortenAddress(account.address.value)} + name={contact ? contact.name : shortenAddress(account.address.value)} address={account.address.value as Address} balance={account.fiatTotal} chains={isEdit ? undefined : chains} diff --git a/apps/mobile/src/features/AccountsSheet/AccountItem/hooks/useEditAccountItem.ts b/apps/mobile/src/features/AccountsSheet/AccountItem/hooks/useEditAccountItem.ts new file mode 100644 index 0000000000..bad93ebab5 --- /dev/null +++ b/apps/mobile/src/features/AccountsSheet/AccountItem/hooks/useEditAccountItem.ts @@ -0,0 +1,42 @@ +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { selectMyAccountsMode } from '@/src/store/myAccountsSlice' +import { selectAllSafes } from '@/src/store/safesSlice' +import { selectSigners } from '@/src/store/signersSlice' +import { useDelegateCleanup } from '@/src/hooks/useDelegateCleanup' +import { Address } from '@/src/types/address' +import { useCallback } from 'react' +import { useNavigation } from 'expo-router' +import { handleSafeDeletion } from '../utils/editAccountHelpers' + +export const useEditAccountItem = () => { + const isEdit = useAppSelector(selectMyAccountsMode) + const activeSafe = useAppSelector(selectActiveSafe) + const safes = useAppSelector(selectAllSafes) + const allSigners = useAppSelector(selectSigners) + const dispatch = useAppDispatch() + const navigation = useNavigation() + const { removeAllDelegatesForOwner } = useDelegateCleanup() + + const deleteSafe = useCallback( + async (address: Address) => { + const navigationConfig = { + navigation, + activeSafe, + safes, + dispatch, + } + + await handleSafeDeletion({ + address, + allSafesInfo: safes, + allSigners, + removeAllDelegatesForOwner, + navigationConfig, + }) + }, + [navigation, activeSafe, safes, dispatch, allSigners, removeAllDelegatesForOwner], + ) + + return { isEdit, deleteSafe } +} diff --git a/apps/mobile/src/features/Assets/components/AccountItem/index.ts b/apps/mobile/src/features/AccountsSheet/AccountItem/index.ts similarity index 100% rename from apps/mobile/src/features/Assets/components/AccountItem/index.ts rename to apps/mobile/src/features/AccountsSheet/AccountItem/index.ts diff --git a/apps/mobile/src/features/AccountsSheet/AccountItem/utils/__tests__/editAccountHelpers.test.ts b/apps/mobile/src/features/AccountsSheet/AccountItem/utils/__tests__/editAccountHelpers.test.ts new file mode 100644 index 0000000000..33b7b03ec9 --- /dev/null +++ b/apps/mobile/src/features/AccountsSheet/AccountItem/utils/__tests__/editAccountHelpers.test.ts @@ -0,0 +1,383 @@ +import { + isOwnerInOtherSafes, + getSafeOwnersWithPrivateKeys, + getOwnersToDelete, + createDeletionMessage, + cleanupSinglePrivateKey, + cleanupPrivateKeysForOwners, +} from '../editAccountHelpers' +import { ErrorType } from '@/src/utils/errors' +import { Address } from '@/src/types/address' +import { AppDispatch } from '@/src/store' +import { keyStorageService } from '@/src/services/key-storage' +import { removeSigner } from '@/src/store/signersSlice' +import Logger from '@/src/utils/logger' + +jest.mock('@/src/services/key-storage', () => ({ + keyStorageService: { + getPrivateKey: jest.fn(), + removePrivateKey: jest.fn(), + }, +})) + +jest.mock('@/src/store/signersSlice', () => ({ + removeSigner: jest.fn(), +})) + +jest.mock('@/src/utils/logger', () => ({ + error: jest.fn(), +})) + +describe('editAccountHelpers', () => { + const mockAddress1 = '0x1234567890123456789012345678901234567890' as Address + const mockAddress2 = '0x9876543210987654321098765432109876543210' as Address + const mockAddress3 = '0x1111111111111111111111111111111111111111' as Address + const mockSafeAddress1 = '0x5555555555555555555555555555555555555555' as Address + const mockSafeAddress2 = '0x6666666666666666666666666666666666666666' as Address + + const mockSafesInfo = { + [mockSafeAddress1]: { + deployment1: { + address: { value: mockSafeAddress1 }, + chainId: 'deployment1', + threshold: 1, + owners: [{ value: mockAddress1 }, { value: mockAddress2 }], + fiatTotal: '0', + queued: 0, + }, + }, + [mockSafeAddress2]: { + deployment1: { + address: { value: mockSafeAddress2 }, + chainId: 'deployment1', + threshold: 1, + owners: [{ value: mockAddress2 }, { value: mockAddress3 }], + fiatTotal: '0', + queued: 0, + }, + }, + } + + const mockSigners = { + [mockAddress1]: { + /* private key data */ + }, + [mockAddress2]: { + /* private key data */ + }, + // mockAddress3 has no private key stored + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('isOwnerInOtherSafes', () => { + it('should return true when owner is in other safes', () => { + const result = isOwnerInOtherSafes(mockAddress2, mockSafeAddress1, mockSafesInfo) + expect(result).toBe(true) + }) + + it('should return false when owner is not in other safes', () => { + const result = isOwnerInOtherSafes(mockAddress1, mockSafeAddress1, mockSafesInfo) + expect(result).toBe(false) + }) + + it('should exclude the specified safe address', () => { + const result = isOwnerInOtherSafes(mockAddress3, mockSafeAddress2, mockSafesInfo) + expect(result).toBe(false) + }) + }) + + describe('getSafeOwnersWithPrivateKeys', () => { + it('should return owners that have private keys stored', () => { + const result = getSafeOwnersWithPrivateKeys(mockSafeAddress1, mockSafesInfo, mockSigners) + expect(result).toEqual([mockAddress1, mockAddress2]) + }) + + it('should return empty array for safe with no private keys', () => { + const safeWithNoPrivateKeys = { + [mockSafeAddress1]: { + deployment1: { + address: { value: mockSafeAddress1 }, + chainId: 'deployment1', + threshold: 1, + owners: [{ value: mockAddress3 }], + fiatTotal: '0', + queued: 0, + }, + }, + } + const result = getSafeOwnersWithPrivateKeys(mockSafeAddress1, safeWithNoPrivateKeys, mockSigners) + expect(result).toEqual([]) + }) + + it('should return empty array for non-existent safe', () => { + const result = getSafeOwnersWithPrivateKeys( + '0x999999999999999999999999999999999999999' as Address, + mockSafesInfo, + mockSigners, + ) + expect(result).toEqual([]) + }) + }) + + describe('getOwnersToDelete', () => { + it('should return owners that can be safely deleted', () => { + const result = getOwnersToDelete(mockSafeAddress1, mockSafesInfo, mockSigners) + expect(result).toEqual([mockAddress1]) // mockAddress2 is used in other safes + }) + + it('should return empty array when all owners are used in other safes', () => { + const result = getOwnersToDelete(mockSafeAddress2, mockSafesInfo, mockSigners) + expect(result).toEqual([]) // mockAddress2 is used in other safes, mockAddress3 has no private key + }) + }) + + describe('createDeletionMessage', () => { + it('should create message for partial deletion', () => { + const ownersWithPrivateKeys = [mockAddress1, mockAddress2] + const ownersToDelete = [mockAddress1] + + const result = createDeletionMessage(ownersWithPrivateKeys, ownersToDelete) + + expect(result).toContain('2 owner(s) with private keys') + expect(result).toContain('1 of these private key(s) will be deleted') + expect(result).toContain('1 private key(s) will be kept') + expect(result).toContain('cannot be undone') + }) + + it('should create message for full deletion', () => { + const ownersWithPrivateKeys = [mockAddress1] + const ownersToDelete = [mockAddress1] + + const result = createDeletionMessage(ownersWithPrivateKeys, ownersToDelete) + + expect(result).toContain('1 owner(s) with private keys') + expect(result).toContain('1 of these private key(s) will be deleted') + expect(result).not.toContain('will be kept') + }) + + it('should create message for no deletion', () => { + const ownersWithPrivateKeys = [mockAddress1, mockAddress2] + const ownersToDelete: Address[] = [] + + const result = createDeletionMessage(ownersWithPrivateKeys, ownersToDelete) + + expect(result).toContain('2 owner(s) with private keys') + expect(result).not.toContain('will be deleted') + expect(result).toContain('2 private key(s) will be kept') + }) + }) + + describe('cleanupSinglePrivateKey', () => { + it('should successfully cleanup a single private key', async () => { + const mockDispatch = jest.fn() as unknown as AppDispatch + const mockRemoveAllDelegatesForOwner = jest.fn().mockResolvedValue({ success: true }) + + ;(keyStorageService.getPrivateKey as jest.Mock).mockResolvedValue('private-key-data') + ;(keyStorageService.removePrivateKey as jest.Mock).mockResolvedValue(undefined) + + const result = await cleanupSinglePrivateKey(mockAddress1, mockRemoveAllDelegatesForOwner, mockDispatch) + + expect(result.success).toBe(true) + expect(mockRemoveAllDelegatesForOwner).toHaveBeenCalledWith(mockAddress1, 'private-key-data') + expect(keyStorageService.removePrivateKey).toHaveBeenCalledWith(mockAddress1) + expect(mockDispatch).toHaveBeenCalledWith(removeSigner(mockAddress1)) + }) + + it('should handle missing private key', async () => { + const mockDispatch = jest.fn() as unknown as AppDispatch + const mockRemoveAllDelegatesForOwner = jest.fn() + + ;(keyStorageService.getPrivateKey as jest.Mock).mockResolvedValue(null) + + const result = await cleanupSinglePrivateKey(mockAddress1, mockRemoveAllDelegatesForOwner, mockDispatch) + + expect(result.success).toBe(false) + expect(result.error?.type).toBe(ErrorType.STORAGE_ERROR) + expect(result.error?.message).toBe('Private key not found for the specified address') + expect(mockRemoveAllDelegatesForOwner).not.toHaveBeenCalled() + expect(keyStorageService.removePrivateKey).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should handle delegate removal failure', async () => { + const mockDispatch = jest.fn() as unknown as AppDispatch + const mockRemoveAllDelegatesForOwner = jest.fn().mockResolvedValue({ + success: false, + error: { + message: 'Failed to remove delegates', + type: 'BACKEND_REMOVAL_FAILED', + }, + }) + + ;(keyStorageService.getPrivateKey as jest.Mock).mockResolvedValue('private-key-data') + + const result = await cleanupSinglePrivateKey(mockAddress1, mockRemoveAllDelegatesForOwner, mockDispatch) + + expect(result.success).toBe(false) + expect(result.error?.type).toBe(ErrorType.CLEANUP_ERROR) + expect(result.error?.message).toBe('Failed to remove delegates') + expect(keyStorageService.removePrivateKey).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should handle keychain errors', async () => { + const mockDispatch = jest.fn() as unknown as AppDispatch + const mockRemoveAllDelegatesForOwner = jest.fn() + + ;(keyStorageService.getPrivateKey as jest.Mock).mockRejectedValue(new Error('Keychain error')) + + const result = await cleanupSinglePrivateKey(mockAddress1, mockRemoveAllDelegatesForOwner, mockDispatch) + + expect(result.success).toBe(false) + expect(result.error?.type).toBe(ErrorType.SYSTEM_ERROR) + expect(result.error?.message).toBe('An unexpected error occurred during private key cleanup') + expect(mockRemoveAllDelegatesForOwner).not.toHaveBeenCalled() + expect(keyStorageService.removePrivateKey).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + }) + + describe('cleanupPrivateKeysForOwners', () => { + it('should successfully cleanup private keys for owners', async () => { + const mockDispatch = jest.fn() as unknown as AppDispatch + const mockRemoveAllDelegatesForOwner = jest.fn().mockResolvedValue({ success: true }) + + ;(keyStorageService.getPrivateKey as jest.Mock).mockResolvedValue('private-key-data') + ;(keyStorageService.removePrivateKey as jest.Mock).mockResolvedValue(undefined) + + await cleanupPrivateKeysForOwners([mockAddress1, mockAddress2], mockRemoveAllDelegatesForOwner, mockDispatch) + + expect(mockRemoveAllDelegatesForOwner).toHaveBeenCalledTimes(2) + expect(keyStorageService.removePrivateKey).toHaveBeenCalledTimes(2) + expect(mockDispatch).toHaveBeenCalledTimes(2) + expect(mockDispatch).toHaveBeenCalledWith(removeSigner(mockAddress1)) + expect(mockDispatch).toHaveBeenCalledWith(removeSigner(mockAddress2)) + }) + + it('should handle delegate removal failure gracefully', async () => { + const mockDispatch = jest.fn() as unknown as AppDispatch + const mockRemoveAllDelegatesForOwner = jest.fn().mockResolvedValue({ + success: false, + error: { + message: 'Failed to remove delegates', + type: 'BACKEND_REMOVAL_FAILED', + }, + }) + + ;(keyStorageService.getPrivateKey as jest.Mock).mockResolvedValue('private-key-data') + + await cleanupPrivateKeysForOwners([mockAddress1], mockRemoveAllDelegatesForOwner, mockDispatch) + + expect(Logger.error).toHaveBeenCalledWith( + `Failed to cleanup private key for ${mockAddress1}:`, + expect.objectContaining({ + message: 'Failed to remove delegates', + type: 'CLEANUP_ERROR', + }), + ) + expect(keyStorageService.removePrivateKey).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should handle missing private key gracefully', async () => { + const mockDispatch = jest.fn() as unknown as AppDispatch + const mockRemoveAllDelegatesForOwner = jest.fn() + + ;(keyStorageService.getPrivateKey as jest.Mock).mockResolvedValue(null) + + await cleanupPrivateKeysForOwners([mockAddress1], mockRemoveAllDelegatesForOwner, mockDispatch) + + expect(mockRemoveAllDelegatesForOwner).not.toHaveBeenCalled() + expect(keyStorageService.removePrivateKey).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should handle keychain errors gracefully', async () => { + const mockDispatch = jest.fn() as unknown as AppDispatch + const mockRemoveAllDelegatesForOwner = jest.fn() + + ;(keyStorageService.getPrivateKey as jest.Mock).mockRejectedValue(new Error('Keychain error')) + + await cleanupPrivateKeysForOwners([mockAddress1], mockRemoveAllDelegatesForOwner, mockDispatch) + + expect(Logger.error).toHaveBeenCalledWith( + `Failed to cleanup private key for ${mockAddress1}:`, + expect.objectContaining({ + message: 'An unexpected error occurred during private key cleanup', + type: 'SYSTEM_ERROR', + }), + ) + expect(mockRemoveAllDelegatesForOwner).not.toHaveBeenCalled() + expect(keyStorageService.removePrivateKey).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should handle mixed success and failure scenarios', async () => { + const mockDispatch = jest.fn() as unknown as AppDispatch + const mockRemoveAllDelegatesForOwner = jest + .fn() + .mockResolvedValueOnce({ success: true }) // First call succeeds + .mockResolvedValueOnce({ success: false, error: { message: 'Network error' } }) // Second call fails + + ;(keyStorageService.getPrivateKey as jest.Mock).mockResolvedValue('private-key-data') + ;(keyStorageService.removePrivateKey as jest.Mock).mockResolvedValue(undefined) + + const result = await cleanupPrivateKeysForOwners( + [mockAddress1, mockAddress2], + mockRemoveAllDelegatesForOwner, + mockDispatch, + ) + + expect(result.success).toBe(false) + expect(result.error?.message).toBe('Failed to clean up 1 out of 2 private keys') + expect(result.error?.details?.processedCount).toBe(1) + expect(result.error?.details?.failures).toHaveLength(1) + expect((result.error?.details?.failures as { address: string; error: unknown }[])?.[0]?.address).toBe( + mockAddress2, + ) + expect(mockRemoveAllDelegatesForOwner).toHaveBeenCalledTimes(2) + expect(keyStorageService.removePrivateKey).toHaveBeenCalledTimes(1) // Only successful one + expect(mockDispatch).toHaveBeenCalledTimes(1) // Only successful one + }) + + it('should handle empty owner list', async () => { + const mockDispatch = jest.fn() as unknown as AppDispatch + const mockRemoveAllDelegatesForOwner = jest.fn() + + const result = await cleanupPrivateKeysForOwners([], mockRemoveAllDelegatesForOwner, mockDispatch) + + expect(result.success).toBe(true) + expect(result.data?.processedCount).toBe(0) + expect(result.data?.failures).toHaveLength(0) + expect(mockRemoveAllDelegatesForOwner).not.toHaveBeenCalled() + expect(keyStorageService.getPrivateKey).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should handle all cleanup failures', async () => { + const mockDispatch = jest.fn() as unknown as AppDispatch + const mockRemoveAllDelegatesForOwner = jest.fn().mockResolvedValue({ + success: false, + error: { message: 'All delegates failed' }, + }) + + ;(keyStorageService.getPrivateKey as jest.Mock).mockResolvedValue('private-key-data') + + const result = await cleanupPrivateKeysForOwners( + [mockAddress1, mockAddress2], + mockRemoveAllDelegatesForOwner, + mockDispatch, + ) + + expect(result.success).toBe(false) + expect(result.error?.message).toBe('Failed to clean up 2 out of 2 private keys') + expect(result.error?.details?.processedCount).toBe(0) + expect(result.error?.details?.failures).toHaveLength(2) + expect(keyStorageService.removePrivateKey).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/mobile/src/features/AccountsSheet/AccountItem/utils/editAccountHelpers.ts b/apps/mobile/src/features/AccountsSheet/AccountItem/utils/editAccountHelpers.ts new file mode 100644 index 0000000000..c8dfa8b500 --- /dev/null +++ b/apps/mobile/src/features/AccountsSheet/AccountItem/utils/editAccountHelpers.ts @@ -0,0 +1,287 @@ +import { Address } from '@/src/types/address' +import { AppDispatch } from '@/src/store' +import { removeSigner } from '@/src/store/signersSlice' +import { setActiveSafe } from '@/src/store/activeSafeSlice' +import { removeSafe, SafesSliceItem } from '@/src/store/safesSlice' +import { setEditMode } from '@/src/store/myAccountsSlice' +import { keyStorageService } from '@/src/services/key-storage' +import Logger from '@/src/utils/logger' +import { CommonActions } from '@react-navigation/native' +import { Alert } from 'react-native' +import { StandardErrorResult, ErrorType, createErrorResult, createSuccessResult } from '@/src/utils/errors' + +interface SafesCollection extends Record {} + +interface SignersCollection extends Record {} + +export interface SafeNavigationConfig { + navigation: { + dispatch: (action: ReturnType) => void + } + activeSafe: { address: Address; chainId: string } | null + safes: SafesCollection + dispatch: AppDispatch +} + +export const isOwnerInOtherSafes = ( + ownerAddress: Address, + excludeSafeAddress: Address, + allSafesInfo: SafesCollection, +): boolean => { + return Object.entries(allSafesInfo).some(([safeAddr, safeInfo]) => { + if (safeAddr === excludeSafeAddress) { + return false + } + + return Object.values(safeInfo).some((deployment) => deployment.owners.some((owner) => owner.value === ownerAddress)) + }) +} + +export const getSafeOwnersWithPrivateKeys = ( + safeAddress: Address, + allSafesInfo: SafesCollection, + allSigners: SignersCollection, +): Address[] => { + const safeInfo = allSafesInfo[safeAddress] + if (!safeInfo) { + return [] + } + + const ownersWithPrivateKeys: Address[] = [] + + Object.values(safeInfo).forEach((deployment) => { + deployment.owners.forEach((owner) => { + const hasPrivateKey = !!allSigners[owner.value] + if (hasPrivateKey && !ownersWithPrivateKeys.includes(owner.value as Address)) { + ownersWithPrivateKeys.push(owner.value as Address) + } + }) + }) + + return ownersWithPrivateKeys +} + +export const getOwnersToDelete = ( + safeAddress: Address, + allSafesInfo: SafesCollection, + allSigners: SignersCollection, +): Address[] => { + const ownersWithPrivateKeys = getSafeOwnersWithPrivateKeys(safeAddress, allSafesInfo, allSigners) + + return ownersWithPrivateKeys.filter((ownerAddress) => !isOwnerInOtherSafes(ownerAddress, safeAddress, allSafesInfo)) +} + +export const cleanupSinglePrivateKey = async ( + ownerAddress: Address, + removeAllDelegatesForOwner: ( + ownerAddress: Address, + ownerPrivateKey: string, + ) => Promise>, + dispatch: AppDispatch, +): Promise> => { + try { + const privateKey = await keyStorageService.getPrivateKey(ownerAddress) + if (!privateKey) { + return createErrorResult(ErrorType.STORAGE_ERROR, 'Private key not found for the specified address', null, { + ownerAddress, + }) + } + + // Remove delegates (includes notification cleanup) + const result = await removeAllDelegatesForOwner(ownerAddress, privateKey) + + if (!result.success) { + return createErrorResult( + ErrorType.CLEANUP_ERROR, + result.error?.message || 'Failed to clean up delegates before removing private key', + result.error, + { ownerAddress }, + ) + } + + // Remove private key from keychain + await keyStorageService.removePrivateKey(ownerAddress) + + // Remove from Redux store + dispatch(removeSigner(ownerAddress)) + + return createSuccessResult({ success: true as const }) + } catch (error) { + return createErrorResult(ErrorType.SYSTEM_ERROR, 'An unexpected error occurred during private key cleanup', error, { + ownerAddress, + }) + } +} + +export const cleanupPrivateKeysForOwners = async ( + ownerAddresses: Address[], + removeAllDelegatesForOwner: ( + ownerAddress: Address, + ownerPrivateKey: string, + ) => Promise>, + dispatch: AppDispatch, +): Promise> => { + const failures: { address: Address; error: unknown }[] = [] + + for (const ownerAddress of ownerAddresses) { + const result = await cleanupSinglePrivateKey(ownerAddress, removeAllDelegatesForOwner, dispatch) + + if (!result.success) { + Logger.error(`Failed to cleanup private key for ${ownerAddress}:`, result.error) + failures.push({ address: ownerAddress, error: result.error }) + } + } + + const processedCount = ownerAddresses.length - failures.length + + if (failures.length > 0) { + return createErrorResult( + ErrorType.CLEANUP_ERROR, + `Failed to clean up ${failures.length} out of ${ownerAddresses.length} private keys`, + failures, + { processedCount, failures }, + ) + } + + return createSuccessResult({ processedCount, failures }) +} + +export const createDeletionMessage = (ownersWithPrivateKeys: Address[], ownersToDelete: Address[]): string => { + let message = `This account has ${ownersWithPrivateKeys.length} owner(s) with private keys stored on this device.` + + if (ownersToDelete.length > 0) { + message += ` ${ownersToDelete.length} of these private key(s) will be deleted because they are not used in other safes.` + } + + if (ownersToDelete.length < ownersWithPrivateKeys.length) { + const keysToKeep = ownersWithPrivateKeys.length - ownersToDelete.length + message += ` ${keysToKeep} private key(s) will be kept because they are used as signers in other safes.` + } + + message += ' This action cannot be undone.' + return message +} + +export const proceedWithSafeDeletion = ( + address: Address, + { navigation, activeSafe, safes, dispatch }: SafeNavigationConfig, +): void => { + if (activeSafe?.address === address) { + const [nextAddress, nextInfo] = Object.entries(safes).find(([addr]) => addr !== address) || [null, null] + + if (nextAddress && nextInfo) { + const firstChain = Object.keys(nextInfo)[0] + dispatch( + setActiveSafe({ + address: nextAddress as Address, + chainId: firstChain, + }), + ) + } else { + // If we are here it means that the user has deleted all safes + // We need to reset the navigation to the onboarding screen + // Otherwise the app will crash as there is no active safe + navigation.dispatch( + CommonActions.reset({ + routes: [{ name: 'onboarding' }], + }), + ) + + dispatch(setEditMode(false)) + dispatch(setActiveSafe(null)) + } + } + + dispatch(removeSafe(address)) +} + +interface HandleConfirmedDeletionParams { + address: Address + ownersToDelete: Address[] + removeAllDelegatesForOwner: ( + ownerAddress: Address, + ownerPrivateKey: string, + ) => Promise> + navigationConfig: SafeNavigationConfig + resolve: () => void + reject: (error: Error) => void +} + +const handleConfirmedDeletion = async (params: HandleConfirmedDeletionParams) => { + const { address, ownersToDelete, removeAllDelegatesForOwner, navigationConfig, resolve, reject } = params + try { + if (ownersToDelete.length === 0) { + proceedWithSafeDeletion(address, navigationConfig) + resolve() + return + } + + const cleanupResult = await cleanupPrivateKeysForOwners( + ownersToDelete, + removeAllDelegatesForOwner, + navigationConfig.dispatch, + ) + + if (!cleanupResult.success) { + Logger.error('Failed to clean up private keys during safe deletion:', cleanupResult.error) + Alert.alert('Error', cleanupResult.error?.message || 'Failed to delete private keys. Please try again.') + reject(new Error(cleanupResult.error?.message || 'Failed to delete private keys')) + return + } + + proceedWithSafeDeletion(address, navigationConfig) + resolve() + } catch (error) { + Logger.error('Failed to clean up private keys during safe deletion:', error) + Alert.alert('Error', 'Failed to delete private keys. Please try again.') + reject(error as Error) + } +} + +interface HandleSafeDeletionParams { + address: Address + allSafesInfo: SafesCollection + allSigners: SignersCollection + removeAllDelegatesForOwner: ( + ownerAddress: Address, + ownerPrivateKey: string, + ) => Promise> + navigationConfig: SafeNavigationConfig +} + +export const handleSafeDeletion = async (params: HandleSafeDeletionParams): Promise => { + const { address, allSafesInfo, allSigners, removeAllDelegatesForOwner, navigationConfig } = params + const ownersWithPrivateKeys = getSafeOwnersWithPrivateKeys(address, allSafesInfo, allSigners) + const ownersToDelete = getOwnersToDelete(address, allSafesInfo, allSigners) + + if (ownersWithPrivateKeys.length === 0) { + proceedWithSafeDeletion(address, navigationConfig) + return + } + + const message = createDeletionMessage(ownersWithPrivateKeys, ownersToDelete) + const buttonTitle = ownersToDelete.length > 0 ? 'Delete account and private keys' : 'Delete account' + + return new Promise((resolve, reject) => { + Alert.alert('Delete account', message, [ + { + text: 'Cancel', + style: 'cancel', + onPress: () => reject(new Error('User cancelled deletion')), + }, + { + text: buttonTitle, + style: 'destructive', + onPress: () => + handleConfirmedDeletion({ + address, + ownersToDelete, + removeAllDelegatesForOwner, + navigationConfig, + resolve, + reject, + }), + }, + ]) + }) +} diff --git a/apps/mobile/src/features/AccountsSheet/AccountItem/utils/index.ts b/apps/mobile/src/features/AccountsSheet/AccountItem/utils/index.ts new file mode 100644 index 0000000000..2ee1f460ea --- /dev/null +++ b/apps/mobile/src/features/AccountsSheet/AccountItem/utils/index.ts @@ -0,0 +1 @@ +export * from './editAccountHelpers' diff --git a/apps/mobile/src/features/AccountsSheet/AccountsSheet.container.tsx b/apps/mobile/src/features/AccountsSheet/AccountsSheet.container.tsx new file mode 100644 index 0000000000..05398d52c7 --- /dev/null +++ b/apps/mobile/src/features/AccountsSheet/AccountsSheet.container.tsx @@ -0,0 +1,44 @@ +import { H6 } from 'tamagui' +import { SafeBottomSheet } from '@/src/components/SafeBottomSheet' +import { MyAccountsContainer, MyAccountsFooter } from '@/src/features/AccountsSheet/MyAccounts' +import { TouchableOpacity } from 'react-native' +import React, { useEffect } from 'react' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { selectMyAccountsMode, toggleMode } from '@/src/store/myAccountsSlice' +import { useMyAccountsSortable } from '@/src/features/AccountsSheet/MyAccounts/hooks/useMyAccountsSortable' +import { useMyAccountsAnalytics } from '@/src/features/AccountsSheet/MyAccounts/hooks/useMyAccountsAnalytics' + +export const AccountsSheetContainer = () => { + const dispatch = useAppDispatch() + const isEdit = useAppSelector(selectMyAccountsMode) + const { safes, onDragEnd } = useMyAccountsSortable() + const { trackScreenView, trackEditModeChange } = useMyAccountsAnalytics() + + // Track screen view with total safe count when component mounts + useEffect(() => { + trackScreenView() + }, []) + + const toggleEditMode = async () => { + const isEnteringEditMode = !isEdit // Before dispatching, determine if we're entering edit mode + await trackEditModeChange(isEnteringEditMode) + dispatch(toggleMode()) + } + + return ( + item.address} + FooterComponent={MyAccountsFooter} + renderItem={MyAccountsContainer} + sortable={isEdit} + onDragEnd={onDragEnd} + actions={ + +
{isEdit ? 'Done' : 'Edit'}
+
+ } + /> + ) +} diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx b/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccounts.container.test.tsx similarity index 52% rename from apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx rename to apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccounts.container.test.tsx index 04e77fb86b..e3599e7279 100644 --- a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx +++ b/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccounts.container.test.tsx @@ -5,23 +5,37 @@ import { mockedChains } from '@/src/store/constants' import { server } from '@/src/tests/server' import { http, HttpResponse } from 'msw' import { GATEWAY_URL } from '@/src/config/constants' +import { faker } from '@faker-js/faker' +import { shortenAddress } from '@/src/utils/formatters' + +jest.mock('expo-router', () => ({ + useNavigation: () => ({ + navigate: jest.fn(), + dispatch: jest.fn(), + }), + useSegments: () => ['test'], // if you use useSegments anywhere +})) // Mock the safe item data +const mockSafeAddress = faker.finance.ethereumAddress() as `0x${string}` const mockSafeItem = { - SafeInfo: { - address: { value: '0x123' as `0x${string}`, name: 'Test Safe' }, - threshold: 1, - owners: [{ value: '0x456' as `0x${string}` }], - fiatTotal: '1000', - chainId: '1', - queued: 0, + address: mockSafeAddress, + info: { + '1': { + address: { value: mockSafeAddress, name: 'Test Safe' }, + threshold: 1, + owners: [{ value: '0x456' as `0x${string}` }], + fiatTotal: '1000', + chainId: '1', + queued: 0, + }, }, - chains: ['1'], } // Create a constant object for the selector result -const mockActiveSafe = { address: '0x789' as `0x${string}`, chainId: '1' } +const mockActiveSafe = { address: faker.finance.ethereumAddress() as `0x${string}`, chainId: '1' } const mockChainIds = ['1'] as const +const mockDelegates = {} // Mock Redux selectors jest.mock('@/src/store/activeSafeSlice', () => ({ @@ -35,12 +49,33 @@ jest.mock('@/src/store/activeSafeSlice', () => ({ jest.mock('@/src/store/chains', () => ({ getChainsByIds: () => mockedChains, selectAllChainsIds: () => mockChainIds, + selectAllChains: () => mockedChains, })) jest.mock('@/src/store/myAccountsSlice', () => ({ selectMyAccountsMode: () => false, })) +jest.mock('@/src/store/delegatesSlice', () => ({ + selectDelegates: () => mockDelegates, + addDelegate: { + type: 'delegates/addDelegate', + match: jest.fn(), + }, +})) + +jest.mock('@/src/hooks/useNotificationCleanup', () => ({ + useNotificationCleanup: () => ({ + cleanupNotificationsForDelegate: jest.fn(), + }), +})) + +jest.mock('@safe-global/store/gateway/AUTO_GENERATED/delegates', () => ({ + cgwApi: { + useDelegatesDeleteDelegateV2Mutation: () => [jest.fn(), { isLoading: false }], + }, +})) + describe('MyAccountsContainer', () => { const mockOnClose = jest.fn() @@ -66,12 +101,29 @@ describe('MyAccountsContainer', () => { server.resetHandlers() }) - it('renders account item with correct data', () => { + it('renders account item with correct data but no contact exists in address book', () => { render() + expect(screen.getByText(shortenAddress(mockSafeItem.address))).toBeTruthy() + expect(screen.getByText('1/1')).toBeTruthy() + expect(screen.getByText('$ 1,000.00')).toBeTruthy() + }) + + it('renders account item with correct data when contact for safe exist', () => { + render(, { + initialStore: { + addressBook: { + contacts: { + [mockSafeItem.address]: { name: 'Test Safe', value: mockSafeItem.address, chainIds: [] }, + }, + selectedContact: null, + }, + }, + }) + expect(screen.getByText('Test Safe')).toBeTruthy() expect(screen.getByText('1/1')).toBeTruthy() - expect(screen.getByText('$1000')).toBeTruthy() + expect(screen.getByText('$ 1,000.00')).toBeTruthy() }) it('calls onClose when account is selected', () => { diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx b/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccounts.container.tsx similarity index 57% rename from apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx rename to apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccounts.container.tsx index ea034426b8..230af001eb 100644 --- a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx +++ b/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccounts.container.tsx @@ -4,31 +4,33 @@ import { AccountItem } from '../AccountItem' import { SafesSliceItem } from '@/src/store/safesSlice' import { Address } from '@/src/types/address' import { useDispatch, useSelector } from 'react-redux' -import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice' +import { setActiveSafe } from '@/src/store/activeSafeSlice' import { getChainsByIds } from '@/src/store/chains' import { RootState } from '@/src/store' -import { useMyAccountsService } from './hooks/useMyAccountsService' +import { useSafeOverviewService } from '@/src/hooks/services/useSafeOverviewService' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' interface MyAccountsContainerProps { - item: SafesSliceItem + item: { address: Address; info: SafesSliceItem } onClose: () => void isDragging?: boolean - drag?: RenderItemParams['drag'] + drag?: RenderItemParams<{ address: Address; info: SafesSliceItem }>['drag'] } export function MyAccountsContainer({ item, isDragging, drag, onClose }: MyAccountsContainerProps) { - useMyAccountsService(item) + useSafeOverviewService(item.address) const dispatch = useDispatch() - const activeSafe = useSelector(selectActiveSafe) - const filteredChains = useSelector((state: RootState) => getChainsByIds(state, item.chains)) + const activeSafe = useDefinedActiveSafe() + const chainsIds = Object.keys(item.info) + const filteredChains = useSelector((state: RootState) => getChainsByIds(state, chainsIds)) const handleAccountSelected = () => { - const chainId = item.chains[0] + const chainId = chainsIds[0] dispatch( setActiveSafe({ - address: item.SafeInfo.address.value as Address, + address: item.address, chainId, }), ) @@ -39,7 +41,10 @@ export function MyAccountsContainer({ item, isDragging, drag, onClose }: MyAccou return ( acc + parseFloat(item.info[id].fiatTotal), 0).toString(), + }} isDragging={isDragging} chains={filteredChains} onSelect={handleAccountSelected} diff --git a/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccountsFooter.test.tsx b/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccountsFooter.test.tsx new file mode 100644 index 0000000000..5dc71b6f49 --- /dev/null +++ b/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccountsFooter.test.tsx @@ -0,0 +1,10 @@ +import { render } from '@/src/tests/test-utils' +import { MyAccountsFooter } from './MyAccountsFooter' + +describe('MyAccountsFooter', () => { + it('should render the defualt template', () => { + const container = render() + + expect(container.getByText('Add existing account')).toBeDefined() + }) +}) diff --git a/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccountsFooter.tsx b/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccountsFooter.tsx new file mode 100644 index 0000000000..a60e7e65f8 --- /dev/null +++ b/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccountsFooter.tsx @@ -0,0 +1,41 @@ +import { Badge } from '@/src/components/Badge' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import React from 'react' +import { styled, Text, View } from 'tamagui' +import { Link } from 'expo-router' +const MyAccountsFooterContainer = styled(View, { + borderTopWidth: 1, + borderTopColor: '$colorSecondary', + paddingVertical: '$4', + paddingHorizontal: '$4', + backgroundColor: '$backgroundPaper', +}) + +const MyAccountsButton = styled(View, { + columnGap: '$2', + alignItems: 'center', + flexDirection: 'row', + padding: '$2', +}) + +export function MyAccountsFooter() { + return ( + + + + + } + /> + + + + Add existing account + + + + + ) +} diff --git a/apps/mobile/src/features/AccountsSheet/MyAccounts/hooks/useMyAccountsAnalytics.test.ts b/apps/mobile/src/features/AccountsSheet/MyAccounts/hooks/useMyAccountsAnalytics.test.ts new file mode 100644 index 0000000000..0b8d7a14aa --- /dev/null +++ b/apps/mobile/src/features/AccountsSheet/MyAccounts/hooks/useMyAccountsAnalytics.test.ts @@ -0,0 +1,85 @@ +import { renderHook, act } from '@/src/tests/test-utils' +import { useMyAccountsAnalytics } from './useMyAccountsAnalytics' +import * as firebaseAnalytics from '@/src/services/analytics/firebaseAnalytics' +import * as overviewEvents from '@/src/services/analytics/events/overview' + +const mockTotalSafeCount = 3 +const initialStore = { + safes: { '0x1': {}, '0x2': {}, '0x3': {} }, + myAccounts: { isEdit: false }, +} + +describe('useMyAccountsAnalytics', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('tracks screen view event', async () => { + const trackEventSpy = jest.spyOn(firebaseAnalytics, 'trackEvent').mockResolvedValue(undefined) + const eventSpy = jest.spyOn(overviewEvents, 'createMyAccountsScreenViewEvent') + const { result } = renderHook(() => useMyAccountsAnalytics(), initialStore) + + await act(async () => { + await result.current.trackScreenView() + }) + + expect(eventSpy).toHaveBeenCalledWith(mockTotalSafeCount) + expect(trackEventSpy).toHaveBeenCalledWith(expect.objectContaining({ eventAction: 'My accounts screen viewed' })) + }) + + it('tracks edit mode change event (enter)', async () => { + const trackEventSpy = jest.spyOn(firebaseAnalytics, 'trackEvent').mockResolvedValue(undefined) + const eventSpy = jest.spyOn(overviewEvents, 'createMyAccountsEditModeEvent') + const { result } = renderHook(() => useMyAccountsAnalytics(), initialStore) + + await act(async () => { + await result.current.trackEditModeChange(true) + }) + + expect(eventSpy).toHaveBeenCalledWith(true, mockTotalSafeCount) + expect(trackEventSpy).toHaveBeenCalledWith(expect.objectContaining({ eventAction: 'Edit mode entered' })) + }) + + it('tracks edit mode change event (exit)', async () => { + const trackEventSpy = jest.spyOn(firebaseAnalytics, 'trackEvent').mockResolvedValue(undefined) + const eventSpy = jest.spyOn(overviewEvents, 'createMyAccountsEditModeEvent') + const { result } = renderHook(() => useMyAccountsAnalytics(), initialStore) + + await act(async () => { + await result.current.trackEditModeChange(false) + }) + + expect(eventSpy).toHaveBeenCalledWith(false, mockTotalSafeCount) + expect(trackEventSpy).toHaveBeenCalledWith(expect.objectContaining({ eventAction: 'Edit mode exited' })) + }) + + it('tracks reorder event', async () => { + const trackEventSpy = jest.spyOn(firebaseAnalytics, 'trackEvent').mockResolvedValue(undefined) + const eventSpy = jest.spyOn(overviewEvents, 'createSafeReorderEvent') + const { result } = renderHook(() => useMyAccountsAnalytics(), initialStore) + + await act(async () => { + await result.current.trackReorder() + }) + + expect(eventSpy).toHaveBeenCalledWith(mockTotalSafeCount) + expect(trackEventSpy).toHaveBeenCalledWith(expect.objectContaining({ eventAction: 'Safe reordered' })) + }) + + it('logs error if trackEvent throws', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { + // do nothing + }) + jest.spyOn(firebaseAnalytics, 'trackEvent').mockRejectedValue(new Error('fail')) + const { result } = renderHook(() => useMyAccountsAnalytics(), initialStore) + + await act(async () => { + await result.current.trackScreenView() + await result.current.trackEditModeChange(true) + await result.current.trackReorder() + }) + + expect(errorSpy).toHaveBeenCalled() + errorSpy.mockRestore() + }) +}) diff --git a/apps/mobile/src/features/AccountsSheet/MyAccounts/hooks/useMyAccountsAnalytics.ts b/apps/mobile/src/features/AccountsSheet/MyAccounts/hooks/useMyAccountsAnalytics.ts new file mode 100644 index 0000000000..644bede417 --- /dev/null +++ b/apps/mobile/src/features/AccountsSheet/MyAccounts/hooks/useMyAccountsAnalytics.ts @@ -0,0 +1,54 @@ +import { useAppSelector } from '@/src/store/hooks' +import { selectTotalSafeCount } from '@/src/store/safesSlice' +import { + createMyAccountsScreenViewEvent, + createMyAccountsEditModeEvent, + createSafeReorderEvent, +} from '@/src/services/analytics/events/overview' +import { trackEvent } from '@/src/services/analytics/firebaseAnalytics' + +/** + * Hook to track MyAccounts analytics events + */ +export const useMyAccountsAnalytics = () => { + const totalSafeCount = useAppSelector(selectTotalSafeCount) + + /** + * Track My Accounts screen view + */ + const trackScreenView = async () => { + try { + const event = createMyAccountsScreenViewEvent(totalSafeCount) + await trackEvent(event) + } catch (error) { + console.error('Error tracking My accounts screen view:', error) + } + } + + /** + * Track entering or exiting edit mode + * @param isEnteringEditMode - true if entering edit mode, false if exiting + */ + const trackEditModeChange = async (isEnteringEditMode: boolean) => { + try { + const event = createMyAccountsEditModeEvent(isEnteringEditMode, totalSafeCount) + await trackEvent(event) + } catch (error) { + console.error('Error tracking My accounts edit mode change:', error) + } + } + + /** + * Track reorder event + */ + const trackReorder = async () => { + try { + const event = createSafeReorderEvent(totalSafeCount) + await trackEvent(event) + } catch (error) { + console.error('Error tracking safe reorder event:', error) + } + } + + return { trackScreenView, trackEditModeChange, trackReorder } +} diff --git a/apps/mobile/src/features/AccountsSheet/MyAccounts/hooks/useMyAccountsSortable.ts b/apps/mobile/src/features/AccountsSheet/MyAccounts/hooks/useMyAccountsSortable.ts new file mode 100644 index 0000000000..d081d7bdb4 --- /dev/null +++ b/apps/mobile/src/features/AccountsSheet/MyAccounts/hooks/useMyAccountsSortable.ts @@ -0,0 +1,47 @@ +import { SafesSliceItem, selectAllSafes, setSafes } from '@/src/store/safesSlice' +import { useCallback, useEffect, useState } from 'react' +import { DragEndParams } from 'react-native-draggable-flatlist' +import { useDispatch, useSelector } from 'react-redux' +import { Address } from '@/src/types/address' +import { useMyAccountsAnalytics } from './useMyAccountsAnalytics' + +type SafeListItem = { address: Address; info: SafesSliceItem } + +type useMyAccountsSortableReturn = { + safes: SafeListItem[] + onDragEnd: (params: DragEndParams) => void +} + +export const useMyAccountsSortable = (): useMyAccountsSortableReturn => { + const dispatch = useDispatch() + const safes = useSelector(selectAllSafes) + const [sortableSafes, setSortableSafes] = useState(() => + Object.entries(safes).map(([address, info]) => ({ address: address as Address, info })), + ) + const { trackReorder } = useMyAccountsAnalytics() + + useEffect(() => { + setSortableSafes(Object.entries(safes).map(([address, info]) => ({ address: address as Address, info }))) + }, [safes]) + + const onDragEnd = useCallback( + ({ data }: DragEndParams) => { + // Track reordering event + trackReorder() + + // Defer Redux update due to incompatibility issues between + // react-native-draggable-flatlist and new architecture. + setTimeout(() => { + const updated = data.reduce>( + (acc, item) => ({ ...acc, [item.address]: item.info }), + {}, + ) + setSortableSafes(data) + dispatch(setSafes(updated)) + }, 0) // Ensure this happens after the re-render + }, + [dispatch, trackReorder], + ) + + return { safes: sortableSafes, onDragEnd } +} diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/index.ts b/apps/mobile/src/features/AccountsSheet/MyAccounts/index.ts similarity index 100% rename from apps/mobile/src/features/Assets/components/MyAccounts/index.ts rename to apps/mobile/src/features/AccountsSheet/MyAccounts/index.ts diff --git a/apps/mobile/src/features/AccountsSheet/__tests__/AccountsSheet.container.test.tsx b/apps/mobile/src/features/AccountsSheet/__tests__/AccountsSheet.container.test.tsx new file mode 100644 index 0000000000..9487b8edf3 --- /dev/null +++ b/apps/mobile/src/features/AccountsSheet/__tests__/AccountsSheet.container.test.tsx @@ -0,0 +1,99 @@ +import { + createMyAccountsScreenViewEvent, + createMyAccountsEditModeEvent, + createSafeReorderEvent, +} from '../../../services/analytics/events/overview' +import { EventType } from '../../../services/analytics/types' + +// Mock Firebase Analytics +jest.mock('@/src/services/analytics/firebaseAnalytics') + +describe('AccountsSheetContainer tracking', () => { + describe('My accounts screen view tracking', () => { + it('should call createMyAccountsScreenViewEvent with correct parameters', () => { + const totalSafeCount = 3 + const event = createMyAccountsScreenViewEvent(totalSafeCount) + + expect(event).toEqual({ + eventName: EventType.SCREEN_VIEW, + eventCategory: 'overview', + eventAction: 'My accounts screen viewed', + eventLabel: 3, + }) + }) + + it('should handle zero safe count correctly', () => { + const totalSafeCount = 0 + const event = createMyAccountsScreenViewEvent(totalSafeCount) + + expect(event.eventLabel).toBe(0) + }) + }) + + describe('My accounts edit mode tracking', () => { + it('should create correct event for entering edit mode', () => { + const totalSafeCount = 5 + const event = createMyAccountsEditModeEvent(true, totalSafeCount) + + expect(event).toEqual({ + eventName: EventType.META, + eventCategory: 'overview', + eventAction: 'Edit mode entered', + eventLabel: 5, + }) + }) + + it('should create correct event for exiting edit mode', () => { + const totalSafeCount = 2 + const event = createMyAccountsEditModeEvent(false, totalSafeCount) + + expect(event).toEqual({ + eventName: EventType.META, + eventCategory: 'overview', + eventAction: 'Edit mode exited', + eventLabel: 2, + }) + }) + + it('should track different actions for entering vs exiting edit mode', () => { + const enterEvent = createMyAccountsEditModeEvent(true, 10) + const exitEvent = createMyAccountsEditModeEvent(false, 10) + + expect(enterEvent.eventAction).toBe('Edit mode entered') + expect(exitEvent.eventAction).toBe('Edit mode exited') + expect(enterEvent.eventName).toBe(EventType.META) + expect(exitEvent.eventName).toBe(EventType.META) + }) + }) + + describe('My accounts safe reordering tracking', () => { + it('should create correct event for safe reordering', () => { + const totalSafeCount = 8 + const event = createSafeReorderEvent(totalSafeCount) + + expect(event).toEqual({ + eventName: EventType.META, + eventCategory: 'overview', + eventAction: 'Safe reordered', + eventLabel: 8, + }) + }) + + it('should track reordering events with different safe counts', () => { + const smallCountEvent = createSafeReorderEvent(2) + const largeCountEvent = createSafeReorderEvent(50) + + expect(smallCountEvent.eventLabel).toBe(2) + expect(largeCountEvent.eventLabel).toBe(50) + expect(smallCountEvent.eventAction).toBe('Safe reordered') + expect(largeCountEvent.eventAction).toBe('Safe reordered') + }) + + it('should use META event type for reordering', () => { + const event = createSafeReorderEvent(15) + + expect(event.eventName).toBe(EventType.META) + expect(event.eventCategory).toBe('overview') + }) + }) +}) diff --git a/apps/mobile/src/features/AccountsSheet/index.tsx b/apps/mobile/src/features/AccountsSheet/index.tsx new file mode 100644 index 0000000000..7fc409e42d --- /dev/null +++ b/apps/mobile/src/features/AccountsSheet/index.tsx @@ -0,0 +1 @@ +export { AccountsSheetContainer } from './AccountsSheet.container' diff --git a/apps/mobile/src/features/ActionDetails/ActionDetails.container.tsx b/apps/mobile/src/features/ActionDetails/ActionDetails.container.tsx new file mode 100644 index 0000000000..4d2032a15d --- /dev/null +++ b/apps/mobile/src/features/ActionDetails/ActionDetails.container.tsx @@ -0,0 +1,45 @@ +import { useLocalSearchParams } from 'expo-router' +import React, { useMemo } from 'react' +import { ScrollView } from 'tamagui' +import { useTransactionsGetTransactionByIdV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +import { LargeHeaderTitle, NavBarTitle } from '@/src/components/Title' +import { useScrollableHeader } from '@/src/navigation/useScrollableHeader' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { Alert } from '@/src/components/Alert' + +import { LoadingTx } from '../ConfirmTx/components/LoadingTx' +import ActionsDetails from './ActionsDetails' + +export function ActionDetailsContainer() { + const { txId, action, actionName } = useLocalSearchParams<{ txId: string; actionName: string; action: string }>() + const parsedAction = useMemo(() => JSON.parse(action), [action]) + const activeSafe = useDefinedActiveSafe() + + const { data, isFetching, isError } = useTransactionsGetTransactionByIdV1Query({ + chainId: activeSafe.chainId, + id: txId, + }) + + const { handleScroll } = useScrollableHeader({ + children: {actionName}, + }) + + if (isError) { + return + } + + if (isFetching || !data) { + return + } + + return ( + + + {actionName} + + + + + ) +} diff --git a/apps/mobile/src/features/ActionDetails/ActionsDetails.tsx b/apps/mobile/src/features/ActionDetails/ActionsDetails.tsx new file mode 100644 index 0000000000..4c96fd5e6a --- /dev/null +++ b/apps/mobile/src/features/ActionDetails/ActionsDetails.tsx @@ -0,0 +1,18 @@ +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ActionValueDecoded } from '@safe-global/store/gateway/types' +import React, { useMemo } from 'react' +import { ListTable } from '../ConfirmTx/components/ListTable' +import { formatActionDetails } from './utils' +import { View } from 'tamagui' + +function ActionsDetails({ txDetails, action }: { txDetails: TransactionDetails; action: ActionValueDecoded }) { + const items = useMemo(() => txDetails && formatActionDetails({ txData: txDetails.txData, action }), [txDetails]) + + return ( + + + + ) +} + +export default ActionsDetails diff --git a/apps/mobile/src/features/ActionDetails/index.ts b/apps/mobile/src/features/ActionDetails/index.ts new file mode 100644 index 0000000000..598ff1e554 --- /dev/null +++ b/apps/mobile/src/features/ActionDetails/index.ts @@ -0,0 +1 @@ +export { ActionDetailsContainer } from './ActionDetails.container' diff --git a/apps/mobile/src/features/ActionDetails/utils.tsx b/apps/mobile/src/features/ActionDetails/utils.tsx new file mode 100644 index 0000000000..21956ca951 --- /dev/null +++ b/apps/mobile/src/features/ActionDetails/utils.tsx @@ -0,0 +1,122 @@ +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ActionValueDecoded, AddressInfoIndex } from '@safe-global/store/gateway/types' +import { getActionName } from '../TransactionActions/components/TxActionsList' +import { Badge } from '@/src/components/Badge' +import { CircleProps, View, Text } from 'tamagui' +import { ListTableItem } from '../ConfirmTx/components/ListTable' +import { Logo } from '@/src/components/Logo' +import { ellipsis } from '@/src/utils/formatters' +import { CopyButton } from '@/src/components/CopyButton' +import { Identicon } from '@/src/components/Identicon' +import { Address } from '@/src/types/address' +import { EthAddress } from '@/src/components/EthAddress' +import { EncodedData } from '@/src/components/EncodedData/EncodedData' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' + +const badgeProps: CircleProps = { borderRadius: '$2', paddingHorizontal: '$2', paddingVertical: '$1' } + +type formatActionDetailsReturn = { + txData: TransactionDetails['txData'] + action: ActionValueDecoded +} + +const getContractCall = (action: ActionValueDecoded, addressInfoIndex?: AddressInfoIndex) => { + return addressInfoIndex?.[action.to] +} + +const TxOptions = ({ value }: { value: string }) => { + return ( + + + + + ) +} + +const getContractItemLayout = ({ + logoUri, + value, + name, +}: { + logoUri?: string | null + value: string + name?: string | null +}) => ({ + label: 'Contract', + render: () => ( + + {logoUri ? : } + {ellipsis(name || value, 16)} + + + ), +}) + +export const formatActionDetails = ({ txData, action }: formatActionDetailsReturn): ListTableItem[] => { + if (!txData) { + return [] + } + + let columns: ListTableItem[] = [] + + if (action.dataDecoded?.method) { + columns.push({ + label: 'Call', + render: () => ( + + ), + }) + } else { + columns.push({ + label: 'Interacted with', + render: () => ( + + + + + + ), + }) + } + + const contractCall = getContractCall(action, txData.addressInfoIndex as AddressInfoIndex) + + if (contractCall) { + columns.push(getContractItemLayout(contractCall)) + } else if (action.to) { + columns.push(getContractItemLayout({ value: action.to })) + } + + if (action.dataDecoded) { + columns = [ + ...columns, + ...action.dataDecoded.parameters.map((param) => ({ + label: param.name, + render: () => { + if (param.type === 'address') { + return + } + + return {param.value} + }, + })), + ] + } else if (action.data) { + columns = [ + ...columns, + { + label: 'Data', + render: () => , + }, + ] + } + + return columns +} diff --git a/apps/mobile/src/features/AddressBook/Contact/ContactDetail.container.tsx b/apps/mobile/src/features/AddressBook/Contact/ContactDetail.container.tsx new file mode 100644 index 0000000000..cdcaba1684 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/ContactDetail.container.tsx @@ -0,0 +1,46 @@ +import React, { useState, useEffect } from 'react' +import { useLocalSearchParams, useNavigation } from 'expo-router' +import { TouchableOpacity } from 'react-native' +import { useAppSelector } from '@/src/store/hooks' +import { selectContactByAddress } from '@/src/store/addressBookSlice' +import { ContactFormContainer } from './ContactForm.container' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { usePreventLeaveScreen } from '@/src/hooks/usePreventLeaveScreen' +import { useDeleteContact } from './hooks/useDeleteContact' +import { useEditContact } from './hooks/useEditContact' + +export const ContactDetailContainer = () => { + const { address, mode } = useLocalSearchParams<{ + address?: string + mode?: 'view' | 'edit' | 'new' + }>() + + const navigation = useNavigation() + + const contact = useAppSelector(selectContactByAddress(address || '')) + + const [isEditing, setIsEditing] = useState(mode === 'edit' || mode === 'new') + usePreventLeaveScreen(isEditing) + + const { handleDeletePress } = useDeleteContact({ contact, setIsEditing }) + const { handleEdit, handleSave } = useEditContact({ mode, setIsEditing }) + + // Set up navigation header with delete button when editing existing contact + useEffect(() => { + if (isEditing && contact && mode !== 'new') { + navigation.setOptions({ + headerRight: () => ( + + + + ), + }) + } else { + navigation.setOptions({ + headerRight: undefined, + }) + } + }, [isEditing, contact, mode, navigation, handleDeletePress]) + + return +} diff --git a/apps/mobile/src/features/AddressBook/Contact/ContactDisplayName.container.tsx b/apps/mobile/src/features/AddressBook/Contact/ContactDisplayName.container.tsx new file mode 100644 index 0000000000..26435ff0ba --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/ContactDisplayName.container.tsx @@ -0,0 +1,14 @@ +import { useAppSelector } from '@/src/store/hooks' +import { selectContactByAddress } from '@/src/store/addressBookSlice' +import { ContactName } from './components/ContactName' +import { type TextProps } from 'tamagui' + +type Props = { + address: `0x${string}` + textProps?: Partial +} + +export const ContactDisplayNameContainer = ({ address, textProps }: Props) => { + const contact = useAppSelector(selectContactByAddress(address)) + return +} diff --git a/apps/mobile/src/features/AddressBook/Contact/ContactForm.container.tsx b/apps/mobile/src/features/AddressBook/Contact/ContactForm.container.tsx new file mode 100644 index 0000000000..dd6c2b4222 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/ContactForm.container.tsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react' +import { ScrollView, View } from 'tamagui' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { KeyboardAvoidingView } from 'react-native' +import { useForm, FormProvider } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { type Contact } from '@/src/store/addressBookSlice' +import { contactSchema, type ContactFormData } from './schemas' +import { + ContactActionButton, + ContactAddressField, + ContactHeader, + ContactNameField, + ContactNetworkRow, +} from '@/src/features/AddressBook/Contact/components' +import { NetworkSelector } from './NetworkSelector/NetworkSelector' + +interface ContactFormProps { + contact?: Contact | null + isEditing: boolean + onSave: (contact: Contact) => void + onEdit?: () => void +} + +export const ContactFormContainer = ({ contact, isEditing, onSave, onEdit }: ContactFormProps) => { + const insets = useSafeAreaInsets() + const [isNetworkSelectorVisible, setIsNetworkSelectorVisible] = useState(false) + const [selectedChainIds, setSelectedChainIds] = useState(contact?.chainIds || []) + + const methods = useForm({ + resolver: zodResolver(contactSchema), + mode: 'onChange', + defaultValues: { + name: contact?.name || '', + address: contact?.value || '', + }, + }) + + const { + control, + handleSubmit, + watch, + formState: { errors, isValid, dirtyFields }, + } = methods + + const watchedAddress = watch('address') + const watchedName = watch('name') + + const onSubmit = (data: ContactFormData) => { + onSave({ + value: data.address.trim(), + name: data.name.trim(), + logoUri: contact?.logoUri || null, + chainIds: selectedChainIds, + }) + } + + const displayName = isEditing ? watchedName || '' : contact?.name || '' + const displayAddress = isEditing ? watchedAddress : contact?.value + + const handleNetworkPress = () => { + setIsNetworkSelectorVisible(true) + } + + const handleNetworkSelectionChange = (chainIds: string[]) => { + setSelectedChainIds(chainIds) + } + + const handleNetworkSelectorClose = () => { + setIsNetworkSelectorVisible(false) + } + + const content = ( + + + + + + + + + + + + + + + + + + ) + + if (isEditing) { + return ( + + + {content} + + + ) + } + + return content +} diff --git a/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/AllNetworksItem.tsx b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/AllNetworksItem.tsx new file mode 100644 index 0000000000..93cf7673c5 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/AllNetworksItem.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { TouchableOpacity } from 'react-native' +import { View } from 'tamagui' +import { AssetsCard } from '@/src/components/transactions-list/Card/AssetsCard' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' + +interface AllNetworksItemProps { + isSelected: boolean + isReadOnly: boolean + onSelectAll: () => void +} + +export const AllNetworksItem = ({ isSelected, isReadOnly, onSelectAll }: AllNetworksItemProps) => { + return ( + + + } + /> + + + ) +} diff --git a/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/ChainItem.tsx b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/ChainItem.tsx new file mode 100644 index 0000000000..80d0e8fdaf --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/ChainItem.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { TouchableOpacity } from 'react-native' +import { View } from 'tamagui' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { AssetsCard } from '@/src/components/transactions-list/Card/AssetsCard' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' + +interface ChainItemProps { + chain: Chain + isSelected: boolean + isReadOnly: boolean + onToggle: (chainId: string) => void +} + +export const ChainItem = ({ chain, isSelected, isReadOnly, onToggle }: ChainItemProps) => { + const handlePress = () => { + onToggle(chain.chainId) + } + + return ( + + + } + /> + + + ) +} diff --git a/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/NetworkSelector.tsx b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/NetworkSelector.tsx new file mode 100644 index 0000000000..201521e5f4 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/NetworkSelector.tsx @@ -0,0 +1,100 @@ +import React, { useRef, useEffect } from 'react' +import { BottomSheetModal } from '@gorhom/bottom-sheet' +import { getVariable, useTheme } from 'tamagui' +import { useAppSelector } from '@/src/store/hooks' +import { selectAllChains, useGetChainsConfigQuery, getChainsByIds } from '@/src/store/chains' +import { BackdropComponent, BackgroundComponent } from '@/src/components/Dropdown/sheetComponents' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { NetworkSelectorHeader } from './NetworkSelectorHeader' +import { NetworkSelectorContent } from './NetworkSelectorContent' + +interface NetworkSelectorProps { + isVisible: boolean + onClose: () => void + onSelectionChange: (chainIds: string[]) => void + selectedChainIds: string[] + isReadOnly?: boolean +} + +export const NetworkSelector = ({ + isVisible, + onClose, + onSelectionChange, + selectedChainIds, + isReadOnly = false, +}: NetworkSelectorProps) => { + const bottomSheetModalRef = useRef(null) + const insets = useSafeAreaInsets() + const theme = useTheme() + + // Fetch chains data to ensure it's up to date + useGetChainsConfigQuery() + + const allChains = useAppSelector(selectAllChains) || [] + const selectedChains = useAppSelector((state) => getChainsByIds(state, selectedChainIds)) + + // Handle visibility changes + useEffect(() => { + if (isVisible) { + bottomSheetModalRef.current?.present() + } else { + bottomSheetModalRef.current?.dismiss() + } + }, [isVisible]) + + const handleChainToggle = (chainId: string) => { + if (isReadOnly) { + return + } + + let newSelection: string[] + + if (selectedChainIds.includes(chainId)) { + newSelection = selectedChainIds.filter((id) => id !== chainId) + } else { + newSelection = [...selectedChainIds, chainId] + } + + onSelectionChange(newSelection) + } + + const handleSelectAll = () => { + if (isReadOnly) { + return + } + onSelectionChange([]) // Empty array means all chains + } + + const isAllChainsSelected = selectedChainIds.length === 0 + const chainsToDisplay = isReadOnly ? (isAllChainsSelected ? allChains : selectedChains) : allChains + + return ( + } + topInset={insets.top} + bottomInset={insets.bottom} + enableDynamicSizing + handleIndicatorStyle={{ backgroundColor: getVariable(theme.borderMain) }} + onDismiss={onClose} + > + + + + + ) +} diff --git a/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/NetworkSelectorContent.tsx b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/NetworkSelectorContent.tsx new file mode 100644 index 0000000000..b2fc9774b5 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/NetworkSelectorContent.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { BottomSheetScrollView } from '@gorhom/bottom-sheet' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { ChainItem } from './ChainItem' +import { AllNetworksItem } from './AllNetworksItem' + +interface NetworkSelectorContentProps { + chainsToDisplay: Chain[] + selectedChainIds: string[] + isReadOnly: boolean + isAllChainsSelected: boolean + onChainToggle: (chainId: string) => void + onSelectAll: () => void + bottomInset: number + topInset: number +} + +export const NetworkSelectorContent = ({ + chainsToDisplay, + selectedChainIds, + isReadOnly, + isAllChainsSelected, + onChainToggle, + onSelectAll, + bottomInset, + topInset, +}: NetworkSelectorContentProps) => { + const isChainSelected = (chainId: string) => { + return selectedChainIds.includes(chainId) + } + + return ( + + <> + + + {chainsToDisplay.map((chain) => ( + + ))} + + + ) +} diff --git a/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/NetworkSelectorHeader.tsx b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/NetworkSelectorHeader.tsx new file mode 100644 index 0000000000..b892828a58 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/NetworkSelectorHeader.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import { Text, View } from 'tamagui' + +interface NetworkSelectorHeaderProps { + isReadOnly: boolean + isAllChainsSelected: boolean + selectedChainCount: number +} + +interface TitleProps { + isReadOnly: boolean +} + +interface SubtitleProps { + isReadOnly: boolean + isAllChainsSelected: boolean + selectedChainCount: number +} + +const Title = ({ isReadOnly }: TitleProps) => { + const title = isReadOnly ? 'Available Networks' : 'Select Networks' + + return ( + + {title} + + ) +} + +const Subtitle = ({ isReadOnly, isAllChainsSelected, selectedChainCount }: SubtitleProps) => { + const prefix = isReadOnly ? 'Contact is available on' : 'Contact available on' + + return ( + + {isAllChainsSelected + ? `${prefix} all networks` + : `${prefix} ${selectedChainCount} ${selectedChainCount === 1 ? 'network' : 'networks'}`} + + ) +} + +export const NetworkSelectorHeader = ({ + isReadOnly, + isAllChainsSelected, + selectedChainCount, +}: NetworkSelectorHeaderProps) => { + return ( + + + <Subtitle + isReadOnly={isReadOnly} + isAllChainsSelected={isAllChainsSelected} + selectedChainCount={selectedChainCount} + /> + </View> + ) +} diff --git a/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/__tests__/NetworkSelectorHeader.test.tsx b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/__tests__/NetworkSelectorHeader.test.tsx new file mode 100644 index 0000000000..74ceb76b93 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/__tests__/NetworkSelectorHeader.test.tsx @@ -0,0 +1,120 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { NetworkSelectorHeader } from '../NetworkSelectorHeader' + +describe('NetworkSelectorHeader', () => { + const defaultProps = { + isReadOnly: false, + isAllChainsSelected: false, + selectedChainCount: 1, + } + + describe('Title component', () => { + it('should display "Select Networks" when not read-only', () => { + const { getByText } = render(<NetworkSelectorHeader {...defaultProps} isReadOnly={false} />) + + expect(getByText('Select Networks')).toBeTruthy() + }) + + it('should display "Available Networks" when read-only', () => { + const { getByText } = render(<NetworkSelectorHeader {...defaultProps} isReadOnly={true} />) + + expect(getByText('Available Networks')).toBeTruthy() + }) + }) + + describe('Subtitle component - Read-only mode', () => { + it('should display "Contact is available on all networks" when all chains are selected', () => { + const { getByText } = render( + <NetworkSelectorHeader {...defaultProps} isReadOnly={true} isAllChainsSelected={true} />, + ) + + expect(getByText('Contact is available on all networks')).toBeTruthy() + }) + + it('should display singular "network" for one selected network', () => { + const { getByText } = render(<NetworkSelectorHeader {...defaultProps} isReadOnly={true} selectedChainCount={1} />) + + expect(getByText('Contact is available on 1 network')).toBeTruthy() + }) + + it('should display plural "networks" for multiple selected networks', () => { + const { getByText } = render(<NetworkSelectorHeader {...defaultProps} isReadOnly={true} selectedChainCount={3} />) + + expect(getByText('Contact is available on 3 networks')).toBeTruthy() + }) + + it('should display plural "networks" for zero selected networks', () => { + const { getByText } = render(<NetworkSelectorHeader {...defaultProps} isReadOnly={true} selectedChainCount={0} />) + + expect(getByText('Contact is available on 0 networks')).toBeTruthy() + }) + }) + + describe('Subtitle component - Editable mode', () => { + it('should display "Contact available on all networks" when all chains are selected', () => { + const { getByText } = render( + <NetworkSelectorHeader {...defaultProps} isReadOnly={false} isAllChainsSelected={true} />, + ) + + expect(getByText('Contact available on all networks')).toBeTruthy() + }) + + it('should display singular "network" for one selected network', () => { + const { getByText } = render( + <NetworkSelectorHeader {...defaultProps} isReadOnly={false} selectedChainCount={1} />, + ) + + expect(getByText('Contact available on 1 network')).toBeTruthy() + }) + + it('should display plural "networks" for multiple selected networks', () => { + const { getByText } = render( + <NetworkSelectorHeader {...defaultProps} isReadOnly={false} selectedChainCount={5} />, + ) + + expect(getByText('Contact available on 5 networks')).toBeTruthy() + }) + + it('should display plural "networks" for zero selected networks', () => { + const { getByText } = render( + <NetworkSelectorHeader {...defaultProps} isReadOnly={false} selectedChainCount={0} />, + ) + + expect(getByText('Contact available on 0 networks')).toBeTruthy() + }) + }) + + describe('Text differences between modes', () => { + it('should use different text prefixes for read-only vs editable modes', () => { + const readOnlyProps = { ...defaultProps, isReadOnly: true, selectedChainCount: 2 } + const editableProps = { ...defaultProps, isReadOnly: false, selectedChainCount: 2 } + + const { getByText: getByTextReadOnly } = render(<NetworkSelectorHeader {...readOnlyProps} />) + const { getByText: getByTextEditable } = render(<NetworkSelectorHeader {...editableProps} />) + + expect(getByTextReadOnly('Contact is available on 2 networks')).toBeTruthy() + expect(getByTextEditable('Contact available on 2 networks')).toBeTruthy() + }) + + it('should use different text for all networks in both modes', () => { + const readOnlyProps = { ...defaultProps, isReadOnly: true, isAllChainsSelected: true } + const editableProps = { ...defaultProps, isReadOnly: false, isAllChainsSelected: true } + + const { getByText: getByTextReadOnly } = render(<NetworkSelectorHeader {...readOnlyProps} />) + const { getByText: getByTextEditable } = render(<NetworkSelectorHeader {...editableProps} />) + + expect(getByTextReadOnly('Contact is available on all networks')).toBeTruthy() + expect(getByTextEditable('Contact available on all networks')).toBeTruthy() + }) + }) + + describe('Component structure', () => { + it('should render both title and subtitle', () => { + const { getByText } = render(<NetworkSelectorHeader {...defaultProps} />) + + expect(getByText('Select Networks')).toBeTruthy() + expect(getByText('Contact available on 1 network')).toBeTruthy() + }) + }) +}) diff --git a/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/index.ts b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/index.ts new file mode 100644 index 0000000000..787fe834a6 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/NetworkSelector/index.ts @@ -0,0 +1,5 @@ +export { NetworkSelector } from './NetworkSelector' +export { ChainItem } from './ChainItem' +export { AllNetworksItem } from './AllNetworksItem' +export { NetworkSelectorHeader } from './NetworkSelectorHeader' +export { NetworkSelectorContent } from './NetworkSelectorContent' diff --git a/apps/mobile/src/features/AddressBook/Contact/components/ContactActionButton.tsx b/apps/mobile/src/features/AddressBook/Contact/components/ContactActionButton.tsx new file mode 100644 index 0000000000..7821096008 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/components/ContactActionButton.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { SafeButton } from '@/src/components/SafeButton' + +interface ContactActionButtonProps { + isEditing: boolean + isValid: boolean + onEdit?: () => void + onSave: () => void +} + +export const ContactActionButton = ({ isEditing, isValid, onEdit, onSave }: ContactActionButtonProps) => { + if (isEditing) { + return ( + <SafeButton primary onPress={onSave} disabled={!isValid}> + Save contact + </SafeButton> + ) + } + + return ( + <SafeButton secondary onPress={onEdit}> + Edit contact + </SafeButton> + ) +} diff --git a/apps/mobile/src/features/AddressBook/Contact/components/ContactAddressField.tsx b/apps/mobile/src/features/AddressBook/Contact/components/ContactAddressField.tsx new file mode 100644 index 0000000000..10b8f3ff31 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/components/ContactAddressField.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { Text, View } from 'tamagui' +import { SafeInputWithLabel } from '@/src/components/SafeInput/SafeInputWithLabel' +import { Controller, Control, FieldErrors } from 'react-hook-form' +import { ContactFormData } from '../schemas' +import { type Contact } from '@/src/store/addressBookSlice' + +interface ContactAddressFieldProps { + isEditing: boolean + contact?: Contact | null + control?: Control<ContactFormData> + errors?: FieldErrors<ContactFormData> + dirtyFields?: Partial<Record<keyof ContactFormData, boolean>> +} + +export const ContactAddressField = ({ isEditing, contact, control, errors, dirtyFields }: ContactAddressFieldProps) => { + if (isEditing && control) { + return ( + <View> + <Controller + control={control} + name="address" + render={({ field: { onChange, onBlur, value } }) => ( + <SafeInputWithLabel + label="Address" + value={value} + onBlur={onBlur} + onChangeText={onChange} + placeholder="Enter address" + autoCapitalize="none" + autoCorrect={false} + error={dirtyFields?.address && !!errors?.address} + success={dirtyFields?.address && !errors?.address && value.trim().length > 0} + multiline + numberOfLines={3} + /> + )} + /> + {errors?.address && <Text color="$error">{errors.address.message}</Text>} + </View> + ) + } + + return ( + <SafeInputWithLabel + label="Address" + value={contact?.value || ''} + disabled + editable={false} + multiline + numberOfLines={3} + /> + ) +} diff --git a/apps/mobile/src/features/AddressBook/Contact/components/ContactHeader.tsx b/apps/mobile/src/features/AddressBook/Contact/components/ContactHeader.tsx new file mode 100644 index 0000000000..b77c61db4f --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/components/ContactHeader.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { Text, View } from 'tamagui' +import { Identicon } from '@/src/components/Identicon' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { type Address } from '@/src/types/address' + +interface ContactHeaderProps { + displayAddress?: string + displayName: string +} + +export const ContactHeader = ({ displayAddress, displayName }: ContactHeaderProps) => { + return ( + <View paddingHorizontal="$4" paddingTop="$4" paddingBottom="$6" alignItems="center"> + <View marginBottom="$4"> + {displayAddress ? ( + <Identicon address={displayAddress as Address} rounded size={40} /> + ) : ( + <View + width={40} + height={40} + backgroundColor="$backgroundSecondary" + borderRadius={40} + alignItems="center" + justifyContent="center" + > + <SafeFontIcon name="edit-owner" size={16} /> + </View> + )} + </View> + <Text fontSize="$6" fontWeight="600" color="$color" textAlign="center"> + {displayName} + </Text> + </View> + ) +} diff --git a/apps/mobile/src/features/AddressBook/Contact/components/ContactName.tsx b/apps/mobile/src/features/AddressBook/Contact/components/ContactName.tsx new file mode 100644 index 0000000000..14cc32eb2e --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/components/ContactName.tsx @@ -0,0 +1,19 @@ +import { EthAddress } from '@/src/components/EthAddress/ETHAddress' +import { Text, type TextProps, View } from 'tamagui' + +type Props = { + name?: string + address: `0x${string}` + textProps?: Partial<TextProps> +} +export const ContactName = ({ name, address, textProps }: Props) => { + return name ? ( + <View> + <Text fontWeight={500} {...textProps}> + {name} + </Text> + </View> + ) : ( + <EthAddress address={address} /> + ) +} diff --git a/apps/mobile/src/features/AddressBook/Contact/components/ContactNameField.tsx b/apps/mobile/src/features/AddressBook/Contact/components/ContactNameField.tsx new file mode 100644 index 0000000000..e5ffffb7a4 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/components/ContactNameField.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { Text, View } from 'tamagui' +import { SafeInputWithLabel } from '@/src/components/SafeInput/SafeInputWithLabel' +import { Controller, Control, FieldErrors } from 'react-hook-form' +import { ContactFormData } from '../schemas' +import { type Contact } from '@/src/store/addressBookSlice' + +interface ContactNameFieldProps { + isEditing: boolean + contact?: Contact | null + control?: Control<ContactFormData> + errors?: FieldErrors<ContactFormData> + dirtyFields?: Partial<Record<keyof ContactFormData, boolean>> +} + +export const ContactNameField = ({ isEditing, contact, control, errors, dirtyFields }: ContactNameFieldProps) => { + const isNew = !contact?.value + + if (isEditing && control) { + return ( + <View> + <Controller + control={control} + name="name" + render={({ field: { onChange, onBlur, value } }) => ( + <SafeInputWithLabel + label="Name" + value={value} + autoFocus + onBlur={onBlur} + onChangeText={onChange} + placeholder={isNew ? 'Enter name' : contact?.name || 'Enter name'} + error={dirtyFields?.name && !!errors?.name} + success={dirtyFields?.name && !errors?.name && value.trim().length > 0} + /> + )} + /> + {errors?.name && <Text color="$error">{errors.name.message}</Text>} + </View> + ) + } + + return <SafeInputWithLabel label="Name" value={contact?.name || 'Unnamed contact'} disabled editable={false} /> +} diff --git a/apps/mobile/src/features/AddressBook/Contact/components/ContactNetworkRow.tsx b/apps/mobile/src/features/AddressBook/Contact/components/ContactNetworkRow.tsx new file mode 100644 index 0000000000..0a78de2df9 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/components/ContactNetworkRow.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { Text, View, Theme } from 'tamagui' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Pressable, Keyboard } from 'react-native' +import { useContactNetworkData } from '../hooks/useContactNetworkData' + +interface ContactNetworkRowProps { + onPress: () => void + chainIds: string[] +} + +export const ContactNetworkRow = ({ onPress, chainIds }: ContactNetworkRowProps) => { + const { displayText } = useContactNetworkData(chainIds) + + const handlePress = () => { + Keyboard.dismiss() + onPress() + } + + return ( + <Theme name="input_with_label"> + <Pressable onPress={handlePress}> + <View + backgroundColor="$background" + borderRadius={8} + padding="$3" + flexDirection="row" + alignItems="center" + justifyContent="space-between" + > + <View flexDirection="row" alignItems="center" gap="$2"> + <Text fontSize="$4" fontWeight="400" color="$colorSecondary"> + Network + </Text> + </View> + <View flexDirection="row" alignItems="center" gap="$2"> + <Text fontSize="$4" fontWeight="400" color="$colorSecondary"> + {displayText} + </Text> + <SafeFontIcon name="chevron-right" size={16} /> + </View> + </View> + </Pressable> + </Theme> + ) +} diff --git a/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactActionButton.test.tsx b/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactActionButton.test.tsx new file mode 100644 index 0000000000..8b7247497a --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactActionButton.test.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { render, fireEvent } from '@/src/tests/test-utils' +import { ContactActionButton } from '../ContactActionButton' + +describe('ContactActionButton', () => { + it('renders Save button when in editing mode', () => { + const mockOnSave = jest.fn() + const { getByText } = render(<ContactActionButton isEditing={true} isValid={true} onSave={mockOnSave} />) + + expect(getByText('Save contact')).toBeTruthy() + }) + + it('renders Edit button when not in editing mode', () => { + const mockOnEdit = jest.fn() + const { getByText } = render( + <ContactActionButton isEditing={false} isValid={true} onEdit={mockOnEdit} onSave={jest.fn()} />, + ) + + expect(getByText('Edit contact')).toBeTruthy() + }) + + it('disables Save button when form is invalid', () => { + const mockOnSave = jest.fn() + const { getByText } = render(<ContactActionButton isEditing={true} isValid={false} onSave={mockOnSave} />) + + // Find button by traversing from text to its parent containers until we find the button + let buttonElement: unknown = getByText('Save contact') + while (buttonElement && (buttonElement as { props?: { role?: string } }).props?.role !== 'button') { + buttonElement = (buttonElement as { parent?: unknown }).parent + } + + expect(buttonElement).toBeTruthy() + expect((buttonElement as { props?: { pointerEvents?: string } }).props?.pointerEvents).toBe('none') + }) + + it('calls onEdit when Edit button is pressed', () => { + const mockOnEdit = jest.fn() + const { getByText } = render( + <ContactActionButton isEditing={false} isValid={true} onEdit={mockOnEdit} onSave={jest.fn()} />, + ) + + fireEvent.press(getByText('Edit contact')) + expect(mockOnEdit).toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactAddressField.test.tsx b/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactAddressField.test.tsx new file mode 100644 index 0000000000..e57e672304 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactAddressField.test.tsx @@ -0,0 +1,455 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { FormProvider, useForm, Control, FieldErrors } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { faker } from '@faker-js/faker' +import { ContactAddressField } from '../ContactAddressField' +import { contactSchema, type ContactFormData } from '../../schemas' +import { type Contact } from '@/src/store/addressBookSlice' + +// Helper component to wrap ContactAddressField with FormProvider and access control +const TestWrapper = ({ + children, + defaultValues = { name: '', address: '' }, + renderWithControl = false, +}: { + children: + | React.ReactNode + | (( + control: Control<ContactFormData>, + errors: FieldErrors<ContactFormData>, + dirtyFields: Partial<Record<keyof ContactFormData, boolean>>, + ) => React.ReactNode) + defaultValues?: ContactFormData + renderWithControl?: boolean +}) => { + const methods = useForm<ContactFormData>({ + resolver: zodResolver(contactSchema), + mode: 'onChange', + defaultValues, + }) + + const renderChildren = () => { + if (renderWithControl && typeof children === 'function') { + return children(methods.control, methods.formState.errors, methods.formState.dirtyFields) + } + return children as React.ReactNode + } + + return <FormProvider {...methods}>{renderChildren()}</FormProvider> +} + +describe('ContactAddressField', () => { + const validAddress = '0x1234567890123456789012345678901234567890' + const invalidAddress = 'invalid-address' + + const mockContact: Contact = { + value: validAddress, + name: faker.person.firstName(), + logoUri: null, + chainIds: ['1', '5'], + } + + describe('when in read-only mode (isEditing = false)', () => { + it('should render the address field as disabled with contact value', () => { + const { getByTestId, getByText } = render(<ContactAddressField isEditing={false} contact={mockContact} />) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + + const label = getByText('Address') + expect(label).toBeDefined() + }) + + it('should render empty value when no contact is provided', () => { + const { getByTestId } = render(<ContactAddressField isEditing={false} contact={null} />) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + + it('should configure input as multiline with 3 lines', () => { + const { getByTestId } = render(<ContactAddressField isEditing={false} contact={mockContact} />) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + }) + + describe('when in editing mode (isEditing = true)', () => { + it('should render controlled input when control is provided', () => { + const { getByTestId } = render( + <TestWrapper defaultValues={{ name: 'Test', address: validAddress }} renderWithControl> + {(control, errors, dirtyFields) => ( + <ContactAddressField + isEditing={true} + contact={mockContact} + control={control} + errors={errors} + dirtyFields={dirtyFields} + /> + )} + </TestWrapper>, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + + it('should handle address input changes', () => { + const { getByTestId } = render( + <TestWrapper renderWithControl> + {(control, errors, dirtyFields) => ( + <ContactAddressField + isEditing={true} + contact={null} + control={control} + errors={errors} + dirtyFields={dirtyFields} + /> + )} + </TestWrapper>, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + + it('should configure input with correct attributes for address entry', () => { + const { getByTestId } = render( + <TestWrapper renderWithControl> + {(control, errors, dirtyFields) => ( + <ContactAddressField + isEditing={true} + contact={null} + control={control} + errors={errors} + dirtyFields={dirtyFields} + /> + )} + </TestWrapper>, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + + describe('validation states', () => { + it('should show error state when field is dirty and has errors', () => { + // Create a scenario where validation will fail + const { getByTestId, getByText } = render( + <TestWrapper defaultValues={{ name: 'Test', address: invalidAddress }} renderWithControl> + {(control, _errors, dirtyFields) => { + // Manually set dirty state for address field + const mockDirtyFields = { ...dirtyFields, address: true } + const mockErrors = { address: { message: 'Invalid Ethereum address format', type: 'custom' } } + + return ( + <ContactAddressField + isEditing={true} + contact={null} + control={control} + errors={mockErrors} + dirtyFields={mockDirtyFields} + /> + ) + }} + </TestWrapper>, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + + const errorMessage = getByText('Invalid Ethereum address format') + expect(errorMessage).toBeDefined() + }) + + it('should show success state when field is dirty, has no errors, and has value', () => { + const { getByTestId } = render( + <TestWrapper defaultValues={{ name: 'Test', address: validAddress }} renderWithControl> + {(control, _errors, dirtyFields) => { + const mockDirtyFields = { ...dirtyFields, address: true } + + return ( + <ContactAddressField + isEditing={true} + contact={null} + control={control} + errors={_errors} + dirtyFields={mockDirtyFields} + /> + ) + }} + </TestWrapper>, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + + it('should not show success state when field is dirty but value is empty', () => { + const { getByTestId } = render( + <TestWrapper defaultValues={{ name: 'Test', address: '' }} renderWithControl> + {(control, _errors, dirtyFields) => { + const mockDirtyFields = { ...dirtyFields, address: true } + + return ( + <ContactAddressField + isEditing={true} + contact={null} + control={control} + errors={_errors} + dirtyFields={mockDirtyFields} + /> + ) + }} + </TestWrapper>, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + + it('should not show success state when field is dirty but value is only whitespace', () => { + const { getByTestId } = render( + <TestWrapper defaultValues={{ name: 'Test', address: ' ' }} renderWithControl> + {(control, _errors, dirtyFields) => { + const mockDirtyFields = { ...dirtyFields, address: true } + + return ( + <ContactAddressField + isEditing={true} + contact={null} + control={control} + errors={_errors} + dirtyFields={mockDirtyFields} + /> + ) + }} + </TestWrapper>, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + + it('should not show success state when field is not dirty', () => { + const { getByTestId } = render( + <TestWrapper defaultValues={{ name: 'Test', address: validAddress }} renderWithControl> + {(control, _errors, dirtyFields) => ( + <ContactAddressField + isEditing={true} + contact={null} + control={control} + errors={_errors} + dirtyFields={dirtyFields} + /> + )} + </TestWrapper>, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + + it('should display error message when there are errors', () => { + const { getByText } = render( + <TestWrapper renderWithControl> + {(control, _errors, dirtyFields) => { + const mockErrors = { address: { message: 'Invalid Ethereum address format', type: 'custom' } } + + return ( + <ContactAddressField + isEditing={true} + contact={null} + control={control} + errors={mockErrors} + dirtyFields={dirtyFields} + /> + ) + }} + </TestWrapper>, + ) + + const errorMessage = getByText('Invalid Ethereum address format') + expect(errorMessage).toBeDefined() + }) + + it('should not display error message when no errors', () => { + const { queryByText } = render( + <TestWrapper renderWithControl> + {(control, _errors, dirtyFields) => ( + <ContactAddressField + isEditing={true} + contact={null} + control={control} + errors={{}} + dirtyFields={dirtyFields} + /> + )} + </TestWrapper>, + ) + + const errorMessage = queryByText('Invalid Ethereum address format') + expect(errorMessage).toBeNull() + }) + }) + + describe('with existing contact data', () => { + it('should pre-populate with contact address when editing existing contact', () => { + const { getByTestId } = render( + <TestWrapper defaultValues={{ name: mockContact.name, address: mockContact.value }} renderWithControl> + {(control, errors, dirtyFields) => ( + <ContactAddressField + isEditing={true} + contact={mockContact} + control={control} + errors={errors} + dirtyFields={dirtyFields} + /> + )} + </TestWrapper>, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + }) + }) + + describe('edge cases', () => { + it('should handle undefined contact gracefully in read-only mode', () => { + const { getByTestId } = render(<ContactAddressField isEditing={false} contact={undefined} />) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + + it('should handle missing control in editing mode gracefully', () => { + // This should fall back to read-only mode when control is missing + const { getByTestId } = render( + <ContactAddressField isEditing={true} contact={mockContact} control={undefined} errors={{}} dirtyFields={{}} />, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + + it('should handle undefined errors and dirtyFields', () => { + const { getByTestId } = render( + <TestWrapper renderWithControl> + {(control) => ( + <ContactAddressField + isEditing={true} + contact={null} + control={control} + errors={undefined} + dirtyFields={undefined} + /> + )} + </TestWrapper>, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + + it('should handle contact with null value', () => { + const contactWithNullValue: Contact = { + ...mockContact, + value: null as unknown as string, + } + + const { getByTestId } = render(<ContactAddressField isEditing={false} contact={contactWithNullValue} />) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + }) + + describe('form integration', () => { + it('should render within form context', () => { + const { getByTestId } = render( + <TestWrapper renderWithControl> + {(control, errors, dirtyFields) => ( + <ContactAddressField + isEditing={true} + contact={null} + control={control} + errors={errors} + dirtyFields={dirtyFields} + /> + )} + </TestWrapper>, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + + it('should integrate with form validation', () => { + const { getByTestId } = render( + <TestWrapper renderWithControl> + {(control, errors, dirtyFields) => ( + <ContactAddressField + isEditing={true} + contact={null} + control={control} + errors={errors} + dirtyFields={dirtyFields} + /> + )} + </TestWrapper>, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + }) + + describe('component props', () => { + it('should render with all required props for editing mode', () => { + const { getByTestId, getByText } = render( + <TestWrapper renderWithControl> + {(control, errors, dirtyFields) => ( + <ContactAddressField + isEditing={true} + contact={null} + control={control} + errors={errors} + dirtyFields={dirtyFields} + /> + )} + </TestWrapper>, + ) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + + const label = getByText('Address') + expect(label).toBeDefined() + }) + + it('should render with minimal props for read-only mode', () => { + const { getByTestId, getByText } = render(<ContactAddressField isEditing={false} contact={mockContact} />) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + + const label = getByText('Address') + expect(label).toBeDefined() + }) + + it('should handle different contact value types', () => { + const contactWithEmptyValue: Contact = { + ...mockContact, + value: '', + } + + const { getByTestId } = render(<ContactAddressField isEditing={false} contact={contactWithEmptyValue} />) + + const container = getByTestId('safe-input-with-label') + expect(container).toBeDefined() + }) + }) +}) diff --git a/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactHeader.test.tsx b/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactHeader.test.tsx new file mode 100644 index 0000000000..2dc7b119c5 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactHeader.test.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { ContactHeader } from '../ContactHeader' + +describe('ContactHeader', () => { + const mockDisplayName = 'John Doe' + const mockDisplayAddress = '0x1234567890123456789012345678901234567890' + + it('should render contact name correctly', () => { + const { getByText } = render(<ContactHeader displayName={mockDisplayName} displayAddress={mockDisplayAddress} />) + + expect(getByText(mockDisplayName)).toBeTruthy() + }) + + it('should render Identicon when displayAddress is provided', () => { + const { getByTestId } = render(<ContactHeader displayName={mockDisplayName} displayAddress={mockDisplayAddress} />) + + // Identicon component should be rendered when address is provided + expect(getByTestId('identicon-image-container')).toBeTruthy() + }) + + it('should render default icon when displayAddress is not provided', () => { + const { queryByTestId, getByText } = render(<ContactHeader displayName={mockDisplayName} />) + + // Identicon should not be rendered when no address is provided + expect(queryByTestId('identicon-image-container')).toBeFalsy() + + // Should still render the display name + expect(getByText(mockDisplayName)).toBeTruthy() + }) + + it('should render display name', () => { + const { getByText } = render(<ContactHeader displayName={mockDisplayName} displayAddress={mockDisplayAddress} />) + + const nameElement = getByText(mockDisplayName) + expect(nameElement).toBeTruthy() + }) + + it('should render without address and still display name', () => { + const { getByText, queryByTestId } = render(<ContactHeader displayName={mockDisplayName} />) + + expect(getByText(mockDisplayName)).toBeTruthy() + expect(queryByTestId('identicon-image-container')).toBeFalsy() + }) +}) diff --git a/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactName.test.tsx b/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactName.test.tsx new file mode 100644 index 0000000000..517daaa963 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactName.test.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { ContactName } from '../ContactName' + +describe('ContactName', () => { + const mockAddress = '0x1234567890123456789012345678901234567890' as const + const mockName = 'John Doe' + + it('should render name when provided', () => { + const { getByText } = render(<ContactName name={mockName} address={mockAddress} />) + + expect(getByText(mockName)).toBeTruthy() + }) + + it('should render name text when provided', () => { + const { getByText } = render(<ContactName name={mockName} address={mockAddress} />) + + const nameElement = getByText(mockName) + expect(nameElement).toBeTruthy() + }) + + it('should render EthAddress component when name is not provided', () => { + const { queryByText } = render(<ContactName address={mockAddress} />) + + // Name should not be rendered + expect(queryByText(mockName)).toBeFalsy() + }) + + it('should render EthAddress component when name is undefined', () => { + const { queryByText } = render(<ContactName name={undefined} address={mockAddress} />) + + // Name should not be rendered when undefined + expect(queryByText(mockName)).toBeFalsy() + }) + + it('should render EthAddress component when name is empty string', () => { + const { queryByText } = render(<ContactName name="" address={mockAddress} />) + + // Name should not be rendered when empty + expect(queryByText('')).toBeFalsy() + }) + + it('should render name when textProps are provided', () => { + const customTextProps = { + fontSize: '$5', + color: '$red', + } + + const { getByText } = render(<ContactName name={mockName} address={mockAddress} textProps={customTextProps} />) + + const nameElement = getByText(mockName) + expect(nameElement).toBeTruthy() + }) + + it('should use address when name is falsy', () => { + const falsyValues: (string | undefined)[] = [undefined, ''] + + falsyValues.forEach((falsyName) => { + const { queryByText } = render(<ContactName name={falsyName} address={mockAddress} />) + + // Should not render any falsy name values + if (falsyName) { + expect(queryByText(falsyName)).toBeFalsy() + } + }) + }) +}) diff --git a/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactNetworkRow.test.tsx b/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactNetworkRow.test.tsx new file mode 100644 index 0000000000..69b538580a --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/components/__tests__/ContactNetworkRow.test.tsx @@ -0,0 +1,152 @@ +import React from 'react' +import { render, fireEvent } from '@/src/tests/test-utils' +import { Keyboard } from 'react-native' +import { ContactNetworkRow } from '../ContactNetworkRow' +import { useContactNetworkData } from '../../hooks/useContactNetworkData' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' + +// Mock the useContactNetworkData hook +jest.mock('../../hooks/useContactNetworkData') +const mockUseContactNetworkData = useContactNetworkData as jest.MockedFunction<typeof useContactNetworkData> + +// Mock Keyboard.dismiss using jest.spyOn +const mockKeyboardDismiss = jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => { + // Mock implementation for Keyboard.dismiss +}) + +describe('ContactNetworkRow', () => { + const mockOnPress = jest.fn() + const mockChainIds = ['1', '5'] + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render network label correctly', () => { + mockUseContactNetworkData.mockReturnValue({ + displayText: '2 Networks', + selectedChains: [], + }) + + const { getByText } = render(<ContactNetworkRow onPress={mockOnPress} chainIds={mockChainIds} />) + + expect(getByText('Network')).toBeTruthy() + }) + + it('should render display text from hook', () => { + const mockDisplayText = '2 Networks' + mockUseContactNetworkData.mockReturnValue({ + displayText: mockDisplayText, + selectedChains: [], + }) + + const { getByText } = render(<ContactNetworkRow onPress={mockOnPress} chainIds={mockChainIds} />) + + expect(getByText(mockDisplayText)).toBeTruthy() + }) + + it('should render single network name when one chain is selected', () => { + const mockDisplayText = 'Ethereum Mainnet' + mockUseContactNetworkData.mockReturnValue({ + displayText: mockDisplayText, + selectedChains: [{ chainName: 'Ethereum Mainnet', chainId: '1' } as Chain], + }) + + const { getByText } = render(<ContactNetworkRow onPress={mockOnPress} chainIds={['1']} />) + + expect(getByText(mockDisplayText)).toBeTruthy() + }) + + it('should render "All Networks" when no chains are selected', () => { + const mockDisplayText = 'All Networks' + mockUseContactNetworkData.mockReturnValue({ + displayText: mockDisplayText, + selectedChains: [], + }) + + const { getByText } = render(<ContactNetworkRow onPress={mockOnPress} chainIds={[]} />) + + expect(getByText(mockDisplayText)).toBeTruthy() + }) + + it('should call onPress when pressed', () => { + mockUseContactNetworkData.mockReturnValue({ + displayText: '2 Networks', + selectedChains: [], + }) + + const { getByText } = render(<ContactNetworkRow onPress={mockOnPress} chainIds={mockChainIds} />) + + const networkElement = getByText('Network') + const networkRow = networkElement.parent + if (networkRow) { + fireEvent.press(networkRow) + expect(mockOnPress).toHaveBeenCalledTimes(1) + } + }) + + it('should dismiss keyboard when pressed', () => { + mockUseContactNetworkData.mockReturnValue({ + displayText: '2 Networks', + selectedChains: [], + }) + + const { getByText } = render(<ContactNetworkRow onPress={mockOnPress} chainIds={mockChainIds} />) + + const networkElement = getByText('Network') + const networkRow = networkElement.parent + if (networkRow) { + fireEvent.press(networkRow) + expect(mockKeyboardDismiss).toHaveBeenCalledTimes(1) + } + }) + + it('should call useContactNetworkData with correct chainIds', () => { + mockUseContactNetworkData.mockReturnValue({ + displayText: '2 Networks', + selectedChains: [], + }) + + render(<ContactNetworkRow onPress={mockOnPress} chainIds={mockChainIds} />) + + expect(mockUseContactNetworkData).toHaveBeenCalledWith(mockChainIds) + }) + + it('should render component structure correctly', () => { + mockUseContactNetworkData.mockReturnValue({ + displayText: '2 Networks', + selectedChains: [], + }) + + const { getByText } = render(<ContactNetworkRow onPress={mockOnPress} chainIds={mockChainIds} />) + + // Check that the component structure is correct + expect(getByText('Network')).toBeTruthy() + expect(getByText('2 Networks')).toBeTruthy() + }) + + it('should handle empty chainIds array', () => { + mockUseContactNetworkData.mockReturnValue({ + displayText: 'All Networks', + selectedChains: [], + }) + + const { getByText } = render(<ContactNetworkRow onPress={mockOnPress} chainIds={[]} />) + + expect(getByText('All Networks')).toBeTruthy() + expect(mockUseContactNetworkData).toHaveBeenCalledWith([]) + }) + + it('should handle multiple chainIds', () => { + const multipleChainIds = ['1', '5', '137', '42161'] + mockUseContactNetworkData.mockReturnValue({ + displayText: '4 Networks', + selectedChains: [], + }) + + const { getByText } = render(<ContactNetworkRow onPress={mockOnPress} chainIds={multipleChainIds} />) + + expect(getByText('4 Networks')).toBeTruthy() + expect(mockUseContactNetworkData).toHaveBeenCalledWith(multipleChainIds) + }) +}) diff --git a/apps/mobile/src/features/AddressBook/Contact/components/index.tsx b/apps/mobile/src/features/AddressBook/Contact/components/index.tsx new file mode 100644 index 0000000000..58dd898f7e --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/components/index.tsx @@ -0,0 +1,5 @@ +export { ContactActionButton } from './ContactActionButton' +export { ContactAddressField } from './ContactAddressField' +export { ContactHeader } from './ContactHeader' +export { ContactNameField } from './ContactNameField' +export { ContactNetworkRow } from './ContactNetworkRow' diff --git a/apps/mobile/src/features/AddressBook/Contact/hooks/index.ts b/apps/mobile/src/features/AddressBook/Contact/hooks/index.ts new file mode 100644 index 0000000000..fa84615450 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/hooks/index.ts @@ -0,0 +1 @@ +export { useContactNetworkData } from './useContactNetworkData' diff --git a/apps/mobile/src/features/AddressBook/Contact/hooks/useContactNetworkData.test.ts b/apps/mobile/src/features/AddressBook/Contact/hooks/useContactNetworkData.test.ts new file mode 100644 index 0000000000..4e17b97b8c --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/hooks/useContactNetworkData.test.ts @@ -0,0 +1,162 @@ +import { useContactNetworkData } from './useContactNetworkData' +import { mockedChains } from '@/src/store/constants' +import * as storeHooks from '@/src/store/hooks' + +// Mock chain data for testing - using a subset of mockedChains from constants +const mockChain1 = mockedChains[0] // Gnosis Chain (chainId: "100") +const mockChain2 = mockedChains[1] // Polygon (chainId: "137") +const mockChain3 = mockedChains[2] // Arbitrum (chainId: "42161") + +// Mock the useAppSelector hook +const mockUseAppSelector = jest.spyOn(storeHooks, 'useAppSelector') + +describe('useContactNetworkData', () => { + beforeEach(() => { + // Reset mock implementations + mockUseAppSelector.mockClear() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should return "All Networks" when chainIds is empty', () => { + // Mock the selector to return empty array for empty chainIds + mockUseAppSelector.mockReturnValue([]) + + const result = useContactNetworkData([]) + + expect(result.selectedChains).toEqual([]) + expect(result.displayText).toBe('All Networks') + }) + + it('should return single chain name when chainIds has one item', () => { + // Mock the selector to return the first chain + mockUseAppSelector.mockReturnValue([mockChain1]) + + const result = useContactNetworkData(['100']) + + expect(result.selectedChains).toHaveLength(1) + expect(result.selectedChains[0]).toEqual(mockChain1) + expect(result.displayText).toBe('Gnosis Chain') + }) + + it('should return chain count when chainIds has multiple items', () => { + // Mock the selector to return multiple chains + mockUseAppSelector.mockReturnValue([mockChain1, mockChain2]) + + const result = useContactNetworkData(['100', '137']) + + expect(result.selectedChains).toHaveLength(2) + expect(result.selectedChains).toEqual([mockChain1, mockChain2]) + expect(result.displayText).toBe('2 Networks') + }) + + it('should return count text for three or more networks', () => { + // Mock the selector to return all three chains + mockUseAppSelector.mockReturnValue([mockChain1, mockChain2, mockChain3]) + + const result = useContactNetworkData(['100', '137', '42161']) + + expect(result.selectedChains).toHaveLength(3) + expect(result.selectedChains).toEqual([mockChain1, mockChain2, mockChain3]) + expect(result.displayText).toBe('3 Networks') + }) + + it('should filter out invalid chain IDs', () => { + // Mock the selector to return only valid chains (invalid ones are filtered by the selector) + mockUseAppSelector.mockReturnValue([mockChain1, mockChain2]) + + const result = useContactNetworkData(['100', 'invalid-chain-id', '137']) + + expect(result.selectedChains).toHaveLength(2) + expect(result.selectedChains).toEqual([mockChain1, mockChain2]) + expect(result.displayText).toBe('3 Networks') + }) + + it('should handle when no chains match the provided IDs', () => { + // Mock the selector to return empty array (no matching chains) + mockUseAppSelector.mockReturnValue([]) + + const result = useContactNetworkData(['999', '888']) + + expect(result.selectedChains).toHaveLength(0) + expect(result.displayText).toBe('2 Networks') + }) + + it('should handle empty chains data for single chain', () => { + // Mock the selector to return empty array + mockUseAppSelector.mockReturnValue([]) + + const result = useContactNetworkData(['100']) + + expect(result.selectedChains).toHaveLength(0) + expect(result.displayText).toBe('Unknown Network') + }) + + it('should handle single chain with correct name formatting', () => { + // Mock the selector to return Polygon chain + mockUseAppSelector.mockReturnValue([mockChain2]) + + const result = useContactNetworkData(['137']) + + expect(result.selectedChains).toHaveLength(1) + expect(result.selectedChains[0]).toEqual(mockChain2) + expect(result.displayText).toBe('Polygon') + }) + + it('should maintain order of chains as returned by selector', () => { + // Mock the selector to return chains in specific order + mockUseAppSelector.mockReturnValue([mockChain1, mockChain2, mockChain3]) + + const result = useContactNetworkData(['137', '100', '42161']) + + expect(result.selectedChains).toHaveLength(3) + // The order should match the order returned by getChainsByIds selector + expect(result.selectedChains[0].chainId).toBe('100') + expect(result.selectedChains[1].chainId).toBe('137') + expect(result.selectedChains[2].chainId).toBe('42161') + }) + + it('should handle Arbitrum chain correctly', () => { + // Mock the selector to return Arbitrum chain + mockUseAppSelector.mockReturnValue([mockChain3]) + + const result = useContactNetworkData(['42161']) + + expect(result.selectedChains).toHaveLength(1) + expect(result.selectedChains[0]).toEqual(mockChain3) + expect(result.displayText).toBe('Arbitrum') + }) + + it('should handle edge case where single chain is not found', () => { + // Mock the selector to return empty array for a single chainId + mockUseAppSelector.mockReturnValue([]) + + const result = useContactNetworkData(['999']) + + expect(result.selectedChains).toHaveLength(0) + expect(result.displayText).toBe('Unknown Network') + }) + + it('should handle very large number of chains', () => { + // Mock the selector to return 10 chains + const manyChains = Array(10).fill(mockChain1) + mockUseAppSelector.mockReturnValue(manyChains) + + const result = useContactNetworkData(Array(10).fill('100')) + + expect(result.selectedChains).toHaveLength(10) + expect(result.displayText).toBe('10 Networks') + }) + + it('should handle empty selectedChains for multiple chainIds', () => { + // Mock the selector to return empty array even for multiple chainIds + mockUseAppSelector.mockReturnValue([]) + + const result = useContactNetworkData(['100', '137', '42161']) + + expect(result.selectedChains).toHaveLength(0) + expect(result.displayText).toBe('3 Networks') + }) +}) diff --git a/apps/mobile/src/features/AddressBook/Contact/hooks/useContactNetworkData.ts b/apps/mobile/src/features/AddressBook/Contact/hooks/useContactNetworkData.ts new file mode 100644 index 0000000000..15619ecb25 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/hooks/useContactNetworkData.ts @@ -0,0 +1,21 @@ +import { useAppSelector } from '@/src/store/hooks' +import { getChainsByIds } from '@/src/store/chains' + +export const useContactNetworkData = (chainIds: string[]) => { + const selectedChains = useAppSelector((state) => getChainsByIds(state, chainIds)) + + const getDisplayText = () => { + if (chainIds.length === 0) { + return 'All Networks' + } + if (chainIds.length === 1) { + return selectedChains[0]?.chainName || 'Unknown Network' + } + return `${chainIds.length} Networks` + } + + return { + selectedChains, + displayText: getDisplayText(), + } +} diff --git a/apps/mobile/src/features/AddressBook/Contact/hooks/useDeleteContact.test.ts b/apps/mobile/src/features/AddressBook/Contact/hooks/useDeleteContact.test.ts new file mode 100644 index 0000000000..d55a90b696 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/hooks/useDeleteContact.test.ts @@ -0,0 +1,209 @@ +import { renderHook, act } from '@/src/tests/test-utils' +import { Alert } from 'react-native' +import { useDeleteContact } from './useDeleteContact' +import { removeContact, type Contact } from '@/src/store/addressBookSlice' +import { router } from 'expo-router' + +// Mock dependencies +jest.mock('react-native/Libraries/Alert/Alert') + +jest.mock('expo-router', () => ({ + router: { + back: jest.fn(), + }, +})) + +jest.mock('@/src/store/hooks', () => ({ + useAppDispatch: () => mockDispatch, + useAppSelector: jest.fn(), +})) + +jest.mock('@/src/store/addressBookSlice', () => ({ + removeContact: jest.fn(), +})) + +// Mock the notification sync middleware to avoid import issues +jest.mock('@/src/store/middleware/notificationSync', () => ({ + __esModule: true, + default: () => (next: (action: unknown) => unknown) => (action: unknown) => next(action), +})) + +const mockDispatch = jest.fn() +const mockSetIsEditing = jest.fn() + +const mockContact: Contact = { + value: '0x1234567890123456789012345678901234567890', + name: 'Test Contact', + chainIds: ['1', '137'], +} + +describe('useDeleteContact', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return handleDeletePress function', () => { + const { result } = renderHook(() => + useDeleteContact({ + contact: mockContact, + setIsEditing: mockSetIsEditing, + }), + ) + + expect(result.current.handleDeletePress).toBeDefined() + expect(typeof result.current.handleDeletePress).toBe('function') + }) + + it('should show confirmation alert when handleDeletePress is called', () => { + const { result } = renderHook(() => + useDeleteContact({ + contact: mockContact, + setIsEditing: mockSetIsEditing, + }), + ) + + act(() => { + result.current.handleDeletePress() + }) + + expect(Alert.alert).toHaveBeenCalledWith( + 'Delete Contact', + 'Do you really want to delete this contact?', + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Delete', + style: 'destructive', + onPress: expect.any(Function), + }, + ], + { cancelable: true }, + ) + }) + + it('should not show alert when handleDeletePress is called with no contact', () => { + const { result } = renderHook(() => + useDeleteContact({ + contact: null, + setIsEditing: mockSetIsEditing, + }), + ) + + act(() => { + result.current.handleDeletePress() + }) + + expect(Alert.alert).not.toHaveBeenCalled() + }) + + it('should dispatch removeContact, set editing to false, and navigate back when delete is confirmed', () => { + const { result } = renderHook(() => + useDeleteContact({ + contact: mockContact, + setIsEditing: mockSetIsEditing, + }), + ) + + // Trigger the alert + act(() => { + result.current.handleDeletePress() + }) + + // Get the onPress function from the alert call and trigger it + const alertCall = (Alert.alert as jest.Mock).mock.calls[0] + const deleteButton = alertCall[2][1] + const onPressFunction = deleteButton.onPress + + act(() => { + onPressFunction() + }) + + expect(mockDispatch).toHaveBeenCalledWith(removeContact(mockContact.value)) + expect(mockSetIsEditing).toHaveBeenCalledWith(false) + + // Fast-forward through setTimeout + act(() => { + jest.advanceTimersByTime(100) + }) + + expect(router.back).toHaveBeenCalled() + }) + + it('should not perform any action when delete is confirmed with no contact', () => { + const { result } = renderHook(() => + useDeleteContact({ + contact: null, + setIsEditing: mockSetIsEditing, + }), + ) + + // Should not show alert when no contact + act(() => { + result.current.handleDeletePress() + }) + + expect(Alert.alert).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + expect(mockSetIsEditing).not.toHaveBeenCalled() + expect(router.back).not.toHaveBeenCalled() + }) + + it('should execute delete confirmation when alert confirm button is pressed', () => { + const { result } = renderHook(() => + useDeleteContact({ + contact: mockContact, + setIsEditing: mockSetIsEditing, + }), + ) + + act(() => { + result.current.handleDeletePress() + }) + + // Get the onPress function from the alert call + const alertCall = (Alert.alert as jest.Mock).mock.calls[0] + const deleteButton = alertCall[2][1] // Second button (Delete button) + const onPressFunction = deleteButton.onPress + + act(() => { + onPressFunction() + }) + + expect(mockDispatch).toHaveBeenCalledWith(removeContact(mockContact.value)) + expect(mockSetIsEditing).toHaveBeenCalledWith(false) + + // Fast-forward through setTimeout + act(() => { + jest.advanceTimersByTime(100) + }) + + expect(router.back).toHaveBeenCalled() + }) + + it('should memoize functions with useCallback', () => { + const { result, rerender } = renderHook(() => + useDeleteContact({ + contact: mockContact, + setIsEditing: mockSetIsEditing, + }), + ) + + const firstRenderFunctions = { + handleDeletePress: result.current.handleDeletePress, + } + + // Re-render with the same props to test memoization + rerender(() => + useDeleteContact({ + contact: mockContact, + setIsEditing: mockSetIsEditing, + }), + ) + + // Functions should be the same reference due to useCallback + expect(result.current.handleDeletePress).toBe(firstRenderFunctions.handleDeletePress) + }) +}) diff --git a/apps/mobile/src/features/AddressBook/Contact/hooks/useDeleteContact.ts b/apps/mobile/src/features/AddressBook/Contact/hooks/useDeleteContact.ts new file mode 100644 index 0000000000..d14d639e4d --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/hooks/useDeleteContact.ts @@ -0,0 +1,53 @@ +import { useCallback } from 'react' +import { Alert } from 'react-native' +import { router } from 'expo-router' +import { useAppDispatch } from '@/src/store/hooks' +import { removeContact, type Contact } from '@/src/store/addressBookSlice' + +interface UseDeleteContactParams { + contact?: Contact | null + setIsEditing: (isEditing: boolean) => void +} + +export const useDeleteContact = ({ contact, setIsEditing }: UseDeleteContactParams) => { + const dispatch = useAppDispatch() + + const handleDeleteConfirm = useCallback(() => { + if (!contact) { + return + } + + dispatch(removeContact(contact.value)) + setIsEditing(false) + setTimeout(() => { + router.back() + }, 100) + }, [contact, dispatch, setIsEditing]) + + const handleDeletePress = useCallback(() => { + if (!contact) { + return + } + + Alert.alert( + 'Delete Contact', + 'Do you really want to delete this contact?', + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Delete', + style: 'destructive', + onPress: handleDeleteConfirm, + }, + ], + { cancelable: true }, + ) + }, [contact, handleDeleteConfirm]) + + return { + handleDeletePress, + } +} diff --git a/apps/mobile/src/features/AddressBook/Contact/hooks/useEditContact.test.ts b/apps/mobile/src/features/AddressBook/Contact/hooks/useEditContact.test.ts new file mode 100644 index 0000000000..0b0c7827b4 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/hooks/useEditContact.test.ts @@ -0,0 +1,209 @@ +import { renderHook, act } from '@/src/tests/test-utils' +import { Alert } from 'react-native' +import { useEditContact } from './useEditContact' +import { type Contact } from '@/src/store/addressBookSlice' +import { router } from 'expo-router' +import type { RootState } from '@/src/tests/test-utils' + +// Mock dependencies +jest.mock('react-native/Libraries/Alert/Alert') + +jest.mock('expo-router', () => ({ + router: { + setParams: jest.fn(), + }, +})) + +const mockSetIsEditing = jest.fn() + +const mockExistingContact: Contact = { + value: '0x1234567890123456789012345678901234567890', + name: 'Existing Contact', + chainIds: ['1', '137'], +} + +const mockNewContact: Contact = { + value: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + name: 'New Contact', + chainIds: ['1'], +} + +// Set up initial store state with contacts +const initialStore: Partial<RootState> = { + addressBook: { + contacts: { + [mockExistingContact.value]: mockExistingContact, + }, + selectedContact: null, + }, +} + +describe('useEditContact', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return handleEdit and handleSave functions', () => { + const { result } = renderHook( + () => + useEditContact({ + mode: 'edit', + setIsEditing: mockSetIsEditing, + }), + initialStore, + ) + + expect(result.current.handleEdit).toBeDefined() + expect(result.current.handleSave).toBeDefined() + expect(typeof result.current.handleEdit).toBe('function') + expect(typeof result.current.handleSave).toBe('function') + }) + + it('should set editing to true when handleEdit is called', () => { + const { result } = renderHook( + () => + useEditContact({ + mode: 'edit', + setIsEditing: mockSetIsEditing, + }), + initialStore, + ) + + act(() => { + result.current.handleEdit() + }) + + expect(mockSetIsEditing).toHaveBeenCalledWith(true) + }) + + it('should update existing contact in edit mode', () => { + const { result } = renderHook( + () => + useEditContact({ + mode: 'edit', + setIsEditing: mockSetIsEditing, + }), + initialStore, + ) + + act(() => { + result.current.handleSave(mockExistingContact) + }) + + expect(mockSetIsEditing).toHaveBeenCalledWith(false) + }) + + it('should add new contact when no existing contact found', () => { + const { result } = renderHook( + () => + useEditContact({ + mode: 'new', + setIsEditing: mockSetIsEditing, + }), + initialStore, + ) + + act(() => { + result.current.handleSave(mockNewContact) + }) + + expect(mockSetIsEditing).toHaveBeenCalledWith(false) + expect(router.setParams).toHaveBeenCalledWith({ + address: mockNewContact.value, + mode: 'view', + }) + }) + + it('should show alert when contact with same address already exists', () => { + const { result } = renderHook( + () => + useEditContact({ + mode: 'new', + setIsEditing: mockSetIsEditing, + }), + initialStore, + ) + + const contactWithExistingAddress = { + ...mockNewContact, + value: mockExistingContact.value, // Same address as existing contact + } + + act(() => { + result.current.handleSave(contactWithExistingAddress) + }) + + expect(Alert.alert).toHaveBeenCalledWith( + 'Contact Already Exists', + `A contact with this address already exists: "${mockExistingContact.name}". Do you want to update the existing contact?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Update Existing', + onPress: expect.any(Function), + }, + ], + { cancelable: true }, + ) + + // Should not set editing immediately + expect(mockSetIsEditing).not.toHaveBeenCalled() + }) + + it('should update existing contact when user confirms in alert', () => { + const { result } = renderHook( + () => + useEditContact({ + mode: 'new', + setIsEditing: mockSetIsEditing, + }), + initialStore, + ) + + const contactWithExistingAddress = { + ...mockNewContact, + value: mockExistingContact.value, + } + + act(() => { + result.current.handleSave(contactWithExistingAddress) + }) + + // Get the onPress function from the alert call + const alertCall = (Alert.alert as jest.Mock).mock.calls[0] + const updateButton = alertCall[2][1] // Second button (Update Existing) + const onPressFunction = updateButton.onPress + + act(() => { + onPressFunction() + }) + + expect(mockSetIsEditing).toHaveBeenCalledWith(false) + expect(router.setParams).toHaveBeenCalledWith({ + address: contactWithExistingAddress.value, + mode: 'view', + }) + }) + + it('should return stable function references when props do not change', () => { + const { result } = renderHook( + () => + useEditContact({ + mode: 'edit', + setIsEditing: mockSetIsEditing, + }), + initialStore, + ) + + // Test that the functions exist and are callable + expect(typeof result.current.handleEdit).toBe('function') + expect(typeof result.current.handleSave).toBe('function') + + // Test that the handleEdit function is stable (doesn't depend on external state) + const handleEditRef = result.current.handleEdit + expect(result.current.handleEdit).toBe(handleEditRef) + }) +}) diff --git a/apps/mobile/src/features/AddressBook/Contact/hooks/useEditContact.ts b/apps/mobile/src/features/AddressBook/Contact/hooks/useEditContact.ts new file mode 100644 index 0000000000..eb3f80ff1b --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/hooks/useEditContact.ts @@ -0,0 +1,78 @@ +import { useCallback } from 'react' +import { Alert } from 'react-native' +import { router } from 'expo-router' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { selectAllContacts, addContact, updateContact, type Contact } from '@/src/store/addressBookSlice' + +interface UseEditContactParams { + mode?: 'view' | 'edit' | 'new' + setIsEditing: (isEditing: boolean) => void +} + +export const useEditContact = ({ mode, setIsEditing }: UseEditContactParams) => { + const dispatch = useAppDispatch() + const allContacts = useAppSelector(selectAllContacts) + + const findExistingContact = useCallback( + (contactAddress: string) => { + return allContacts.find((c) => c.value === contactAddress) + }, + [allContacts], + ) + + const handleEdit = useCallback(() => { + setIsEditing(true) + }, [setIsEditing]) + + const handleSave = useCallback( + (contactToSave: Contact) => { + if (mode === 'new') { + // Check if a contact with this address already exists + const existingContact = findExistingContact(contactToSave.value) + + if (existingContact) { + Alert.alert( + 'Contact Already Exists', + `A contact with this address already exists: "${existingContact.name}". Do you want to update the existing contact?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Update Existing', + onPress: () => { + dispatch(updateContact(contactToSave)) + setIsEditing(false) + router.setParams({ + address: contactToSave.value, + mode: 'view', + }) + }, + }, + ], + { cancelable: true }, + ) + return + } + + dispatch(addContact(contactToSave)) + setIsEditing(false) + // Update the URL parameters to reflect that we're now viewing an existing contact + router.setParams({ + address: contactToSave.value, + mode: 'view', + }) + } else { + dispatch(updateContact(contactToSave)) + setIsEditing(false) + } + }, + [mode, findExistingContact, dispatch, setIsEditing], + ) + + return { + handleEdit, + handleSave, + } +} diff --git a/apps/mobile/src/features/AddressBook/Contact/schemas/contactSchema.ts b/apps/mobile/src/features/AddressBook/Contact/schemas/contactSchema.ts new file mode 100644 index 0000000000..47b1dafd3f --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/schemas/contactSchema.ts @@ -0,0 +1,31 @@ +import { z } from 'zod' +import { isValidAddress } from '@safe-global/utils/utils/validation' +import { parsePrefixedAddress } from '@safe-global/utils/utils/addresses' + +export const contactSchema = z.object({ + name: z + .string() + .min(1, 'Name is required') + .max(50, 'Name is too long') + .refine((value) => value.trim().length > 0, { + message: 'Name cannot be empty or only whitespace', + }), + address: z + .string() + .min(1, 'Address is required') + .refine( + (value) => { + try { + const { address } = parsePrefixedAddress(value) + return isValidAddress(address) + } catch { + return false + } + }, + { + message: 'Invalid Ethereum address format', + }, + ), +}) + +export type ContactFormData = z.infer<typeof contactSchema> diff --git a/apps/mobile/src/features/AddressBook/Contact/schemas/index.ts b/apps/mobile/src/features/AddressBook/Contact/schemas/index.ts new file mode 100644 index 0000000000..f461b032bb --- /dev/null +++ b/apps/mobile/src/features/AddressBook/Contact/schemas/index.ts @@ -0,0 +1 @@ +export { contactSchema, type ContactFormData } from './contactSchema' diff --git a/apps/mobile/src/features/AddressBook/List/AddressBookList.container.test.tsx b/apps/mobile/src/features/AddressBook/List/AddressBookList.container.test.tsx new file mode 100644 index 0000000000..541f4bb783 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/AddressBookList.container.test.tsx @@ -0,0 +1,255 @@ +import { render, userEvent } from '@/src/tests/test-utils' +import { AddressBookListContainer } from './AddressBookList.container' +import { Contact } from '@/src/store/addressBookSlice' +import * as router from 'expo-router' +import React from 'react' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { trackEvent } from '@/src/services/analytics' + +// Mock expo-router +jest.mock('expo-router', () => ({ + router: { + push: jest.fn(), + }, +})) + +// Mock analytics tracking +jest.mock('@/src/services/analytics', () => ({ + trackEvent: jest.fn(), +})) + +// Mock the AddressBookListView component +jest.mock('./components/AddressBookListView', () => ({ + AddressBookListView: ({ + contacts, + filteredContacts, + onSearch, + onSelectContact, + onAddContact, + }: { + contacts: AddressInfo[] + filteredContacts: AddressInfo[] + onSearch: (query: string) => void + onSelectContact: (contact: AddressInfo) => void + onAddContact: () => void + }) => { + const React = require('react') + return React.createElement( + 'View', + { testID: 'address-book-view' }, + React.createElement('Text', { testID: 'total-contacts' }, contacts.length), + React.createElement('Text', { testID: 'filtered-contacts' }, filteredContacts.length), + React.createElement('TextInput', { testID: 'search-input', onChangeText: onSearch }), + React.createElement( + 'Pressable', + { testID: 'add-contact-btn', onPress: onAddContact }, + React.createElement('Text', null, 'Add Contact'), + ), + filteredContacts.map((contact: AddressInfo) => + React.createElement( + 'Pressable', + { + key: contact.value, + testID: `select-contact-${contact.value}`, + onPress: () => onSelectContact(contact), + }, + React.createElement('Text', null, contact.name), + ), + ), + ) + }, +})) + +describe('AddressBookListContainer', () => { + const mockContacts: Contact[] = [ + { + value: '0x1234567890123456789012345678901234567890', + name: 'Alice', + chainIds: ['1'], + }, + { + value: '0x0987654321098765432109876543210987654321', + name: 'Bob', + chainIds: ['1'], + }, + { + value: '0x1111111111111111111111111111111111111111', + name: 'Charlie', + chainIds: ['1'], + }, + ] + + const mockStore = { + addressBook: { + contacts: { + '0x1234567890123456789012345678901234567890': mockContacts[0], + '0x0987654321098765432109876543210987654321': mockContacts[1], + '0x1111111111111111111111111111111111111111': mockContacts[2], + }, + selectedContact: null, + }, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render with contacts from Redux store', () => { + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + expect(container.getByTestId('address-book-view')).toBeTruthy() + expect(container.getByTestId('total-contacts')).toHaveTextContent('3') + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('3') + }) + + it('should filter contacts by name when searching', async () => { + const user = userEvent.setup() + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const searchInput = container.getByTestId('search-input') + await user.type(searchInput, 'Alice') + + // Should show only Alice in filtered results + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('1') + expect(container.getByTestId('select-contact-0x1234567890123456789012345678901234567890')).toBeTruthy() + expect(container.queryByTestId('select-contact-0x0987654321098765432109876543210987654321')).not.toBeTruthy() + }) + + it('should filter contacts by address when searching', async () => { + const user = userEvent.setup() + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const searchInput = container.getByTestId('search-input') + await user.type(searchInput, '0x0987654321') + + // Should show only Bob in filtered results + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('1') + expect(container.getByTestId('select-contact-0x0987654321098765432109876543210987654321')).toBeTruthy() + expect(container.queryByTestId('select-contact-0x1234567890123456789012345678901234567890')).not.toBeTruthy() + }) + + it('should show all contacts when search is empty', async () => { + const user = userEvent.setup() + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const searchInput = container.getByTestId('search-input') + + // First search for something + await user.type(searchInput, 'Alice') + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('1') + + // Then clear the search + await user.clear(searchInput) + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('3') + }) + + it('should be case insensitive when searching', async () => { + const user = userEvent.setup() + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const searchInput = container.getByTestId('search-input') + await user.type(searchInput, 'ALICE') + + // Should still find Alice despite case difference + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('1') + expect(container.getByTestId('select-contact-0x1234567890123456789012345678901234567890')).toBeTruthy() + }) + + it('should navigate to contact view when selecting a contact', async () => { + const user = userEvent.setup() + const mockPush = jest.spyOn(router.router, 'push') + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const selectContactBtn = container.getByTestId('select-contact-0x1234567890123456789012345678901234567890') + await user.press(selectContactBtn) + + expect(mockPush).toHaveBeenCalledWith({ + pathname: '/contact', + params: { + address: '0x1234567890123456789012345678901234567890', + mode: 'view', + }, + }) + }) + + it('should navigate to add contact when pressing add contact button', async () => { + const user = userEvent.setup() + const mockPush = jest.spyOn(router.router, 'push') + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const addContactBtn = container.getByTestId('add-contact-btn') + await user.press(addContactBtn) + + expect(mockPush).toHaveBeenCalledWith({ + pathname: '/contact', + params: { + mode: 'new', + }, + }) + }) + + it('should show empty results when search matches nothing', async () => { + const user = userEvent.setup() + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const searchInput = container.getByTestId('search-input') + await user.type(searchInput, 'NonexistentName') + + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('0') + expect(container.queryByTestId('select-contact-0x1234567890123456789012345678901234567890')).not.toBeTruthy() + expect(container.queryByTestId('select-contact-0x0987654321098765432109876543210987654321')).not.toBeTruthy() + expect(container.queryByTestId('select-contact-0x1111111111111111111111111111111111111111')).not.toBeTruthy() + }) + + it('should handle empty contacts array from store', () => { + const emptyStore = { + addressBook: { + contacts: {}, + selectedContact: null, + }, + } + + const container = render(<AddressBookListContainer />, { initialStore: emptyStore }) + + expect(container.getByTestId('total-contacts')).toHaveTextContent('0') + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('0') + }) + + it('should handle contacts without names in search', async () => { + const contactWithoutName: Contact = { + value: '0x1234567890123456789012345678901234567890', + name: '', + chainIds: ['1'], + } + + const storeWithUnnamedContacts = { + addressBook: { + contacts: { + '0x1234567890123456789012345678901234567890': contactWithoutName, + }, + selectedContact: null, + }, + } + + const user = userEvent.setup() + const container = render(<AddressBookListContainer />, { initialStore: storeWithUnnamedContacts }) + + const searchInput = container.getByTestId('search-input') + await user.type(searchInput, '0x1234') + + // Should still find the contact by address + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('1') + }) + + it('should track address book screen visit analytics on render', () => { + render(<AddressBookListContainer />, { initialStore: mockStore }) + + // Should track screen visit with correct contact count + expect(trackEvent).toHaveBeenCalledWith({ + eventName: 'metadata', + eventCategory: 'address-book', + eventAction: 'Screen visited', + eventLabel: '3', + }) + }) +}) diff --git a/apps/mobile/src/features/AddressBook/List/AddressBookList.container.tsx b/apps/mobile/src/features/AddressBook/List/AddressBookList.container.tsx new file mode 100644 index 0000000000..e939577529 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/AddressBookList.container.tsx @@ -0,0 +1,72 @@ +import React, { useCallback, useState, useMemo, useEffect } from 'react' +import { router } from 'expo-router' + +import { useAppSelector } from '@/src/store/hooks' +import { AddressBookListView } from './components/AddressBookListView' +import { selectAllContacts } from '@/src/store/addressBookSlice' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { trackEvent } from '@/src/services/analytics' +import { createAddressBookScreenVisitEvent } from '@/src/services/analytics/events/addressBook' + +export const AddressBookListContainer = () => { + const contacts = useAppSelector(selectAllContacts) + const [searchQuery, setSearchQuery] = useState('') + + // Track screen visit when component mounts + useEffect(() => { + try { + const totalContactCount = contacts.length + const event = createAddressBookScreenVisitEvent(totalContactCount) + trackEvent(event) + } catch (error) { + console.error('Error tracking address book screen visit:', error) + } + }, [contacts.length]) + + // Memoized filtered contacts for performance + const filteredContacts = useMemo(() => { + if (!searchQuery.trim()) { + return contacts + } + + const lowercaseQuery = searchQuery.toLowerCase() + return contacts.filter((contact) => { + const matchesName = contact.name?.toLowerCase().includes(lowercaseQuery) + const matchesAddress = contact.value.toLowerCase().includes(lowercaseQuery) + return matchesName || matchesAddress + }) + }, [contacts, searchQuery]) + + const handleSearch = useCallback((query: string) => { + setSearchQuery(query) + }, []) + + const handleSelectContact = useCallback((contact: AddressInfo) => { + router.push({ + pathname: '/contact', + params: { + address: contact.value, + mode: 'view', + }, + }) + }, []) + + const handleAddContact = useCallback(() => { + router.push({ + pathname: '/contact', + params: { + mode: 'new', + }, + }) + }, []) + + return ( + <AddressBookListView + contacts={contacts} + filteredContacts={filteredContacts} + onSearch={handleSearch} + onSelectContact={handleSelectContact} + onAddContact={handleAddContact} + /> + ) +} diff --git a/apps/mobile/src/features/AddressBook/List/ContactItemActions.container.tsx b/apps/mobile/src/features/AddressBook/List/ContactItemActions.container.tsx new file mode 100644 index 0000000000..ee4ca61d91 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/ContactItemActions.container.tsx @@ -0,0 +1,75 @@ +import React, { useCallback } from 'react' +import { Alert } from 'react-native' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useAppDispatch } from '@/src/store/hooks' +import { removeContact } from '@/src/store/addressBookSlice' +import { useCopyAndDispatchToast } from '@/src/hooks/useCopyAndDispatchToast' +import { useContactActions } from './hooks/useContactActions' +import { ContactListItems } from './components/List/ContactListItems' + +interface ContactItemActionsContainerProps { + contacts: AddressInfo[] + onSelectContact: (contact: AddressInfo) => void +} + +export const ContactItemActionsContainer: React.FC<ContactItemActionsContainerProps> = ({ + contacts, + onSelectContact, +}) => { + const dispatch = useAppDispatch() + const copy = useCopyAndDispatchToast() + const actions = useContactActions() + + const handleDeleteContact = useCallback( + (contact: AddressInfo) => { + Alert.alert( + 'Delete Contact', + 'Do you really want to delete this contact?', + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + dispatch(removeContact(contact.value)) + }, + }, + ], + { cancelable: true }, + ) + }, + [dispatch], + ) + + const handleCopyContact = useCallback( + (contact: AddressInfo) => { + copy(contact.value as string) + }, + [copy], + ) + + const handleMenuAction = useCallback( + (contact: AddressInfo, actionId: string) => { + if (actionId === 'copy') { + return handleCopyContact(contact) + } + + if (actionId === 'delete') { + return handleDeleteContact(contact) + } + }, + [handleCopyContact, handleDeleteContact], + ) + + return ( + <ContactListItems + contacts={contacts} + onSelectContact={onSelectContact} + onMenuAction={handleMenuAction} + menuActions={actions} + /> + ) +} diff --git a/apps/mobile/src/features/AddressBook/List/List.container.test.tsx b/apps/mobile/src/features/AddressBook/List/List.container.test.tsx new file mode 100644 index 0000000000..1f08c7b933 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/List.container.test.tsx @@ -0,0 +1,237 @@ +import { render, userEvent } from '@/src/tests/test-utils' +import { AddressBookListContainer } from './AddressBookList.container' +import { Contact } from '@/src/store/addressBookSlice' +import * as router from 'expo-router' +import React from 'react' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +// Mock expo-router +jest.mock('expo-router', () => ({ + router: { + push: jest.fn(), + }, +})) + +// Mock the AddressBookListView component +jest.mock('./components/AddressBookListView', () => ({ + AddressBookListView: ({ + contacts, + filteredContacts, + onSearch, + onSelectContact, + onAddContact, + }: { + contacts: AddressInfo[] + filteredContacts: AddressInfo[] + onSearch: (query: string) => void + onSelectContact: (contact: AddressInfo) => void + onAddContact: () => void + }) => { + const React = require('react') + return React.createElement( + 'View', + { testID: 'address-book-view' }, + React.createElement('Text', { testID: 'total-contacts' }, contacts.length), + React.createElement('Text', { testID: 'filtered-contacts' }, filteredContacts.length), + React.createElement('TextInput', { testID: 'search-input', onChangeText: onSearch }), + React.createElement( + 'Pressable', + { testID: 'add-contact-btn', onPress: onAddContact }, + React.createElement('Text', null, 'Add Contact'), + ), + filteredContacts.map((contact: AddressInfo) => + React.createElement( + 'Pressable', + { + key: contact.value, + testID: `select-contact-${contact.value}`, + onPress: () => onSelectContact(contact), + }, + React.createElement('Text', null, contact.name), + ), + ), + ) + }, +})) + +describe('AddressBookListContainer', () => { + const mockContacts: Contact[] = [ + { + value: '0x1234567890123456789012345678901234567890', + name: 'Alice', + chainIds: ['1'], + }, + { + value: '0x0987654321098765432109876543210987654321', + name: 'Bob', + chainIds: ['1'], + }, + { + value: '0x1111111111111111111111111111111111111111', + name: 'Charlie', + chainIds: ['1'], + }, + ] + + const mockStore = { + addressBook: { + contacts: { + '0x1234567890123456789012345678901234567890': mockContacts[0], + '0x0987654321098765432109876543210987654321': mockContacts[1], + '0x1111111111111111111111111111111111111111': mockContacts[2], + }, + selectedContact: null, + }, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render with contacts from Redux store', () => { + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + expect(container.getByTestId('address-book-view')).toBeTruthy() + expect(container.getByTestId('total-contacts')).toHaveTextContent('3') + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('3') + }) + + it('should filter contacts by name when searching', async () => { + const user = userEvent.setup() + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const searchInput = container.getByTestId('search-input') + await user.type(searchInput, 'Alice') + + // Should show only Alice in filtered results + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('1') + expect(container.getByTestId('select-contact-0x1234567890123456789012345678901234567890')).toBeTruthy() + expect(container.queryByTestId('select-contact-0x0987654321098765432109876543210987654321')).not.toBeTruthy() + }) + + it('should filter contacts by address when searching', async () => { + const user = userEvent.setup() + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const searchInput = container.getByTestId('search-input') + await user.type(searchInput, '0x0987654321') + + // Should show only Bob in filtered results + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('1') + expect(container.getByTestId('select-contact-0x0987654321098765432109876543210987654321')).toBeTruthy() + expect(container.queryByTestId('select-contact-0x1234567890123456789012345678901234567890')).not.toBeTruthy() + }) + + it('should show all contacts when search is empty', async () => { + const user = userEvent.setup() + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const searchInput = container.getByTestId('search-input') + + // First search for something + await user.type(searchInput, 'Alice') + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('1') + + // Then clear the search + await user.clear(searchInput) + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('3') + }) + + it('should be case insensitive when searching', async () => { + const user = userEvent.setup() + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const searchInput = container.getByTestId('search-input') + await user.type(searchInput, 'ALICE') + + // Should still find Alice despite case difference + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('1') + expect(container.getByTestId('select-contact-0x1234567890123456789012345678901234567890')).toBeTruthy() + }) + + it('should navigate to contact view when selecting a contact', async () => { + const user = userEvent.setup() + const mockPush = jest.spyOn(router.router, 'push') + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const selectContactBtn = container.getByTestId('select-contact-0x1234567890123456789012345678901234567890') + await user.press(selectContactBtn) + + expect(mockPush).toHaveBeenCalledWith({ + pathname: '/contact', + params: { + address: '0x1234567890123456789012345678901234567890', + mode: 'view', + }, + }) + }) + + it('should navigate to add contact when pressing add contact button', async () => { + const user = userEvent.setup() + const mockPush = jest.spyOn(router.router, 'push') + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const addContactBtn = container.getByTestId('add-contact-btn') + await user.press(addContactBtn) + + expect(mockPush).toHaveBeenCalledWith({ + pathname: '/contact', + params: { + mode: 'new', + }, + }) + }) + + it('should show empty results when search matches nothing', async () => { + const user = userEvent.setup() + const container = render(<AddressBookListContainer />, { initialStore: mockStore }) + + const searchInput = container.getByTestId('search-input') + await user.type(searchInput, 'NonexistentName') + + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('0') + expect(container.queryByTestId('select-contact-0x1234567890123456789012345678901234567890')).not.toBeTruthy() + expect(container.queryByTestId('select-contact-0x0987654321098765432109876543210987654321')).not.toBeTruthy() + expect(container.queryByTestId('select-contact-0x1111111111111111111111111111111111111111')).not.toBeTruthy() + }) + + it('should handle empty contacts array from store', () => { + const emptyStore = { + addressBook: { + contacts: {}, + selectedContact: null, + }, + } + + const container = render(<AddressBookListContainer />, { initialStore: emptyStore }) + + expect(container.getByTestId('total-contacts')).toHaveTextContent('0') + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('0') + }) + + it('should handle contacts without names in search', async () => { + const contactWithoutName: Contact = { + value: '0x1234567890123456789012345678901234567890', + name: '', + chainIds: ['1'], + } + + const storeWithUnnamedContacts = { + addressBook: { + contacts: { + '0x1234567890123456789012345678901234567890': contactWithoutName, + }, + selectedContact: null, + }, + } + + const user = userEvent.setup() + const container = render(<AddressBookListContainer />, { initialStore: storeWithUnnamedContacts }) + + const searchInput = container.getByTestId('search-input') + await user.type(searchInput, '0x1234') + + // Should still find the contact by address + expect(container.getByTestId('filtered-contacts')).toHaveTextContent('1') + }) +}) diff --git a/apps/mobile/src/features/AddressBook/List/components/AddressBookListView.test.tsx b/apps/mobile/src/features/AddressBook/List/components/AddressBookListView.test.tsx new file mode 100644 index 0000000000..63d7a76a3a --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/components/AddressBookListView.test.tsx @@ -0,0 +1,176 @@ +import { render, userEvent } from '@/src/tests/test-utils' +import { AddressBookListView } from './AddressBookListView' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import React from 'react' + +// Only mock the complex components with dependencies +jest.mock('../ContactItemActions.container', () => ({ + ContactItemActionsContainer: ({ + contacts, + onSelectContact, + }: { + contacts: AddressInfo[] + onSelectContact: (contact: AddressInfo) => void + }) => { + const React = require('react') + return React.createElement( + 'View', + { testID: 'address-book-list' }, + contacts.map((contact: AddressInfo) => + React.createElement( + 'Pressable', + { + key: contact.value, + testID: `contact-${contact.value}`, + onPress: () => onSelectContact(contact), + }, + React.createElement('Text', null, contact.name), + ), + ), + ) + }, +})) + +describe('AddressBookListView', () => { + const mockContacts: AddressInfo[] = [ + { + value: '0x1234567890123456789012345678901234567890', + name: 'Alice', + }, + { + value: '0x0987654321098765432109876543210987654321', + name: 'Bob', + }, + ] + + const defaultProps = { + contacts: mockContacts, + filteredContacts: mockContacts, + onSearch: jest.fn(), + onSelectContact: jest.fn(), + onAddContact: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render the address book view with all elements', () => { + const container = render(<AddressBookListView {...defaultProps} />) + + expect(container.getByText('Address book')).toBeTruthy() + expect(container.getByPlaceholderText('Name, address')).toBeTruthy() + expect(container.getByTestId('address-book-list')).toBeTruthy() + expect(container.getByText('Add contact')).toBeTruthy() + expect(container.getByTestId('address-book-screen')).toBeTruthy() + }) + + it('should show no contacts component when contacts array is empty', () => { + const props = { + ...defaultProps, + contacts: [], + filteredContacts: [], + } + + const container = render(<AddressBookListView {...props} />) + + expect(container.getByText('No contacts yet')).toBeTruthy() + expect(container.getByText('This account has no contacts added.')).toBeTruthy() + expect(container.queryByText('No contacts found matching your search.')).not.toBeTruthy() + expect(container.queryByTestId('address-book-list')).toBeTruthy() + }) + + it('should show no contacts found when search returns empty results', () => { + const props = { + ...defaultProps, + contacts: mockContacts, // Has contacts + filteredContacts: [], // But search returned empty + } + + const container = render(<AddressBookListView {...props} />) + + expect(container.getByText('No contacts found matching your search.')).toBeTruthy() + expect(container.queryByText('No contacts yet')).not.toBeTruthy() + }) + + it('should call onSearch when search input changes', async () => { + jest.useFakeTimers() + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + const mockOnSearch = jest.fn() + const props = { + ...defaultProps, + onSearch: mockOnSearch, + } + + const container = render(<AddressBookListView {...props} />) + const searchInput = container.getByPlaceholderText('Name, address') + + await user.type(searchInput, 'Alice') + + // Fast-forward timers to trigger the throttled callback + jest.advanceTimersByTime(300) + + expect(mockOnSearch).toHaveBeenCalledWith('Alice') + + jest.useRealTimers() + }) + + it('should call onAddContact when add contact button is pressed', async () => { + const user = userEvent.setup() + const mockOnAddContact = jest.fn() + const props = { + ...defaultProps, + onAddContact: mockOnAddContact, + } + + const container = render(<AddressBookListView {...props} />) + const addButton = container.getByText('Add contact') + + await user.press(addButton) + + expect(mockOnAddContact).toHaveBeenCalled() + }) + + it('should pass filtered contacts to the list component', () => { + const filteredContacts = [mockContacts[0]] // Only Alice + const props = { + ...defaultProps, + filteredContacts, + } + + const container = render(<AddressBookListView {...props} />) + + expect(container.getByTestId('address-book-list')).toBeTruthy() + expect(container.getByTestId('contact-0x1234567890123456789012345678901234567890')).toBeTruthy() + expect(container.queryByTestId('contact-0x0987654321098765432109876543210987654321')).not.toBeTruthy() + }) + + it('should pass onSelectContact callback to the list component', () => { + const mockOnSelectContact = jest.fn() + const props = { + ...defaultProps, + onSelectContact: mockOnSelectContact, + } + + render(<AddressBookListView {...props} />) + + // The mock component should receive the callback + // This tests that the prop is correctly passed down + expect(mockOnSelectContact).toBeDefined() + }) + + it('should have correct search bar placeholder', () => { + const container = render(<AddressBookListView {...defaultProps} />) + const searchBar = container.getByPlaceholderText('Name, address') + + expect(searchBar).toBeTruthy() + }) + + it('should not show no contacts found when there are filtered contacts', () => { + const container = render(<AddressBookListView {...defaultProps} />) + + expect(container.queryByText('No contacts found matching your search.')).not.toBeTruthy() + expect(container.queryByText('No contacts yet')).not.toBeTruthy() + }) +}) diff --git a/apps/mobile/src/features/AddressBook/List/components/AddressBookListView.tsx b/apps/mobile/src/features/AddressBook/List/components/AddressBookListView.tsx new file mode 100644 index 0000000000..74f6c05afe --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/components/AddressBookListView.tsx @@ -0,0 +1,45 @@ +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import React from 'react' + +import { NoContacts } from './List/NoContacts' +import { View } from 'tamagui' +import SafeSearchBar from '@/src/components/SafeSearchBar/SafeSearchBar' +import { ContactItemActionsContainer } from '../ContactItemActions.container' +import { LargeHeaderTitle } from '@/src/components/Title' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { SafeButton } from '@/src/components/SafeButton' +import { NoContactsFound } from './List/NoContactsFound' + +type Props = { + contacts: AddressInfo[] + filteredContacts: AddressInfo[] + onSearch: (query: string) => void + onSelectContact: (contact: AddressInfo) => void + onAddContact: () => void +} + +export const AddressBookListView = ({ contacts, filteredContacts, onSearch, onSelectContact, onAddContact }: Props) => { + const insets = useSafeAreaInsets() + + return ( + <View marginTop="$2" style={{ flex: 1, marginBottom: insets.bottom }} testID={'address-book-screen'}> + <View flex={1}> + <View paddingHorizontal="$4"> + <LargeHeaderTitle>Address book</LargeHeaderTitle> + </View> + <View paddingHorizontal="$4"> + <SafeSearchBar placeholder="Name, address" onSearch={onSearch} throttleTime={300} /> + </View> + {contacts.length === 0 && <NoContacts />} + {contacts.length > 0 && filteredContacts.length === 0 && <NoContactsFound />} + <ContactItemActionsContainer contacts={filteredContacts} onSelectContact={onSelectContact} /> + </View> + {/* Add Contact Button */} + <View paddingTop="$4" paddingHorizontal="$4"> + <SafeButton primary onPress={onAddContact}> + Add contact + </SafeButton> + </View> + </View> + ) +} diff --git a/apps/mobile/src/features/AddressBook/List/components/List/ContactItem.tsx b/apps/mobile/src/features/AddressBook/List/components/List/ContactItem.tsx new file mode 100644 index 0000000000..041f9fd0db --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/components/List/ContactItem.tsx @@ -0,0 +1,99 @@ +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import React, { useMemo } from 'react' +import { MenuView, NativeActionEvent } from '@react-native-menu/menu' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import { Identicon } from '@/src/components/Identicon' +import { Pressable } from 'react-native' +import { SafeListItem } from '@/src/components/SafeListItem' +import { Text, View, type TextProps } from 'tamagui' +import { EthAddress } from '@/src/components/EthAddress' +import { type Address } from '@/src/types/address' + +export interface ContactItemProps { + contact: AddressInfo + onPress: () => void + onMenuAction: (contact: AddressInfo, actionId: string) => void + menuActions: { + id: string + title: string + image?: string + imageColor?: string + attributes?: { destructive?: boolean } + }[] +} + +const descriptionStyle: Partial<TextProps> = { + fontSize: '$4', + color: '$backgroundPress', + fontWeight: 400, +} + +const titleStyle: Partial<TextProps> = { + fontSize: '$4', + fontWeight: 600, +} + +export const ContactItem: React.FC<ContactItemProps> = ({ contact, onPress, onMenuAction, menuActions }) => { + const textProps = useMemo(() => { + return contact.name ? descriptionStyle : titleStyle + }, [contact.name]) + + const onPressMenuAction = ({ nativeEvent }: NativeActionEvent) => { + onMenuAction(contact, nativeEvent.event) + } + + return ( + <View position="relative"> + <Pressable style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1.0 }]} onPress={onPress}> + <SafeListItem + transparent + label={ + <View> + {contact.name && ( + <Text fontSize="$4" fontWeight={600}> + {contact.name} + </Text> + )} + + <EthAddress address={`${contact.value as Address}`} textProps={textProps} /> + </View> + } + leftNode={ + <View width="$10"> + <Identicon address={`${contact.value as Address}`} rounded size={40} /> + </View> + } + rightNode={ + <View> + <SafeFontIcon name={'options-horizontal'} /> + </View> + } + /> + </Pressable> + + <View + position="absolute" + right={0} + top={0} + height={'100%'} + display="flex" + alignItems="center" + justifyContent="center" + > + <MenuView + onPressAction={onPressMenuAction} + actions={menuActions} + style={{ + height: '100%', + justifyContent: 'center', + alignItems: 'center', + paddingRight: 16, + paddingLeft: 16, + }} + > + <SafeFontIcon name={'options-horizontal'} /> + </MenuView> + </View> + </View> + ) +} diff --git a/apps/mobile/src/features/AddressBook/List/components/List/ContactListItems.tsx b/apps/mobile/src/features/AddressBook/List/components/List/ContactListItems.tsx new file mode 100644 index 0000000000..f8270967b5 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/components/List/ContactListItems.tsx @@ -0,0 +1,53 @@ +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import React, { useCallback } from 'react' +import { FlashList } from '@shopify/flash-list' +import { getTokenValue } from 'tamagui' +import { ContactItem } from './ContactItem' + +interface ContactListItemsProps { + contacts: AddressInfo[] + onSelectContact: (contact: AddressInfo) => void + onMenuAction: (contact: AddressInfo, actionId: string) => void + menuActions: { + id: string + title: string + image?: string + imageColor?: string + attributes?: { destructive?: boolean } + }[] +} + +export const ContactListItems: React.FC<ContactListItemsProps> = ({ + contacts, + onSelectContact, + onMenuAction, + menuActions, +}) => { + const renderContact = useCallback( + ({ item }: { item: AddressInfo }) => ( + <ContactItem + contact={item} + onPress={() => onSelectContact(item)} + onMenuAction={onMenuAction} + menuActions={menuActions} + /> + ), + [onSelectContact, onMenuAction, menuActions], + ) + + const keyExtractor = useCallback((item: AddressInfo) => item.value, []) + + if (contacts.length === 0) { + return null + } + + return ( + <FlashList + data={contacts} + renderItem={renderContact} + estimatedItemSize={200} + keyExtractor={keyExtractor} + contentContainerStyle={{ paddingHorizontal: getTokenValue('$2') }} + /> + ) +} diff --git a/apps/mobile/src/features/AddressBook/List/components/List/EmptyAddressBookDark.tsx b/apps/mobile/src/features/AddressBook/List/components/List/EmptyAddressBookDark.tsx new file mode 100644 index 0000000000..8b23164dda --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/components/List/EmptyAddressBookDark.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import Svg, { Path } from 'react-native-svg' + +function EmptyAddressBookDark() { + return ( + <Svg width={161} height={160} fill="none"> + <Path + fill="#1C1C1C" + d="M66.79 146.58c26.67 0 48.29-21.62 48.29-48.29C115.08 71.62 93.46 50 66.79 50 40.12 50 18.5 71.62 18.5 98.29c0 26.67 21.62 48.29 48.29 48.29Z" + /> + <Path + fill="#1C1C1C" + d="M99.85 125.7c26.151 0 47.35-21.199 47.35-47.35C147.2 52.2 126.001 31 99.85 31 73.7 31 52.5 52.2 52.5 78.35c0 26.151 21.2 47.35 47.35 47.35Z" + /> + <Path + fill="#1C1C1C" + stroke="#A1A3A7" + strokeDasharray="6 6" + strokeLinecap="round" + strokeMiterlimit={10} + strokeWidth={1.33} + d="M99.85 125.7c26.151 0 47.35-21.199 47.35-47.35C147.2 52.2 126.001 31 99.85 31 73.7 31 52.5 52.2 52.5 78.35c0 26.151 21.2 47.35 47.35 47.35Z" + /> + <Path + fill="#1C1C1C" + d="M44.07 74.49c16.524 0 29.92-13.395 29.92-29.92 0-16.524-13.396-29.92-29.92-29.92S14.15 28.046 14.15 44.57c0 16.525 13.396 29.92 29.92 29.92Z" + /> + <Path + stroke="#A1A3A7" + strokeWidth={1.2} + d="M46.634 47.534h-.6v10.004a2.534 2.534 0 1 1-5.068 0V47.534H30.962a2.534 2.534 0 0 1 0-5.069h10.004V32.462a2.534 2.534 0 1 1 5.068 0v10.003h10.004a2.534 2.534 0 0 1 0 5.07h-9.404Z" + /> + <Path + fill="#A1A3A7" + fillRule="evenodd" + d="M44.07 15.3c-16.165 0-29.27 13.105-29.27 29.27s13.105 29.27 29.27 29.27 29.27-13.105 29.27-29.27S60.235 15.3 44.07 15.3ZM13.5 44.57C13.5 27.687 27.187 14 44.07 14c16.883 0 30.57 13.687 30.57 30.57 0 16.883-13.687 30.57-30.57 30.57-16.883 0-30.57-13.687-30.57-30.57ZM82.236 80.156c-2.015 2.718-3.062 5.998-3.402 8.175a.65.65 0 0 1-1.284-.2c.365-2.337 1.476-5.826 3.642-8.75 2.178-2.938 5.457-5.339 10.116-5.339 4.671 0 7.888 2.611 9.905 5.15a19.834 19.834 0 0 1 2.725 4.64 10.559 10.559 0 0 1 .163.42l.009.024.003.007.001.003-.614.214.614-.213a.65.65 0 0 1-1.228.425l-.002-.003-.006-.017a9.552 9.552 0 0 0-.143-.367 18.502 18.502 0 0 0-2.54-4.325c-1.88-2.365-4.76-4.658-8.887-4.658-4.142 0-7.069 2.112-9.072 4.814Z" + clipRule="evenodd" + /> + <Path fill="#1C1C1C" d="M100.615 65.808a9.308 9.308 0 1 1-18.615 0 9.308 9.308 0 0 1 18.615 0Z" /> + <Path + fill="#A1A3A7" + fillRule="evenodd" + d="M91.308 73.785a7.978 7.978 0 1 0 0-15.955 7.978 7.978 0 0 0 0 15.955Zm0 1.33a9.307 9.307 0 0 0 9.307-9.307 9.308 9.308 0 1 0-9.307 9.308ZM102.044 87.656c-2.015 2.718-3.061 5.998-3.402 8.175a.65.65 0 0 1-1.284-.2c.365-2.337 1.476-5.826 3.642-8.75 2.178-2.938 5.457-5.339 10.116-5.339 4.655 0 8.001 2.398 10.266 5.33 2.254 2.918 3.473 6.403 3.911 8.739a.65.65 0 1 1-1.278.24c-.408-2.178-1.558-5.462-3.662-8.185-2.093-2.71-5.093-4.824-9.237-4.824-4.142 0-7.068 2.112-9.072 4.814Z" + clipRule="evenodd" + /> + <Path fill="#1C1C1C" d="M120.423 73.308a9.307 9.307 0 1 1-18.615 0 9.308 9.308 0 1 1 18.615 0Z" /> + <Path + fill="#A1A3A7" + fillRule="evenodd" + d="M111.116 81.285a7.977 7.977 0 0 0 7.977-7.977 7.978 7.978 0 1 0-7.977 7.978Zm0 1.33a9.307 9.307 0 0 0 9.307-9.307 9.307 9.307 0 1 0-18.615 0 9.308 9.308 0 0 0 9.308 9.307Z" + clipRule="evenodd" + /> + </Svg> + ) +} + +export default EmptyAddressBookDark diff --git a/apps/mobile/src/features/AddressBook/List/components/List/EmptyAddressBookLight.tsx b/apps/mobile/src/features/AddressBook/List/components/List/EmptyAddressBookLight.tsx new file mode 100644 index 0000000000..07420ec24a --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/components/List/EmptyAddressBookLight.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import Svg, { Path } from 'react-native-svg' + +function EmptyAddressBookLight() { + return ( + <Svg width={161} height={160} fill="none"> + <Path + fill="#fff" + d="M66.79 146.58c26.67 0 48.29-21.62 48.29-48.29C115.08 71.62 93.46 50 66.79 50 40.12 50 18.5 71.62 18.5 98.29c0 26.67 21.62 48.29 48.29 48.29Z" + /> + <Path + fill="#fff" + d="M99.85 125.7c26.151 0 47.35-21.199 47.35-47.35C147.2 52.2 126.001 31 99.85 31 73.7 31 52.5 52.2 52.5 78.35c0 26.151 21.2 47.35 47.35 47.35Z" + /> + <Path + fill="#fff" + stroke="#A1A3A7" + strokeDasharray="6 6" + strokeLinecap="round" + strokeMiterlimit={10} + strokeWidth={1.33} + d="M99.85 125.7c26.151 0 47.35-21.199 47.35-47.35C147.2 52.2 126.001 31 99.85 31 73.7 31 52.5 52.2 52.5 78.35c0 26.151 21.2 47.35 47.35 47.35Z" + /> + <Path + fill="#fff" + d="M44.07 74.49c16.524 0 29.92-13.395 29.92-29.92 0-16.524-13.396-29.92-29.92-29.92S14.15 28.046 14.15 44.57c0 16.525 13.396 29.92 29.92 29.92Z" + /> + <Path + stroke="#A1A3A7" + strokeWidth={1.2} + d="M46.634 47.534h-.6v10.004a2.534 2.534 0 1 1-5.068 0V47.534H30.962a2.534 2.534 0 0 1 0-5.069h10.004V32.462a2.534 2.534 0 1 1 5.068 0v10.003h10.004a2.534 2.534 0 0 1 0 5.07h-9.404Z" + /> + <Path + fill="#A1A3A7" + fillRule="evenodd" + d="M44.07 15.3c-16.165 0-29.27 13.105-29.27 29.27s13.105 29.27 29.27 29.27 29.27-13.105 29.27-29.27S60.235 15.3 44.07 15.3ZM13.5 44.57C13.5 27.687 27.187 14 44.07 14c16.883 0 30.57 13.687 30.57 30.57 0 16.883-13.687 30.57-30.57 30.57-16.883 0-30.57-13.687-30.57-30.57ZM82.236 80.156c-2.015 2.718-3.062 5.998-3.402 8.175a.65.65 0 0 1-1.284-.2c.365-2.337 1.476-5.826 3.642-8.75 2.178-2.938 5.457-5.339 10.116-5.339 4.671 0 7.888 2.611 9.905 5.15a19.834 19.834 0 0 1 2.725 4.64 10.559 10.559 0 0 1 .163.42l.009.024.003.007.001.003-.614.214.614-.213a.65.65 0 0 1-1.228.425l-.002-.003-.006-.017a9.552 9.552 0 0 0-.143-.367 18.502 18.502 0 0 0-2.54-4.325c-1.88-2.365-4.76-4.658-8.887-4.658-4.142 0-7.069 2.112-9.072 4.814Z" + clipRule="evenodd" + /> + <Path fill="#fff" d="M100.615 65.808a9.308 9.308 0 1 1-18.615 0 9.308 9.308 0 0 1 18.615 0Z" /> + <Path + fill="#A1A3A7" + fillRule="evenodd" + d="M91.308 73.785a7.978 7.978 0 1 0 0-15.955 7.978 7.978 0 0 0 0 15.955Zm0 1.33a9.307 9.307 0 0 0 9.307-9.307 9.308 9.308 0 1 0-9.307 9.308ZM102.044 87.656c-2.015 2.718-3.061 5.998-3.402 8.175a.65.65 0 0 1-1.284-.2c.365-2.337 1.476-5.826 3.642-8.75 2.178-2.938 5.457-5.339 10.116-5.339 4.655 0 8.001 2.398 10.266 5.33 2.254 2.918 3.473 6.403 3.911 8.739a.65.65 0 1 1-1.278.24c-.408-2.178-1.558-5.462-3.662-8.185-2.093-2.71-5.093-4.824-9.237-4.824-4.142 0-7.068 2.112-9.072 4.814Z" + clipRule="evenodd" + /> + <Path fill="#fff" d="M120.423 73.308a9.307 9.307 0 1 1-18.615 0 9.308 9.308 0 1 1 18.615 0Z" /> + <Path + fill="#A1A3A7" + fillRule="evenodd" + d="M111.116 81.285a7.977 7.977 0 0 0 7.977-7.977 7.978 7.978 0 1 0-7.977 7.978Zm0 1.33a9.307 9.307 0 0 0 9.307-9.307 9.307 9.307 0 1 0-18.615 0 9.308 9.308 0 0 0 9.308 9.307Z" + clipRule="evenodd" + /> + </Svg> + ) +} + +export default EmptyAddressBookLight diff --git a/apps/mobile/src/features/AddressBook/List/components/List/NoContacts.tsx b/apps/mobile/src/features/AddressBook/List/components/List/NoContacts.tsx new file mode 100644 index 0000000000..5c5508e022 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/components/List/NoContacts.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { useColorScheme } from 'react-native' +import { H3, Text, View } from 'tamagui' +import EmptyAddressBookLight from './EmptyAddressBookLight' +import EmptyAddressBookDark from './EmptyAddressBookDark' + +export const NoContacts = () => { + const colorScheme = useColorScheme() + + const EmptyAddress = colorScheme === 'dark' ? <EmptyAddressBookDark /> : <EmptyAddressBookLight /> + + return ( + <View testID="empty-token" alignItems="center" flex={1} justifyContent="center" gap="$4"> + {EmptyAddress} + <H3 fontWeight={600}>No contacts yet</H3> + <Text textAlign="center" color="$colorSecondary" width="70%" fontSize="$4"> + This account has no contacts added. + </Text> + </View> + ) +} diff --git a/apps/mobile/src/features/AddressBook/List/components/List/NoContactsFound.tsx b/apps/mobile/src/features/AddressBook/List/components/List/NoContactsFound.tsx new file mode 100644 index 0000000000..e57b4568de --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/components/List/NoContactsFound.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { useColorScheme } from 'react-native' +import { Text, View } from 'tamagui' +import EmptyAddressBookLight from './EmptyAddressBookLight' +import EmptyAddressBookDark from './EmptyAddressBookDark' + +export const NoContactsFound = () => { + const colorScheme = useColorScheme() + + const EmptyAddress = colorScheme === 'dark' ? <EmptyAddressBookDark /> : <EmptyAddressBookLight /> + + return ( + <View testID="empty-token" alignItems="center" flex={1} justifyContent="center" gap="$4"> + {EmptyAddress} + <Text textAlign="center" color="$colorSecondary" width="70%" fontSize="$4"> + No contacts found matching your search. + </Text> + </View> + ) +} diff --git a/apps/mobile/src/features/AddressBook/List/components/List/index.ts b/apps/mobile/src/features/AddressBook/List/components/List/index.ts new file mode 100644 index 0000000000..09fd9f8ace --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/components/List/index.ts @@ -0,0 +1,4 @@ +import { ContactListItems } from './ContactListItems' +export { ContactListItems } +export { ContactItem } from './ContactItem' +export { ContactItemActionsContainer } from '../../ContactItemActions.container' diff --git a/apps/mobile/src/features/AddressBook/List/components/ListView.test.tsx b/apps/mobile/src/features/AddressBook/List/components/ListView.test.tsx new file mode 100644 index 0000000000..63d7a76a3a --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/components/ListView.test.tsx @@ -0,0 +1,176 @@ +import { render, userEvent } from '@/src/tests/test-utils' +import { AddressBookListView } from './AddressBookListView' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import React from 'react' + +// Only mock the complex components with dependencies +jest.mock('../ContactItemActions.container', () => ({ + ContactItemActionsContainer: ({ + contacts, + onSelectContact, + }: { + contacts: AddressInfo[] + onSelectContact: (contact: AddressInfo) => void + }) => { + const React = require('react') + return React.createElement( + 'View', + { testID: 'address-book-list' }, + contacts.map((contact: AddressInfo) => + React.createElement( + 'Pressable', + { + key: contact.value, + testID: `contact-${contact.value}`, + onPress: () => onSelectContact(contact), + }, + React.createElement('Text', null, contact.name), + ), + ), + ) + }, +})) + +describe('AddressBookListView', () => { + const mockContacts: AddressInfo[] = [ + { + value: '0x1234567890123456789012345678901234567890', + name: 'Alice', + }, + { + value: '0x0987654321098765432109876543210987654321', + name: 'Bob', + }, + ] + + const defaultProps = { + contacts: mockContacts, + filteredContacts: mockContacts, + onSearch: jest.fn(), + onSelectContact: jest.fn(), + onAddContact: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render the address book view with all elements', () => { + const container = render(<AddressBookListView {...defaultProps} />) + + expect(container.getByText('Address book')).toBeTruthy() + expect(container.getByPlaceholderText('Name, address')).toBeTruthy() + expect(container.getByTestId('address-book-list')).toBeTruthy() + expect(container.getByText('Add contact')).toBeTruthy() + expect(container.getByTestId('address-book-screen')).toBeTruthy() + }) + + it('should show no contacts component when contacts array is empty', () => { + const props = { + ...defaultProps, + contacts: [], + filteredContacts: [], + } + + const container = render(<AddressBookListView {...props} />) + + expect(container.getByText('No contacts yet')).toBeTruthy() + expect(container.getByText('This account has no contacts added.')).toBeTruthy() + expect(container.queryByText('No contacts found matching your search.')).not.toBeTruthy() + expect(container.queryByTestId('address-book-list')).toBeTruthy() + }) + + it('should show no contacts found when search returns empty results', () => { + const props = { + ...defaultProps, + contacts: mockContacts, // Has contacts + filteredContacts: [], // But search returned empty + } + + const container = render(<AddressBookListView {...props} />) + + expect(container.getByText('No contacts found matching your search.')).toBeTruthy() + expect(container.queryByText('No contacts yet')).not.toBeTruthy() + }) + + it('should call onSearch when search input changes', async () => { + jest.useFakeTimers() + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + const mockOnSearch = jest.fn() + const props = { + ...defaultProps, + onSearch: mockOnSearch, + } + + const container = render(<AddressBookListView {...props} />) + const searchInput = container.getByPlaceholderText('Name, address') + + await user.type(searchInput, 'Alice') + + // Fast-forward timers to trigger the throttled callback + jest.advanceTimersByTime(300) + + expect(mockOnSearch).toHaveBeenCalledWith('Alice') + + jest.useRealTimers() + }) + + it('should call onAddContact when add contact button is pressed', async () => { + const user = userEvent.setup() + const mockOnAddContact = jest.fn() + const props = { + ...defaultProps, + onAddContact: mockOnAddContact, + } + + const container = render(<AddressBookListView {...props} />) + const addButton = container.getByText('Add contact') + + await user.press(addButton) + + expect(mockOnAddContact).toHaveBeenCalled() + }) + + it('should pass filtered contacts to the list component', () => { + const filteredContacts = [mockContacts[0]] // Only Alice + const props = { + ...defaultProps, + filteredContacts, + } + + const container = render(<AddressBookListView {...props} />) + + expect(container.getByTestId('address-book-list')).toBeTruthy() + expect(container.getByTestId('contact-0x1234567890123456789012345678901234567890')).toBeTruthy() + expect(container.queryByTestId('contact-0x0987654321098765432109876543210987654321')).not.toBeTruthy() + }) + + it('should pass onSelectContact callback to the list component', () => { + const mockOnSelectContact = jest.fn() + const props = { + ...defaultProps, + onSelectContact: mockOnSelectContact, + } + + render(<AddressBookListView {...props} />) + + // The mock component should receive the callback + // This tests that the prop is correctly passed down + expect(mockOnSelectContact).toBeDefined() + }) + + it('should have correct search bar placeholder', () => { + const container = render(<AddressBookListView {...defaultProps} />) + const searchBar = container.getByPlaceholderText('Name, address') + + expect(searchBar).toBeTruthy() + }) + + it('should not show no contacts found when there are filtered contacts', () => { + const container = render(<AddressBookListView {...defaultProps} />) + + expect(container.queryByText('No contacts found matching your search.')).not.toBeTruthy() + expect(container.queryByText('No contacts yet')).not.toBeTruthy() + }) +}) diff --git a/apps/mobile/src/features/AddressBook/List/hooks/index.ts b/apps/mobile/src/features/AddressBook/List/hooks/index.ts new file mode 100644 index 0000000000..dc5dca7455 --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/hooks/index.ts @@ -0,0 +1 @@ +export { useContactActions } from './useContactActions' diff --git a/apps/mobile/src/features/AddressBook/List/hooks/useContactActions.ts b/apps/mobile/src/features/AddressBook/List/hooks/useContactActions.ts new file mode 100644 index 0000000000..e23cc6780c --- /dev/null +++ b/apps/mobile/src/features/AddressBook/List/hooks/useContactActions.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react' +import { Platform } from 'react-native' +import { useTheme } from 'tamagui' + +export const useContactActions = () => { + const theme = useTheme() + const color = theme.color?.get() + const colorError = 'red' + + const actions = useMemo( + () => [ + { + id: 'copy', + title: 'Copy address', + image: Platform.select({ + ios: 'doc.on.doc', + android: 'baseline_content_copy_24', + }), + imageColor: Platform.select({ ios: color, android: '#000' }), + }, + { + id: 'delete', + title: 'Delete contact', + attributes: { + destructive: true, + }, + image: Platform.select({ + ios: 'trash', + android: 'baseline_delete_24', + }), + imageColor: colorError, + }, + ], + [color, colorError], + ) + + return actions +} diff --git a/apps/mobile/src/features/AddressBook/index.tsx b/apps/mobile/src/features/AddressBook/index.tsx new file mode 100644 index 0000000000..f3455ca36b --- /dev/null +++ b/apps/mobile/src/features/AddressBook/index.tsx @@ -0,0 +1,3 @@ +export { AddressBookListContainer } from './List/AddressBookList.container' +export { ContactDetailContainer } from './Contact/ContactDetail.container' +export { ContactDisplayNameContainer } from './Contact/ContactDisplayName.container' diff --git a/apps/mobile/src/features/AdvancedDetails/TxData.container.tsx b/apps/mobile/src/features/AdvancedDetails/TxData.container.tsx new file mode 100644 index 0000000000..7ecc80ce8a --- /dev/null +++ b/apps/mobile/src/features/AdvancedDetails/TxData.container.tsx @@ -0,0 +1,42 @@ +import React, { useMemo } from 'react' +import { View, ScrollView } from 'tamagui' +import { useTransactionsGetTransactionByIdV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ListTable } from '@/src/features/ConfirmTx/components/ListTable' +import { useLocalSearchParams } from 'expo-router' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { Alert } from '@/src/components/Alert' +import { LoadingTx } from '../ConfirmTx/components/LoadingTx' +import { formatTxDetails } from './utils/formatTxDetails' +import { useOpenExplorer } from '@/src/features/ConfirmTx/hooks/useOpenExplorer' + +export function TxDataContainer() { + const activeSafe = useDefinedActiveSafe() + const { txId } = useLocalSearchParams<{ txId: string }>() + + const { + data: txDetails, + isFetching, + isError, + } = useTransactionsGetTransactionByIdV1Query({ + chainId: activeSafe.chainId, + id: txId, + }) + + const viewOnExplorer = useOpenExplorer(txDetails?.txData?.to.value || '') + + const parameters = useMemo(() => formatTxDetails({ txDetails, viewOnExplorer }), [txDetails, viewOnExplorer]) + + if (isError) { + return ( + <View margin="$4"> + <Alert type="error" message="Error fetching transaction details" /> + </View> + ) + } + + return ( + <ScrollView marginTop="$2"> + {isFetching || !txDetails ? <LoadingTx /> : <ListTable items={parameters} />} + </ScrollView> + ) +} diff --git a/apps/mobile/src/features/AdvancedDetails/TxParameters.container.tsx b/apps/mobile/src/features/AdvancedDetails/TxParameters.container.tsx new file mode 100644 index 0000000000..a69bbb8f52 --- /dev/null +++ b/apps/mobile/src/features/AdvancedDetails/TxParameters.container.tsx @@ -0,0 +1,39 @@ +import React, { useMemo } from 'react' +import { View, ScrollView } from 'tamagui' +import { useTransactionsGetTransactionByIdV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ListTable } from '@/src/features/ConfirmTx/components/ListTable' +import { useLocalSearchParams } from 'expo-router' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { Alert } from '@/src/components/Alert' +import { LoadingTx } from '../ConfirmTx/components/LoadingTx' +import { formatParameters } from './utils/formatParameters' + +export function TxParametersContainer() { + const activeSafe = useDefinedActiveSafe() + const { txId } = useLocalSearchParams<{ txId: string }>() + + const { + data: txDetails, + isFetching, + isError, + } = useTransactionsGetTransactionByIdV1Query({ + chainId: activeSafe.chainId, + id: txId, + }) + + const parameters = useMemo(() => formatParameters({ txData: txDetails?.txData }), [txDetails?.txData]) + + if (isError) { + return ( + <View margin="$4"> + <Alert type="error" message="Error fetching transaction details" /> + </View> + ) + } + + return ( + <ScrollView marginTop="$2"> + {isFetching || !txDetails ? <LoadingTx /> : <ListTable items={parameters} />} + </ScrollView> + ) +} diff --git a/apps/mobile/src/features/AdvancedDetails/components/Receiver/Receiver.tsx b/apps/mobile/src/features/AdvancedDetails/components/Receiver/Receiver.tsx new file mode 100644 index 0000000000..31325f3513 --- /dev/null +++ b/apps/mobile/src/features/AdvancedDetails/components/Receiver/Receiver.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { View, Text } from 'tamagui' +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { Logo } from '@/src/components/Logo' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Identicon } from '@/src/components/Identicon' +import { useAppSelector } from '@/src/store/hooks' +import { selectContactByAddress } from '@/src/store/addressBookSlice' + +interface ReceiverProps { + txData: TransactionDetails['txData'] +} + +export function Receiver({ txData }: ReceiverProps) { + const { to: { value = '', name, logoUri } = {} } = txData || {} + + const contact = useAppSelector(selectContactByAddress(value)) + + const content = contact?.name || name + + if (!content) { + return null + } + + return ( + <View + backgroundColor="$backgroundSecondary" + padding="$2" + paddingHorizontal="$3" + borderRadius="$8" + flexDirection="row" + alignItems="center" + gap="$2" + alignSelf="flex-start" + > + <Logo + logoUri={logoUri} + size="$4" + fallbackContent={value ? <Identicon address={value as `0x${string}`} size={16} /> : undefined} + /> + <Text fontWeight={600}>{content}</Text> + <SafeFontIcon name="check-oulined" color="$success" size={16} style={{ marginLeft: 'auto' }} /> + </View> + ) +} diff --git a/apps/mobile/src/features/AdvancedDetails/components/Receiver/__tests__/Receiver.test.tsx b/apps/mobile/src/features/AdvancedDetails/components/Receiver/__tests__/Receiver.test.tsx new file mode 100644 index 0000000000..2ed09a60b8 --- /dev/null +++ b/apps/mobile/src/features/AdvancedDetails/components/Receiver/__tests__/Receiver.test.tsx @@ -0,0 +1,110 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { Receiver } from '../Receiver' +import { faker } from '@faker-js/faker' +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useAppSelector } from '@/src/store/hooks' + +// Mock the store hooks +jest.mock('@/src/store/hooks', () => ({ + useAppSelector: jest.fn(), + useAppDispatch: jest.fn(() => jest.fn()), +})) + +// Mock the useTheme hook +jest.mock('@/src/theme/hooks/useTheme', () => ({ + useTheme: jest.fn(() => ({ currentTheme: 'light' })), +})) + +describe('Receiver', () => { + const mockAddress = faker.finance.ethereumAddress() + + const mockContact = { + value: mockAddress, + name: 'My Custom Safe', + chainIds: ['1'], + logoUri: null, + } + + const mockTo = { + value: mockAddress, + name: 'GnosisSafeProxy', + logoUri: 'https://example.com/logo.png', + } + + const createMockTxData = (overrides?: Partial<TransactionDetails['txData']>): TransactionDetails['txData'] => ({ + operation: 0, + to: { ...mockTo, ...overrides?.to }, + ...overrides, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should display contact name from address book when contact exists', () => { + jest.mocked(useAppSelector).mockReturnValue(mockContact) + + const txData = createMockTxData() + const { getByText, queryByText } = render(<Receiver txData={txData} />) + + // Should show contact name from addressbook, not transaction data name + expect(getByText(mockContact.name)).toBeTruthy() + expect(queryByText(mockTo.name)).toBeNull() + }) + + it('should fall back to transaction data name when no contact exists in address book', () => { + jest.mocked(useAppSelector).mockReturnValue(null) + + const txData = createMockTxData() + const { getByText } = render(<Receiver txData={txData} />) + + // Should show transaction data name as fallback + expect(getByText(mockTo.name)).toBeTruthy() + }) + + it.each([ + { + name: 'no contact name and no transaction data name', + txData: createMockTxData({ + to: { + value: mockAddress, + name: null, + logoUri: null, + }, + }), + }, + { + name: 'txData is null', + txData: null, + }, + { + name: 'txData is undefined', + txData: undefined, + }, + ])('should not render when $name', ({ txData }) => { + jest.mocked(useAppSelector).mockReturnValue(null) + + const { queryByText } = render(<Receiver txData={txData} />) + + // Should not render anything + expect(queryByText(mockContact.name)).toBeNull() + expect(queryByText(mockTo.name)).toBeNull() + }) + + it('should handle missing logoUri gracefully', () => { + jest.mocked(useAppSelector).mockReturnValue(mockContact) + + const txData = createMockTxData({ + to: { + value: mockAddress, + name: 'Contract Name', + logoUri: null, + }, + }) + const { getByText } = render(<Receiver txData={txData} />) + + // Should still render the component even without logoUri + expect(getByText(mockContact.name)).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/features/AdvancedDetails/components/Receiver/index.tsx b/apps/mobile/src/features/AdvancedDetails/components/Receiver/index.tsx new file mode 100644 index 0000000000..34dcfb2219 --- /dev/null +++ b/apps/mobile/src/features/AdvancedDetails/components/Receiver/index.tsx @@ -0,0 +1 @@ +export { Receiver } from './Receiver' diff --git a/apps/mobile/src/features/AdvancedDetails/formatters/arrayValue.tsx b/apps/mobile/src/features/AdvancedDetails/formatters/arrayValue.tsx new file mode 100644 index 0000000000..ff690908ad --- /dev/null +++ b/apps/mobile/src/features/AdvancedDetails/formatters/arrayValue.tsx @@ -0,0 +1,35 @@ +import { ListTableItem } from '@/src/features/ConfirmTx/components/ListTable' +import { DataDecodedParameter } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ReactElement } from 'react' +import { Text, View } from 'tamagui' +import { shortenText } from '@safe-global/utils/utils/formatters' +import { CopyButton } from '@/src/components/CopyButton' + +const renderArrayValue = (value: object, index?: number): ReactElement => { + const displayLimit = 30 + + if (Array.isArray(value)) { + return ( + <View key={`array-${index}`}> + <Text>[</Text> + <View marginLeft={'$2'}>{value.map(renderArrayValue)}</View> + <Text>]</Text> + </View> + ) + } + return ( + <View key={`value-${value}-${index}`} flexDirection="row" alignItems="center" gap="$1"> + <Text>{shortenText(String(value), displayLimit)}</Text> + <CopyButton value={String(value)} color={'$textSecondaryLight'} text="Data copied." /> + </View> + ) +} + +export const formatArrayValue = (param: DataDecodedParameter): ListTableItem => { + return { + label: param.name, + render: () => renderArrayValue(param.value), + direction: 'column', + alignItems: 'flex-start', + } +} diff --git a/apps/mobile/src/features/AdvancedDetails/formatters/singleValue.tsx b/apps/mobile/src/features/AdvancedDetails/formatters/singleValue.tsx new file mode 100644 index 0000000000..252865f61e --- /dev/null +++ b/apps/mobile/src/features/AdvancedDetails/formatters/singleValue.tsx @@ -0,0 +1,60 @@ +import { DataDecodedParameter } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ListTableItem } from '@/src/features/ConfirmTx/components/ListTable' +import { shortenText } from '@safe-global/utils/utils/formatters' +import { Text, View } from 'tamagui' +import { CopyButton } from '@/src/components/CopyButton' +import { EthAddress } from '@/src/components/EthAddress' +import { Address } from '@/src/types/address' +import { Identicon } from '@/src/components/Identicon' +import { InfoSheet } from '@/src/components/InfoSheet' + +export const characterDisplayLimit = 15 + +export const DisplayValue = ({ type, value }: { type: string; value: string }) => { + const isLong = value.length > characterDisplayLimit + + switch (type) { + case 'hash': + case 'address': + return ( + <View flexDirection="row" alignItems="center" gap="$1"> + <Identicon address={value as Address} size={24} /> + <EthAddress address={value as Address} copy copyProps={{ color: '$textSecondaryLight' }} /> + </View> + ) + case 'rawData': + case 'bytes': + return ( + <View flexDirection="row" alignItems="center" gap="$1"> + <Text>{shortenText(value, characterDisplayLimit)}</Text> + <CopyButton value={value} color={'$textSecondaryLight'} text="Data copied." /> + </View> + ) + default: + return ( + <View flexDirection="row" alignItems="center" gap="$1"> + <Text>{isLong ? shortenText(value, characterDisplayLimit) : value}</Text> + {isLong && <CopyButton value={value} color={'$textSecondaryLight'} text="Data copied." />} + </View> + ) + } +} + +export const formatValueTemplate = (param: DataDecodedParameter): ListTableItem => { + if (param.value == undefined || typeof param.value !== 'string') { + return { + label: param.name, + } + } + + const value = String(param.value) + + return { + label: param.name, + render: () => ( + <InfoSheet title={param.name} info={value}> + <DisplayValue type={param.type} value={value} /> + </InfoSheet> + ), + } +} diff --git a/apps/mobile/src/features/AdvancedDetails/index.ts b/apps/mobile/src/features/AdvancedDetails/index.ts new file mode 100644 index 0000000000..5b690c253e --- /dev/null +++ b/apps/mobile/src/features/AdvancedDetails/index.ts @@ -0,0 +1,2 @@ +export { TxDataContainer } from './TxData.container' +export { TxParametersContainer } from './TxParameters.container' diff --git a/apps/mobile/src/features/AdvancedDetails/utils/formatParameters.test.tsx b/apps/mobile/src/features/AdvancedDetails/utils/formatParameters.test.tsx new file mode 100644 index 0000000000..d17fc29bb4 --- /dev/null +++ b/apps/mobile/src/features/AdvancedDetails/utils/formatParameters.test.tsx @@ -0,0 +1,167 @@ +import { TransactionData } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { formatParameters } from './formatParameters' + +// Mock dependencies with minimal implementation +jest.mock('@/src/utils/transaction-guards', () => ({ + isArrayParameter: jest.fn(), +})) + +jest.mock('@safe-global/utils/utils/formatters', () => ({ + shortenText: jest.fn((text: string) => text.slice(0, 15) + '...'), +})) + +jest.mock('../formatters/singleValue', () => ({ + characterDisplayLimit: 15, + formatValueTemplate: jest.fn((param) => ({ + label: param.name, + value: param.value, + })), +})) + +jest.mock('../formatters/arrayValue', () => ({ + formatArrayValue: jest.fn((param) => ({ + label: `${param.name} (array)`, + value: String(param.value), + })), +})) + +const { isArrayParameter } = require('@/src/utils/transaction-guards') +const { formatValueTemplate } = require('../formatters/singleValue') +const { formatArrayValue } = require('../formatters/arrayValue') + +// Mock data helper to bypass strict typing for tests +const createMockTxData = (data: unknown) => data as TransactionData + +describe('formatParameters', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return empty array when txData is undefined', () => { + const result = formatParameters({ txData: undefined }) + expect(result).toEqual([]) + }) + + it('should return empty array when txData is null', () => { + const result = formatParameters({ txData: null }) + expect(result).toEqual([]) + }) + + it('should return basic item when txData exists', () => { + const txData = { + to: { value: '0x123...' }, + dataDecoded: { + method: 'transfer', + parameters: [], + }, + hexData: null, + value: null, + operation: 0, + } + + const result = formatParameters({ txData }) + + expect(result).toHaveLength(1) + expect(result[0]).toHaveProperty('label') + }) + + it('should handle parameters with regular values', () => { + const txData = createMockTxData({ + to: { value: '0x123...' }, + dataDecoded: { + method: 'transfer', + parameters: [ + { name: 'recipient', type: 'address', value: '0xabc...' }, + { name: 'amount', type: 'uint256', value: '1000' }, + ], + }, + hexData: null, + value: null, + operation: 0, + }) + + isArrayParameter.mockImplementation(() => false) + + const result = formatParameters({ txData }) + + expect(result).toHaveLength(3) // 1 basic + 2 parameters + expect(formatValueTemplate).toHaveBeenCalledTimes(2) + }) + + it('should handle array parameters', () => { + const txData = { + to: { value: '0x123...' }, + dataDecoded: { + method: 'batchTransfer', + parameters: [{ name: 'recipients', type: 'address[]', value: ['0xabc...', '0xdef...'] }], + }, + hexData: null, + value: null, + operation: 0, + } + + isArrayParameter.mockImplementation((type: string) => type.endsWith('[]')) + + const result = formatParameters({ txData }) + + expect(result).toHaveLength(2) // 1 basic + 1 array parameter + expect(formatArrayValue).toHaveBeenCalledTimes(1) + }) + + it('should include hex data when present', () => { + const txData = { + to: { value: '0x123...' }, + dataDecoded: { + method: 'transfer', + parameters: [], + }, + hexData: '0x1234567890abcdef1234567890abcdef', + value: null, + operation: 0, + } + + const result = formatParameters({ txData }) + + expect(result).toHaveLength(2) // 1 basic + 1 hex data + }) + + it('should handle missing dataDecoded', () => { + const txData = { + to: { value: '0x123...' }, + dataDecoded: null, + hexData: '0x1234', + value: null, + operation: 0, + } + + const result = formatParameters({ txData }) + + expect(result).toHaveLength(2) // 1 basic + 1 hex data + }) + + it('should handle mixed parameter types', () => { + const txData = createMockTxData({ + to: { value: '0x123...' }, + dataDecoded: { + method: 'complexMethod', + parameters: [ + { name: 'address', type: 'address', value: '0xabc...' }, + { name: 'amounts', type: 'uint256[]', value: ['100', '200'] }, + { name: 'data', type: 'bytes', value: ['0x123'] }, + { name: 'flag', type: 'bool', value: true }, + ], + }, + hexData: '0xabcdef', + value: null, + operation: 0, + }) + + isArrayParameter.mockImplementation((type: string) => type.endsWith('[]')) + + const result = formatParameters({ txData }) + + expect(result).toHaveLength(6) // 1 basic + 4 parameters + 1 hex data + expect(formatValueTemplate).toHaveBeenCalledTimes(2) // address and flag + expect(formatArrayValue).toHaveBeenCalledTimes(2) // amounts array and data with array value + }) +}) diff --git a/apps/mobile/src/features/AdvancedDetails/utils/formatParameters.tsx b/apps/mobile/src/features/AdvancedDetails/utils/formatParameters.tsx new file mode 100644 index 0000000000..89ee85ffdf --- /dev/null +++ b/apps/mobile/src/features/AdvancedDetails/utils/formatParameters.tsx @@ -0,0 +1,77 @@ +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ListTableItem } from '@/src/features/ConfirmTx/components/ListTable' +import { isArrayParameter } from '@/src/utils/transaction-guards' +import { shortenText } from '@safe-global/utils/utils/formatters' +import { CircleProps, Text, View } from 'tamagui' +import { CopyButton } from '@/src/components/CopyButton' +import { characterDisplayLimit, formatValueTemplate } from '../formatters/singleValue' +import { formatArrayValue } from '../formatters/arrayValue' +import { Badge } from '@/src/components/Badge' +import { InfoSheet } from '@/src/components/InfoSheet' +import React from 'react' + +interface formatParametersProps { + txData?: TransactionDetails['txData'] +} +const badgeProps: CircleProps = { borderRadius: '$2', paddingHorizontal: '$2', paddingVertical: '$1' } + +const formatParameters = ({ txData }: formatParametersProps): ListTableItem[] => { + if (!txData) { + return [] + } + + const items: ListTableItem[] = [ + { + label: txData?.dataDecoded?.method ? 'Call' : 'Interacted with', + render: () => ( + <Badge + circleProps={badgeProps} + themeName="badge_background" + fontSize={13} + textContentProps={{ fontFamily: 'DM Mono' }} + circular={false} + content={String(txData?.dataDecoded?.method || txData?.to.value)} + /> + ), + }, + ] + + const parameters = txData?.dataDecoded?.parameters + + if (parameters && parameters.length) { + const formatedParameters = parameters.reduce<ListTableItem[]>((acc, param) => { + const isArrayValueParam = isArrayParameter(param.type) || Array.isArray(param.value) + + if (isArrayValueParam) { + acc.push(formatArrayValue(param)) + return acc + } + + acc.push(formatValueTemplate(param)) + + return acc + }, []) + + items.push(...formatedParameters) + } + + if (txData?.hexData) { + const hexData = txData.hexData + + items.push({ + label: 'Hex Data:', + render: () => ( + <InfoSheet title="Hex Data" info={hexData}> + <View flexDirection="row" alignItems="center" gap="$1"> + <Text>{shortenText(txData?.hexData || '', characterDisplayLimit)}</Text> + <CopyButton value={txData?.hexData || ''} color={'$textSecondaryLight'} text="Data copied." /> + </View> + </InfoSheet> + ), + }) + } + + return items +} + +export { formatParameters } diff --git a/apps/mobile/src/features/AdvancedDetails/utils/formatTxDetails.test.tsx b/apps/mobile/src/features/AdvancedDetails/utils/formatTxDetails.test.tsx new file mode 100644 index 0000000000..3b4f6b091d --- /dev/null +++ b/apps/mobile/src/features/AdvancedDetails/utils/formatTxDetails.test.tsx @@ -0,0 +1,276 @@ +import { faker } from '@faker-js/faker' +import { formatTxDetails } from './formatTxDetails' +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { Operation } from '@safe-global/safe-gateway-typescript-sdk' + +// Mock dependencies +jest.mock('@safe-global/utils/utils/formatters', () => ({ + shortenText: jest.fn((text: string, limit: number) => text.slice(0, limit) + '...'), +})) + +jest.mock('@/src/utils/transaction-guards', () => ({ + isMultisigDetailedExecutionInfo: jest.fn(), +})) + +// Mock all UI components to return simple objects +jest.mock('../components/Receiver', () => ({ + Receiver: () => 'MockReceiver', +})) + +jest.mock('@/src/components/Badge', () => ({ + Badge: () => 'MockBadge', +})) + +jest.mock('@/src/components/CopyButton', () => ({ + CopyButton: () => 'MockCopyButton', +})) + +jest.mock('@/src/components/EthAddress', () => ({ + EthAddress: () => 'MockEthAddress', +})) + +jest.mock('@/src/components/Identicon', () => ({ + Identicon: () => 'MockIdenticon', +})) + +jest.mock('@/src/components/SafeFontIcon', () => ({ + SafeFontIcon: () => 'MockSafeFontIcon', +})) + +const { isMultisigDetailedExecutionInfo } = require('@/src/utils/transaction-guards') + +// Helper to create minimal transaction details +const createMockTxDetails = (overrides: Partial<TransactionDetails> = {}): TransactionDetails => { + return { + txInfo: {} as TransactionDetails['txInfo'], + txData: { + to: { value: faker.finance.ethereumAddress() }, + value: null, + operation: Operation.CALL, + hexData: null, + dataDecoded: null, + }, + detailedExecutionInfo: null, + txHash: null, + safeAppInfo: null, + txStatus: 'SUCCESS', + txId: 'test-id', + safeAddress: faker.finance.ethereumAddress(), + ...overrides, + } +} + +describe('formatTxDetails', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const viewOnExplorer = jest.fn() + + it('should return empty array when txDetails is undefined', () => { + const result = formatTxDetails({ txDetails: undefined, viewOnExplorer }) + expect(result).toEqual([]) + }) + + it('should return empty array when txDetails is null', () => { + const result = formatTxDetails({ txDetails: null as unknown as TransactionDetails, viewOnExplorer }) + expect(result).toEqual([]) + }) + + it('should format basic transaction details with To field', () => { + const mockAddress = faker.finance.ethereumAddress() + const txDetails = createMockTxDetails({ + txData: { + to: { value: mockAddress }, + value: null, + operation: Operation.CALL, + hexData: null, + dataDecoded: null, + }, + }) + + const result = formatTxDetails({ txDetails, viewOnExplorer }) + + expect(result.length).toBeGreaterThanOrEqual(1) + // Check that we have a 'To' field + expect(result.some((item) => 'label' in item && item.label === 'To')).toBe(true) + }) + + it('should include Value field when transaction has value', () => { + const txDetails = createMockTxDetails({ + txData: { + to: { value: faker.finance.ethereumAddress() }, + value: '1000000000000000000', // 1 ETH in wei + operation: Operation.CALL, + hexData: null, + dataDecoded: null, + }, + }) + + const result = formatTxDetails({ txDetails, viewOnExplorer }) + + // Check that we have a 'Value' field + expect(result.some((item) => 'label' in item && item.label === 'Value')).toBe(true) + }) + + it('should include Operation field when operation is defined', () => { + const txDetails = createMockTxDetails({ + txData: { + to: { value: faker.finance.ethereumAddress() }, + value: null, + operation: Operation.DELEGATE, + hexData: null, + dataDecoded: null, + }, + }) + + const result = formatTxDetails({ txDetails, viewOnExplorer }) + + // Check that we have an 'Operation' field + expect(result.some((item) => 'label' in item && item.label === 'Operation')).toBe(true) + }) + + it('should include multisig execution details when available', () => { + const mockExecutionInfo = { + type: 'MULTISIG', + nonce: 123, + confirmationsRequired: 2, + confirmationsSubmitted: 1, + missingSigners: null, + signers: [], + submittedAt: 1234567890000, + gasPrice: '20000000000', + safeTxGas: '50000', + baseGas: '21000', + gasToken: faker.finance.ethereumAddress(), + refundReceiver: { value: faker.finance.ethereumAddress() }, + safeTxHash: faker.string.hexadecimal({ length: 64 }), + } + + const txDetails = createMockTxDetails({ + detailedExecutionInfo: mockExecutionInfo as unknown as TransactionDetails['detailedExecutionInfo'], + }) + + isMultisigDetailedExecutionInfo.mockReturnValue(true) + + const result = formatTxDetails({ txDetails, viewOnExplorer }) + + // Check for gas-related fields + expect(result.some((item) => 'label' in item && item.label === 'SafeTxGas')).toBe(true) + expect(result.some((item) => 'label' in item && item.label === 'BaseGas')).toBe(true) + expect(result.some((item) => 'label' in item && item.label === 'GasPrice')).toBe(true) + expect(result.some((item) => 'label' in item && item.label === 'GasToken')).toBe(true) + expect(result.some((item) => 'label' in item && item.label === 'RefundReceiver')).toBe(true) + expect(result.some((item) => 'label' in item && item.label === 'Nonce')).toBe(true) + expect(result.some((item) => 'label' in item && item.label === 'Safe Tx Hash')).toBe(true) + }) + + it('should include transaction hash when available', () => { + const mockTxHash = faker.string.hexadecimal({ length: 64 }) + const txDetails = createMockTxDetails({ + txHash: mockTxHash, + }) + + // Ensure multisig check returns false for this test + isMultisigDetailedExecutionInfo.mockReturnValue(false) + + const result = formatTxDetails({ txDetails, viewOnExplorer }) + + // Check that we have a 'Transaction Hash' field + expect(result.some((item) => 'label' in item && item.label === 'Transaction Hash')).toBe(true) + }) + + it('should not include optional fields when they are not present', () => { + const txDetails = createMockTxDetails({ + txData: { + to: { value: faker.finance.ethereumAddress() }, + value: null, + operation: undefined, + hexData: null, + dataDecoded: null, + }, + } as unknown as TransactionDetails) + + isMultisigDetailedExecutionInfo.mockReturnValue(false) + + const result = formatTxDetails({ txDetails, viewOnExplorer }) + + // Should only have the 'To' field + expect(result).toHaveLength(1) + expect(result.some((item) => 'label' in item && item.label === 'Value')).toBe(false) + expect(result.some((item) => 'label' in item && item.label === 'Operation')).toBe(false) + expect(result.some((item) => 'label' in item && item.label === 'Transaction Hash')).toBe(false) + }) + + it('should handle multisig execution without safeTxHash', () => { + const mockExecutionInfo = { + type: 'MULTISIG', + nonce: 123, + confirmationsRequired: 2, + confirmationsSubmitted: 1, + missingSigners: null, + signers: [], + submittedAt: 1234567890000, + gasPrice: '20000000000', + safeTxGas: '50000', + baseGas: '21000', + gasToken: faker.finance.ethereumAddress(), + refundReceiver: { value: faker.finance.ethereumAddress() }, + safeTxHash: null, // No safe tx hash + } + + const txDetails = createMockTxDetails({ + detailedExecutionInfo: mockExecutionInfo as unknown as TransactionDetails['detailedExecutionInfo'], + }) + + isMultisigDetailedExecutionInfo.mockReturnValue(true) + + const result = formatTxDetails({ txDetails, viewOnExplorer }) + + // Should have gas fields but not safe tx hash + expect(result.some((item) => 'label' in item && item.label === 'SafeTxGas')).toBe(true) + expect(result.some((item) => 'label' in item && item.label === 'Safe Tx Hash')).toBe(false) + }) + + it('should handle complete transaction with all fields', () => { + const mockExecutionInfo = { + type: 'MULTISIG', + nonce: 123, + confirmationsRequired: 2, + confirmationsSubmitted: 1, + missingSigners: null, + signers: [], + submittedAt: 1234567890000, + gasPrice: '20000000000', + safeTxGas: '50000', + baseGas: '21000', + gasToken: faker.finance.ethereumAddress(), + refundReceiver: { value: faker.finance.ethereumAddress() }, + safeTxHash: faker.string.hexadecimal({ length: 64 }), + } + + const txDetails = createMockTxDetails({ + txData: { + to: { value: faker.finance.ethereumAddress() }, + value: '1000000000000000000', + operation: Operation.DELEGATE, + hexData: null, + dataDecoded: null, + }, + detailedExecutionInfo: mockExecutionInfo as unknown as TransactionDetails['detailedExecutionInfo'], + txHash: faker.string.hexadecimal({ length: 64 }), + }) + + isMultisigDetailedExecutionInfo.mockReturnValue(true) + + const result = formatTxDetails({ txDetails, viewOnExplorer }) + + // Should have all possible fields + expect(result.some((item) => 'label' in item && item.label === 'To')).toBe(true) + expect(result.some((item) => 'label' in item && item.label === 'Value')).toBe(true) + expect(result.some((item) => 'label' in item && item.label === 'Operation')).toBe(true) + expect(result.some((item) => 'label' in item && item.label === 'SafeTxGas')).toBe(true) + expect(result.some((item) => 'label' in item && item.label === 'Transaction Hash')).toBe(true) + expect(result.some((item) => 'label' in item && item.label === 'Safe Tx Hash')).toBe(true) + }) +}) diff --git a/apps/mobile/src/features/AdvancedDetails/utils/formatTxDetails.tsx b/apps/mobile/src/features/AdvancedDetails/utils/formatTxDetails.tsx new file mode 100644 index 0000000000..667c1cf55f --- /dev/null +++ b/apps/mobile/src/features/AdvancedDetails/utils/formatTxDetails.tsx @@ -0,0 +1,173 @@ +import React from 'react' +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ListTableItem } from '@/src/features/ConfirmTx/components/ListTable' +import { CircleProps, Text, View } from 'tamagui' +import { CopyButton } from '@/src/components/CopyButton' +import { EthAddress } from '@/src/components/EthAddress' +import { Address } from '@/src/types/address' +import { Identicon } from '@/src/components/Identicon' +import { Badge } from '@/src/components/Badge' +import { shortenText } from '@safe-global/utils/utils/formatters' +import { isMultisigDetailedExecutionInfo } from '@/src/utils/transaction-guards' +import { Operation } from '@safe-global/safe-gateway-typescript-sdk' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { TouchableOpacity } from 'react-native' +import { Receiver } from '../components/Receiver' +import { InfoSheet } from '@/src/components/InfoSheet' + +interface formatTxDetailsProps { + txDetails?: TransactionDetails + viewOnExplorer: () => void +} + +const badgeProps: CircleProps = { borderRadius: '$2', paddingHorizontal: '$2', paddingVertical: '$1' } +const characterDisplayLimit = 15 + +const formatTxDetails = ({ txDetails, viewOnExplorer }: formatTxDetailsProps): ListTableItem[] => { + const items: ListTableItem[] = [] + + if (!txDetails) { + return items + } + + // Basic transaction info + items.push({ + label: 'To', + render: () => ( + <> + <View width="100%"> + <Receiver txData={txDetails.txData} /> + </View> + <View width="100%" flexDirection="row" alignItems="center" gap="$2"> + <Identicon address={txDetails.txData?.to.value as Address} size={24} /> + + <View flexDirection="row" justifyContent="space-between" alignItems="center"> + <Text flexWrap="wrap" width="77%"> + {txDetails.txData?.to.value} + </Text> + + <View flexDirection="row" alignItems="center" gap="$3"> + <CopyButton value={txDetails.txData?.to.value || ''} size={16} color={'$textSecondaryLight'} /> + + <TouchableOpacity onPress={viewOnExplorer}> + <SafeFontIcon name="external-link" size={16} color="$textSecondaryLight" /> + </TouchableOpacity> + </View> + </View> + </View> + </> + ), + }) + + // Value + if (txDetails.txData?.value) { + items.push({ + label: 'Value', + render: () => <Text>{txDetails.txData?.value || '0'}</Text>, + }) + } + + // Operation + if (txDetails.txData?.operation !== undefined) { + const operationText = txDetails.txData.operation === Operation.CALL ? '0 (call)' : '1 (delegate call)' + items.push({ + label: 'Operation', + render: () => ( + <Badge + circleProps={badgeProps} + themeName="badge_background" + fontSize={13} + textContentProps={{ fontFamily: 'DM Mono' }} + circular={false} + content={operationText} + /> + ), + }) + } + + // Gas details if available (for multisig transactions) + if (isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo)) { + const executionInfo = txDetails.detailedExecutionInfo + + items.push({ + label: 'SafeTxGas', + render: () => <Text>{executionInfo.safeTxGas}</Text>, + }) + + items.push({ + label: 'BaseGas', + render: () => <Text>{executionInfo.baseGas}</Text>, + }) + + items.push({ + label: 'GasPrice', + render: () => <Text>{executionInfo.gasPrice}</Text>, + }) + + // Gas Token + items.push({ + label: 'GasToken', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$1"> + <Identicon address={executionInfo.gasToken as Address} size={24} /> + <EthAddress address={executionInfo.gasToken as Address} copy copyProps={{ color: '$textSecondaryLight' }} /> + </View> + ), + }) + + // Refund Receiver + items.push({ + label: 'RefundReceiver', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$1"> + <Identicon address={executionInfo.refundReceiver.value as Address} size={24} /> + <EthAddress + address={executionInfo.refundReceiver.value as Address} + copy + copyProps={{ color: '$textSecondaryLight' }} + /> + </View> + ), + }) + + // Nonce + items.push({ + label: 'Nonce', + render: () => <Text>{executionInfo.nonce}</Text>, + }) + + // Safe Tx Hash + if (executionInfo.safeTxHash) { + items.push({ + label: 'Safe Tx Hash', + render: () => ( + <InfoSheet title="Safe Tx Hash" info={executionInfo.safeTxHash}> + <View flexDirection="row" alignItems="center" gap="$1"> + <Text>{shortenText(executionInfo.safeTxHash || '', characterDisplayLimit)}</Text> + <CopyButton value={executionInfo.safeTxHash || ''} color={'$textSecondaryLight'} text="Hash copied." /> + </View> + </InfoSheet> + ), + }) + } + } + + // Transaction Hash + if (txDetails.txHash) { + items.push({ + label: 'Transaction Hash', + render: () => ( + <InfoSheet title="Transaction Hash" info={txDetails.txHash || ''}> + <View flexDirection="row" alignItems="center" gap="$1"> + <Text>{shortenText(txDetails.txHash || '', characterDisplayLimit)}</Text> + <CopyButton value={txDetails.txHash || ''} color={'$textSecondaryLight'} text="Hash copied." /> + </View> + </InfoSheet> + ), + }) + } + + return items +} + +export { formatTxDetails } diff --git a/apps/mobile/src/features/Assets/Assets.container.tsx b/apps/mobile/src/features/Assets/Assets.container.tsx index 0cbfcecfb2..e52afb3794 100644 --- a/apps/mobile/src/features/Assets/Assets.container.tsx +++ b/apps/mobile/src/features/Assets/Assets.container.tsx @@ -1,14 +1,10 @@ -import React, { useEffect } from 'react' +import React from 'react' import { SafeTab } from '@/src/components/SafeTab' import { TokensContainer } from '@/src/features/Assets/components/Tokens' import { NFTsContainer } from '@/src/features/Assets/components/NFTs' import { AssetsHeaderContainer } from '@/src/features/Assets/components/AssetsHeader' -import useNotifications from '@/src/hooks/useNotifications' -import { useRouter } from 'expo-router' -import { useAppDispatch } from '@/src/store/hooks' -import { updatePromptAttempts } from '@/src/store/notificationsSlice' const tabItems = [ { @@ -16,28 +12,11 @@ const tabItems = [ Component: TokensContainer, }, { - label: `NFT's`, + label: `NFTs`, Component: NFTsContainer, }, ] export function AssetsContainer() { - const { isAppNotificationEnabled, promptAttempts } = useNotifications() - const dispatch = useAppDispatch() - const router = useRouter() - - /* - * If the user has not enabled notifications and has not been prompted to enable them, - * redirect to the opt-in screen - * */ - - const shouldShowOptIn = !isAppNotificationEnabled && !promptAttempts - - useEffect(() => { - if (shouldShowOptIn) { - dispatch(updatePromptAttempts(1)) - router.navigate('/notifications-opt-in') - } - }, []) return <SafeTab items={tabItems} headerHeight={200} renderHeader={AssetsHeaderContainer} /> } diff --git a/apps/mobile/src/features/Assets/Assets.error.tsx b/apps/mobile/src/features/Assets/Assets.error.tsx new file mode 100644 index 0000000000..958c1fe6b9 --- /dev/null +++ b/apps/mobile/src/features/Assets/Assets.error.tsx @@ -0,0 +1,21 @@ +import { SafeButton } from '@/src/components/SafeButton' +import React from 'react' +import { H6, Text, View } from 'tamagui' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' + +export const AssetError = ({ assetType, onRetry }: { assetType: 'token' | 'nft'; onRetry: () => void }) => { + const title = assetType === 'token' ? 'Couldn’t load tokens balances' : 'Couldn’t load NFTs' + + return ( + <View testID="token-error" alignItems="center" gap="$4" marginTop={'$4'}> + <H6 fontWeight={600}>{title}</H6> + <Text textAlign="center" color="$colorSecondary" width="80%"> + Something went wrong. Please try to load the page again. + </Text> + <SafeButton backgroundColor="$backgroundSecondary" color="$colorPrimary" onPress={onRetry}> + <SafeFontIcon size={16} name="update" color="$colorPrimary" /> + Retry + </SafeButton> + </View> + ) +} diff --git a/apps/mobile/src/features/Assets/components/AccountItem/hooks/useEditAccountItem.ts b/apps/mobile/src/features/Assets/components/AccountItem/hooks/useEditAccountItem.ts deleted file mode 100644 index 759ba32a06..0000000000 --- a/apps/mobile/src/features/Assets/components/AccountItem/hooks/useEditAccountItem.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice' -import { useAppDispatch, useAppSelector } from '@/src/store/hooks' -import { selectMyAccountsMode } from '@/src/store/myAccountsSlice' -import { removeSafe, selectAllSafes } from '@/src/store/safesSlice' -import { Address } from '@/src/types/address' -import { useCallback } from 'react' - -export const useEditAccountItem = () => { - const isEdit = useAppSelector(selectMyAccountsMode) - const activeSafe = useAppSelector(selectActiveSafe) - const safes = useAppSelector(selectAllSafes) - const dispatch = useAppDispatch() - - const onSafeDeleted = useCallback( - (address: Address) => () => { - if (activeSafe.address === address) { - const safe = Object.values(safes).find((item) => item.SafeInfo.address.value !== address) - - if (safe) { - dispatch( - setActiveSafe({ - address: safe.SafeInfo.address.value as Address, - chainId: safe.chains[0], - }), - ) - } - } - - dispatch(removeSafe(address)) - }, - [activeSafe], - ) - - return { isEdit, onSafeDeleted } -} diff --git a/apps/mobile/src/features/Assets/components/AssetsHeader/AssetsHeader.tsx b/apps/mobile/src/features/Assets/components/AssetsHeader/AssetsHeader.tsx index fa0e163129..b5742b1396 100644 --- a/apps/mobile/src/features/Assets/components/AssetsHeader/AssetsHeader.tsx +++ b/apps/mobile/src/features/Assets/components/AssetsHeader/AssetsHeader.tsx @@ -3,6 +3,7 @@ import { BalanceContainer } from '../Balance' import { PendingTransactions } from '@/src/components/StatusBanners/PendingTransactions' import { View } from 'tamagui' import { StyledAssetsHeader } from './styles' +import { ReadOnlyContainer } from '../ReadOnly/ReadOnly.container' interface AssetsHeaderProps { amount: number @@ -13,8 +14,8 @@ interface AssetsHeaderProps { export function AssetsHeader({ amount, isLoading, onPendingTransactionsPress, hasMore }: AssetsHeaderProps) { return ( - <StyledAssetsHeader backgroundColor="$background"> - <View marginBottom="$10" marginTop="0"> + <StyledAssetsHeader> + <View marginBottom="$8" marginTop="$4"> {amount > 0 && ( <PendingTransactions isLoading={isLoading} @@ -25,6 +26,8 @@ export function AssetsHeader({ amount, isLoading, onPendingTransactionsPress, ha </View> <BalanceContainer /> + + <ReadOnlyContainer /> </StyledAssetsHeader> ) } diff --git a/apps/mobile/src/features/Assets/components/AssetsHeader/styles.ts b/apps/mobile/src/features/Assets/components/AssetsHeader/styles.ts index 3f37a11007..50b5c74cb3 100644 --- a/apps/mobile/src/features/Assets/components/AssetsHeader/styles.ts +++ b/apps/mobile/src/features/Assets/components/AssetsHeader/styles.ts @@ -1,5 +1,6 @@ import { styled, View } from 'tamagui' export const StyledAssetsHeader = styled(View, { - paddingHorizontal: 10, + paddingHorizontal: '$4', + backgroundColor: '$background', }) diff --git a/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx b/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx index c84c3da24b..d0876b5722 100644 --- a/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx +++ b/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx @@ -1,25 +1,29 @@ -import { useDispatch } from 'react-redux' -import { useSafesGetSafeOverviewV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes' -import { selectActiveSafe, switchActiveChain } from '@/src/store/activeSafeSlice' import { SafeOverviewResult } from '@safe-global/store/gateway/types' import { POLLING_INTERVAL } from '@/src/config/constants' -import { getChainsByIds, selectAllChains } from '@/src/store/chains' +import { getChainsByIds, selectAllChains, selectChainById } from '@/src/store/chains' import { Balance } from './Balance' import { makeSafeId } from '@/src/utils/formatters' import { RootState } from '@/src/store' -import { selectSafeInfo } from '@/src/store/safesSlice' +import { selectSafeChains } from '@/src/store/safesSlice' import { useAppSelector } from '@/src/store/hooks' +import { useSafesGetOverviewForManyQuery } from '@safe-global/store/gateway/safes' +import React, { useCallback } from 'react' +import { useSelector } from 'react-redux' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useCopyAndDispatchToast } from '@/src/hooks/useCopyAndDispatchToast' +import { selectCurrency } from '@/src/store/settingsSlice' export function BalanceContainer() { const chains = useAppSelector(selectAllChains) - const activeSafe = useAppSelector(selectActiveSafe) - const dispatch = useDispatch() - const activeSafeInfo = useAppSelector((state: RootState) => selectSafeInfo(state, activeSafe.address)) - const activeSafeChains = useAppSelector((state: RootState) => getChainsByIds(state, activeSafeInfo.chains)) - const { data, isLoading } = useSafesGetSafeOverviewV1Query<SafeOverviewResult>( + const activeSafe = useDefinedActiveSafe() + const chainsIds = useAppSelector((state: RootState) => selectSafeChains(state, activeSafe.address)) + const activeSafeChains = useAppSelector((state: RootState) => getChainsByIds(state, chainsIds)) + const copy = useCopyAndDispatchToast() + const currency = useAppSelector(selectCurrency) + const { data, isLoading } = useSafesGetOverviewForManyQuery<SafeOverviewResult>( { - safes: chains.map((chain) => makeSafeId(chain.chainId, activeSafe.address)).join(','), - currency: 'usd', + safes: [makeSafeId(activeSafe.chainId, activeSafe.address)], + currency, trusted: true, excludeSpam: true, }, @@ -28,18 +32,22 @@ export function BalanceContainer() { skip: chains.length === 0, }, ) + const activeChain = useSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + const balance = data?.find((chain) => chain.chainId === activeSafe.chainId) - const handleChainChange = (chainId: string) => { - dispatch(switchActiveChain({ chainId })) - } + const onPressAddressCopy = useCallback(() => { + copy(activeSafe.address) + }, [activeSafe.address]) return ( <Balance - data={data} + chainName={activeChain?.chainName} chains={activeSafeChains} isLoading={isLoading} activeChainId={activeSafe.chainId} - onChainChange={handleChainChange} + safeAddress={activeSafe.address} + balanceAmount={balance?.fiatTotal || ''} + onPressAddressCopy={onPressAddressCopy} /> ) } diff --git a/apps/mobile/src/features/Assets/components/Balance/Balance.tsx b/apps/mobile/src/features/Assets/components/Balance/Balance.tsx index 8896651010..6754cb6a4b 100644 --- a/apps/mobile/src/features/Assets/components/Balance/Balance.tsx +++ b/apps/mobile/src/features/Assets/components/Balance/Balance.tsx @@ -1,64 +1,74 @@ import React from 'react' -import { Spinner, View } from 'tamagui' +import { View, XStack, Text } from 'tamagui' -import { Alert } from '@/src/components/Alert' -import { Dropdown } from '@/src/components/Dropdown' +import { DropdownLabel } from '@/src/components/Dropdown' import { Fiat } from '@/src/components/Fiat' -import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' -import { ChainItems } from './ChainItems' import { ChainsDisplay } from '@/src/components/ChainsDisplay' -import { selectChainById } from '@/src/store/chains' -import { useSelector } from 'react-redux' -import { RootState } from '@/src/store' +import { useRouter } from 'expo-router' +import { TouchableOpacity, useColorScheme } from 'react-native' +import { shortenAddress } from '@safe-global/utils/utils/formatters' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Skeleton } from 'moti/skeleton' +import { useAppSelector } from '@/src/store/hooks' +import { selectCurrency } from '@/src/store/settingsSlice' interface BalanceProps { activeChainId: string - data: SafeOverview[] + safeAddress: string isLoading: boolean chains: Chain[] - onChainChange: (chainId: string) => void + balanceAmount: string + chainName: string + onPressAddressCopy: () => void } -export function Balance({ activeChainId, data, chains, isLoading, onChainChange }: BalanceProps) { - const balance = data?.find((chain) => chain.chainId === activeChainId) - const activeChain = useSelector((state: RootState) => selectChainById(state, activeChainId)) +export function Balance({ + activeChainId, + chains, + isLoading, + balanceAmount, + chainName, + safeAddress, + onPressAddressCopy, +}: BalanceProps) { + const router = useRouter() + const colorScheme = useColorScheme() + const currency = useAppSelector(selectCurrency) + + const showSkeleton = isLoading || !balanceAmount return ( - <View> - <View marginBottom="$8"> - {activeChainId && ( - <Dropdown<SafeOverview> - label={activeChain?.chainName} - dropdownTitle="Select network:" + <View marginBottom="$4"> + {activeChainId && ( + <XStack paddingBottom={'$4'} gap={'$1'} alignItems={'center'}> + <DropdownLabel + label={chainName} leftNode={<ChainsDisplay activeChainId={activeChainId} chains={chains} max={1} />} - items={data} - keyExtractor={({ item }) => item.chainId} - renderItem={({ item, onClose }) => ( - <ChainItems - onSelect={(chainId: string) => { - onChainChange(chainId) - onClose() - }} - activeChain={activeChain} - fiatTotal={item.fiatTotal} - chains={chains} - chainId={item.chainId} - key={item.chainId} - /> - )} + onPress={() => { + router.push('/networks-sheet') + }} + labelProps={{ fontWeight: 600, fontSize: '$4' }} + displayDropDownIcon={chains.length > 1} /> - )} - - {isLoading ? ( - <Spinner /> - ) : balance ? ( - <Fiat baseAmount={balance.fiatTotal} /> - ) : ( - <Alert type="error" message="error while getting the balance of your wallet" /> - )} - </View> + <TouchableOpacity onPress={onPressAddressCopy}> + <XStack alignItems={'center'} paddingLeft={'$1'}> + <Text color={'$colorSecondary'} fontSize={'$4'}> + {shortenAddress(safeAddress)} + </Text> + <View paddingLeft={'$1'}> + <SafeFontIcon name={'copy'} size={13} color={'$colorSecondary'} /> + </View> + </XStack> + </TouchableOpacity> + </XStack> + )} + <Skeleton.Group show={showSkeleton}> + <Skeleton colorMode={colorScheme === 'dark' ? 'dark' : 'light'} width={220}> + <Fiat value={balanceAmount} currency={currency} precise /> + </Skeleton> + </Skeleton.Group> </View> ) } diff --git a/apps/mobile/src/features/Assets/components/Fallback/Fallback.tsx b/apps/mobile/src/features/Assets/components/Fallback/Fallback.tsx index e290f86076..3e8e3e7f8b 100644 --- a/apps/mobile/src/features/Assets/components/Fallback/Fallback.tsx +++ b/apps/mobile/src/features/Assets/components/Fallback/Fallback.tsx @@ -1,18 +1,9 @@ import React from 'react' -import { Spinner } from 'tamagui' +import { getTokenValue } from 'tamagui' -import { Alert } from '@/src/components/Alert' import { SafeTab } from '@/src/components/SafeTab' -import { NoFunds } from '../NoFunds' +import { Loader } from '@/src/components/Loader' -export const Fallback = ({ loading, hasError }: { loading: boolean; hasError: boolean }) => ( - <SafeTab.ScrollView> - {loading ? ( - <Spinner size="small" /> - ) : hasError ? ( - <Alert type="error" message={`Error to get this assets list`} /> - ) : ( - <NoFunds /> - )} - </SafeTab.ScrollView> +export const Fallback = ({ loading, children }: { loading: boolean; children: React.ReactElement }) => ( + <SafeTab.ScrollView style={{ padding: getTokenValue('$4') }}>{loading ? <Loader /> : children}</SafeTab.ScrollView> ) diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccountsFooter.test.tsx b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccountsFooter.test.tsx deleted file mode 100644 index 76b422a0ff..0000000000 --- a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccountsFooter.test.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { render } from '@/src/tests/test-utils' -import { MyAccountsFooter } from './MyAccountsFooter' -import { SharedValue } from 'react-native-reanimated' - -describe('MyAccountsFooter', () => { - it('should render the defualt template', () => { - const container = render(<MyAccountsFooter animatedFooterPosition={2 as unknown as SharedValue<number>} />) - - expect(container.getByText('Add Existing Account')).toBeDefined() - expect(container.getByText('Join New Account')).toBeDefined() - }) -}) diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccountsFooter.tsx b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccountsFooter.tsx deleted file mode 100644 index 7d930ce395..0000000000 --- a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccountsFooter.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Badge } from '@/src/components/Badge' -import { SafeFontIcon } from '@/src/components/SafeFontIcon' -import { BottomSheetFooter, BottomSheetFooterProps } from '@gorhom/bottom-sheet' -import React from 'react' -import { TouchableOpacity } from 'react-native' -import { styled, Text, View } from 'tamagui' - -const MyAccountsFooterContainer = styled(View, { - borderTopWidth: 1, - borderTopColor: '$colorSecondary', - paddingVertical: '$7', - paddingHorizontal: '$5', - backgroundColor: '$backgroundPaper', -}) - -const MyAccountsButton = styled(View, { - columnGap: '$3', - alignItems: 'center', - flexDirection: 'row', - marginBottom: '$7', -}) - -interface CustomFooterProps extends BottomSheetFooterProps {} - -export function MyAccountsFooter({ animatedFooterPosition }: CustomFooterProps) { - const onAddAccountClick = () => null - const onJoinAccountClick = () => null - - return ( - <BottomSheetFooter animatedFooterPosition={animatedFooterPosition}> - <MyAccountsFooterContainer> - <TouchableOpacity onPress={onAddAccountClick}> - <MyAccountsButton testID="add-existing-account"> - <Badge - themeName="badge_background" - circleSize="$10" - content={<SafeFontIcon size={20} name="plus-filled" />} - /> - - <Text fontSize="$4" fontWeight={600}> - Add Existing Account - </Text> - </MyAccountsButton> - </TouchableOpacity> - - <TouchableOpacity onPress={onJoinAccountClick}> - <MyAccountsButton testID="join-new-account"> - <Badge themeName="badge_background" circleSize="$10" content={<SafeFontIcon size={20} name="owners" />} /> - - <Text fontSize="$4" fontWeight={600}> - Join New Account - </Text> - </MyAccountsButton> - </TouchableOpacity> - </MyAccountsFooterContainer> - </BottomSheetFooter> - ) -} diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.test.tsx b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.test.tsx deleted file mode 100644 index 165a90eb18..0000000000 --- a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { renderHook, waitFor } from '@/src/tests/test-utils' -import { useMyAccountsService } from './useMyAccountsService' -import { server } from '@/src/tests/server' -import { http, HttpResponse } from 'msw' -import { GATEWAY_URL } from '@/src/config/constants' - -// Mock safe item -const mockSafeItem = { - SafeInfo: { - address: { value: '0x123' as `0x${string}`, name: 'Test Safe' }, - threshold: 1, - owners: [{ value: '0x456' as `0x${string}` }], - fiatTotal: '1000', - chainId: '1', - queued: 0, - }, - chains: ['1'], -} - -// Mock chain IDs selector -const mockChainIds = ['1', '5'] as const - -jest.mock('@/src/store/chains', () => ({ - selectAllChainsIds: () => mockChainIds, -})) - -// Mock Redux dispatch and selector -const mockDispatch = jest.fn() - -jest.mock('@/src/store/hooks', () => ({ - useAppDispatch: () => mockDispatch, - useAppSelector: (selector: (state: unknown) => unknown) => { - if (selector.name === 'selectAllChainsIds') { - return mockChainIds - } - return null - }, -})) - -describe('useMyAccountsService', () => { - beforeEach(() => { - jest.clearAllMocks() - server.use( - http.get(`${GATEWAY_URL}/v1/safes`, ({ request }) => { - const url = new URL(request.url) - const safes = url.searchParams.get('safes')?.split(',') || [] - - return HttpResponse.json( - safes.map((safe) => ({ - address: { value: '0x123', name: 'Test Safe' }, - chainId: safe.split(':')[0], - threshold: 1, - owners: [{ value: '0x456' }], - fiatTotal: '1000', - queued: 0, - })), - ) - }), - ) - }) - - afterEach(() => { - server.resetHandlers() - }) - - it('should fetch safe overview and update store', async () => { - renderHook(() => useMyAccountsService(mockSafeItem)) - - // Wait for dispatch to be called - await waitFor(() => { - expect(mockDispatch).toHaveBeenCalled() - }) - - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'safes/updateSafeInfo', - payload: expect.objectContaining({ - address: '0x123', - item: expect.objectContaining({ - chains: ['1', '5'], - SafeInfo: expect.objectContaining({ - fiatTotal: '2000', // Sum of both chain balances - }), - }), - }), - }), - ) - }) - - it('should not update store if no data is returned', async () => { - server.use( - http.get(`${GATEWAY_URL}/v1/safes`, () => { - return HttpResponse.json([]) - }), - ) - - renderHook(() => useMyAccountsService(mockSafeItem)) - - await waitFor(() => { - expect(mockDispatch).not.toHaveBeenCalled() - }) - }) - - it('should handle API errors gracefully', async () => { - server.use( - http.get(`${GATEWAY_URL}/v1/safes`, () => { - return HttpResponse.error() - }), - ) - - renderHook(() => useMyAccountsService(mockSafeItem)) - - await waitFor(() => { - expect(mockDispatch).not.toHaveBeenCalled() - }) - }) -}) diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.ts b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.ts deleted file mode 100644 index 11cd7525c5..0000000000 --- a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useSafesGetSafeOverviewV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes' -import { SafeOverviewResult } from '@safe-global/store/gateway/types' -import { useEffect, useMemo } from 'react' - -import { selectAllChainsIds } from '@/src/store/chains' -import { SafesSliceItem, updateSafeInfo } from '@/src/store/safesSlice' -import { Address } from '@/src/types/address' -import { makeSafeId } from '@/src/utils/formatters' -import { useAppDispatch, useAppSelector } from '@/src/store/hooks' - -export const useMyAccountsService = (item: SafesSliceItem) => { - const dispatch = useAppDispatch() - const chainIds = useAppSelector(selectAllChainsIds) - const safes = useMemo( - () => chainIds.map((chainId: string) => makeSafeId(chainId, item.SafeInfo.address.value)).join(','), - [chainIds, item.SafeInfo.address.value], - ) - const { data } = useSafesGetSafeOverviewV1Query<SafeOverviewResult>({ - safes, - currency: 'usd', - trusted: true, - excludeSpam: true, - }) - - useEffect(() => { - if (!data) { - return - } - - const safe = data[0] - - dispatch( - updateSafeInfo({ - address: safe.address.value as Address, - item: { - chains: data.map((safeInfo) => safeInfo.chainId), - SafeInfo: { - ...safe, - fiatTotal: data.reduce((prev, { fiatTotal }) => parseFloat(fiatTotal) + prev, 0).toString(), - }, - }, - }), - ) - }, [data, dispatch]) -} diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts deleted file mode 100644 index 00cc577a90..0000000000 --- a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { selectMyAccountsMode, toggleMode } from '@/src/store/myAccountsSlice' -import { SafesSliceItem, selectAllSafes, setSafes } from '@/src/store/safesSlice' -import { useCallback, useEffect, useState } from 'react' -import { DragEndParams } from 'react-native-draggable-flatlist' -import { useDispatch, useSelector } from 'react-redux' - -type useMyAccountsSortableReturn = { - safes: SafesSliceItem[] - onDragEnd: (params: DragEndParams<SafesSliceItem>) => void -} - -export const useMyAccountsSortable = (): useMyAccountsSortableReturn => { - const dispatch = useDispatch() - const safes = useSelector(selectAllSafes) - const [sortableSafes, setSortableSafes] = useState(() => Object.values(safes)) - const isEdit = useSelector(selectMyAccountsMode) - - useEffect(() => { - const newSafes = Object.values(safes) - const shouldGoToListMode = newSafes.length <= 1 && isEdit - - setSortableSafes(newSafes) - - if (shouldGoToListMode) { - dispatch(toggleMode()) - } - }, [safes, isEdit]) - - const onDragEnd = useCallback(({ data }: DragEndParams<SafesSliceItem>) => { - // Defer Redux update due to incompatibility issues between - // react-native-draggable-flatlist and new architecture. - setTimeout(() => { - const safes = data.reduce((acc, item) => ({ ...acc, [item.SafeInfo.address.value]: item }), {}) - dispatch(setSafes(safes)) - }, 0) // Ensure this happens after the re-render - }, []) - - return { safes: sortableSafes, onDragEnd } -} diff --git a/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.test.tsx b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.test.tsx index d3dedda2a2..cab7e9238b 100644 --- a/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.test.tsx +++ b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.test.tsx @@ -23,7 +23,7 @@ describe('NFTsContainer', () => { it('renders error state when API fails', async () => { server.use( - http.get(`${GATEWAY_URL}//v2/chains/:chainId/safes/:safeAddress/collectibles`, () => { + http.get(`${GATEWAY_URL}/v2/chains/:chainId/safes/:safeAddress/collectibles`, () => { return HttpResponse.error() }), ) @@ -48,7 +48,7 @@ describe('NFTsContainer', () => { it('renders fallback when data is empty', async () => { server.use( - http.get(`${GATEWAY_URL}//v2/chains/:chainId/safes/:safeAddress/collectibles`, () => { + http.get(`${GATEWAY_URL}/v2/chains/:chainId/safes/:safeAddress/collectibles`, () => { return HttpResponse.json({ results: [] }) }), ) diff --git a/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx index 10c88689ab..f350f13b09 100644 --- a/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx +++ b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx @@ -1,10 +1,8 @@ import { safelyDecodeURIComponent } from 'expo-router/build/fork/getStateFromPath-forks' import React, { useState } from 'react' -import { useSelector } from 'react-redux' import { SafeTab } from '@/src/components/SafeTab' import { POLLING_INTERVAL } from '@/src/config/constants' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { Collectible, CollectiblePage, @@ -14,9 +12,14 @@ import { import { Fallback } from '../Fallback' import { NFTItem } from './NFTItem' import { useInfiniteScroll } from '@/src/hooks/useInfiniteScroll' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { NoFunds } from '@/src/features/Assets/components/NoFunds' +import { AssetError } from '../../Assets.error' +import { Loader } from '@/src/components/Loader' +import { getTokenValue } from 'tamagui' export function NFTsContainer() { - const activeSafe = useSelector(selectActiveSafe) + const activeSafe = useDefinedActiveSafe() const [pageUrl, setPageUrl] = useState<string>() const { data, isFetching, error, refetch } = useCollectiblesGetCollectiblesV2Query( @@ -35,16 +38,30 @@ export function NFTsContainer() { data, }) - if (isFetching || !list?.length || error) { - return <Fallback loading={isFetching || !list} hasError={!!error} /> + if (error) { + return ( + <Fallback loading={isFetching}> + <AssetError assetType={'nft'} onRetry={() => refetch()} /> + </Fallback> + ) + } + + if (!list?.results.length) { + return ( + <Fallback loading={isFetching || !list}> + <NoFunds fundsType={'nft'} /> + </Fallback> + ) } return ( <SafeTab.FlatList<Collectible> onEndReached={onEndReached} - data={list} + data={list?.results} renderItem={NFTItem} - keyExtractor={(item) => item.id} + ListFooterComponent={isFetching ? <Loader size={24} /> : undefined} + keyExtractor={(item, index) => `${item.address}-${index}`} + style={{ marginTop: getTokenValue('$2') }} /> ) } diff --git a/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx b/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx index 0c4aee6608..04e185d164 100644 --- a/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx +++ b/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx @@ -1,21 +1,19 @@ -import { selectActiveSafe } from '@/src/store/activeSafeSlice' -import { View, H6 } from 'tamagui' -import { BlurredIdenticonBackground } from '@/src/components/BlurredIdenticonBackground' -import { SafeAreaView } from 'react-native-safe-area-context' -import { Identicon } from '@/src/components/Identicon' +import React from 'react' +import { View, Pressable } from 'react-native' +import { Theme, XStack, getTokenValue } from 'tamagui' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { IdenticonWithBadge } from '@/src/features/Settings/components/IdenticonWithBadge' + import { shortenAddress } from '@/src/utils/formatters' import { SafeFontIcon } from '@/src/components/SafeFontIcon' -import { StyleSheet, TouchableOpacity } from 'react-native' -import React from 'react' -import { Address } from '@/src/types/address' -import { Dropdown } from '@/src/components/Dropdown' -import { SafesSliceItem } from '@/src/store/safesSlice' -import { selectMyAccountsMode, toggleMode } from '@/src/store/myAccountsSlice' -import { MyAccountsContainer, MyAccountsFooter } from '../MyAccounts' -import { useMyAccountsSortable } from '../MyAccounts/hooks/useMyAccountsSortable' -import { useAppDispatch, useAppSelector } from '@/src/store/hooks' -import { router } from 'expo-router' +import { useAppSelector } from '@/src/store/hooks' +import { Link, useRouter } from 'expo-router' +import { DropdownLabel } from '@/src/components/Dropdown/DropdownLabel' import { selectAppNotificationStatus } from '@/src/store/notificationsSlice' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { selectContactByAddress } from '@/src/store/addressBookSlice' +import { selectSafeInfo } from '@/src/store/safesSlice' +import { RootState } from '@/src/store' const dropdownLabelProps = { fontSize: '$5', @@ -23,73 +21,70 @@ const dropdownLabelProps = { } as const export const Navbar = () => { - const dispatch = useAppDispatch() - const isEdit = useAppSelector(selectMyAccountsMode) - const activeSafe = useAppSelector(selectActiveSafe) + const insets = useSafeAreaInsets() + const router = useRouter() + const activeSafe = useDefinedActiveSafe() + const contact = useAppSelector(selectContactByAddress(activeSafe.address)) const isAppNotificationEnabled = useAppSelector(selectAppNotificationStatus) - const { safes, onDragEnd } = useMyAccountsSortable() const handleNotificationAccess = () => { if (!isAppNotificationEnabled) { router.navigate('/notifications-opt-in') + } else { + router.navigate('/notifications-center') } - // TODO: navigate to notifications list when notifications are enabled } - const toggleEditMode = () => { - dispatch(toggleMode()) - } + const activeSafeInfo = useAppSelector((state: RootState) => selectSafeInfo(state, activeSafe.address)) + const chainSafe = activeSafeInfo ? activeSafeInfo[activeSafe.chainId] : undefined return ( - <View> - <BlurredIdenticonBackground address={activeSafe.address as Address}> - <SafeAreaView style={[styles.headerContainer]}> - <Dropdown<SafesSliceItem> - label={shortenAddress(activeSafe.address)} - labelProps={dropdownLabelProps} - dropdownTitle="My accounts" - leftNode={<Identicon address={activeSafe.address} rounded={true} size={30} />} - items={safes} - keyExtractor={({ item }) => item.SafeInfo.address.value} - footerComponent={MyAccountsFooter} - renderItem={MyAccountsContainer} - sortable={isEdit} - onDragEnd={onDragEnd} - actions={ - safes.length > 1 && ( - <TouchableOpacity onPress={toggleEditMode}> - <H6 fontWeight={600}>{isEdit ? 'Done' : 'Edit'}</H6> - </TouchableOpacity> - ) - } - /> - <View style={styles.rightButtonContainer}> - <TouchableOpacity onPress={handleNotificationAccess}> - <SafeFontIcon name="lightbulb" /> - </TouchableOpacity> - <TouchableOpacity> - <SafeFontIcon name="apps" /> - </TouchableOpacity> - </View> - </SafeAreaView> - </BlurredIdenticonBackground> - </View> + <Theme name="navbar"> + <XStack + paddingTop={getTokenValue('$3') + insets.top} + justifyContent={'space-between'} + paddingHorizontal={16} + alignItems={'center'} + paddingBottom={'$2'} + backgroundColor={'$background'} + > + <DropdownLabel + label={contact ? contact.name : shortenAddress(activeSafe.address)} + labelProps={dropdownLabelProps} + leftNode={ + <IdenticonWithBadge + testID="threshold-info-badge" + variant="sm" + size={30} + badgeSize={18} + fontSize={8} + address={activeSafe.address} + badgeContent={`${chainSafe?.threshold}/${chainSafe?.owners.length}`} + /> + } + onPress={() => { + router.push('/accounts-sheet') + }} + hitSlop={4} + /> + <View + style={{ + flexDirection: 'row', + gap: 18, + alignItems: 'center', + justifyContent: 'center', + }} + > + <Link href={'/share'} asChild> + <Pressable hitSlop={10}> + <SafeFontIcon name="qr-code-1" size={16} /> + </Pressable> + </Link> + <Pressable onPressIn={handleNotificationAccess} hitSlop={8}> + <SafeFontIcon name="bell" size={20} /> + </Pressable> + </View> + </XStack> + </Theme> ) } - -const styles = StyleSheet.create({ - headerContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 10, - paddingVertical: 16, - paddingBottom: 0, - }, - rightButtonContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 12, - }, -}) diff --git a/apps/mobile/src/features/Assets/components/Navbar/theme.ts b/apps/mobile/src/features/Assets/components/Navbar/theme.ts new file mode 100644 index 0000000000..f5e70a6536 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/Navbar/theme.ts @@ -0,0 +1,10 @@ +import { tokens } from '@/src/theme/tokens' + +export const navbarTheme = { + light_navbar: { + background: tokens.color.backgroundPaperLight, + }, + dark_navbar: { + background: tokens.color.backgroundDefaultDark, + }, +} diff --git a/apps/mobile/src/features/Assets/components/NoFunds/EmptyNFT.tsx b/apps/mobile/src/features/Assets/components/NoFunds/EmptyNFT.tsx new file mode 100644 index 0000000000..127af611f4 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/NoFunds/EmptyNFT.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { useColorScheme } from 'react-native' +import Svg, { Path } from 'react-native-svg' +import { getTokenValue } from 'tamagui' + +function EmptyNft() { + const colorScheme = useColorScheme() + let color = getTokenValue('$color.backgroundMainDark') + + if (colorScheme === 'light') { + color = getTokenValue('$color.backgroundMainLight') + } + return ( + <Svg width="101" height="100" viewBox="0 0 101 100" fill="none"> + <Path + fill-rule="evenodd" + clip-rule="evenodd" + d="M58.9064 71.4315C75.7649 71.4315 89.4314 57.765 89.4314 40.9065C89.4314 24.0481 75.7649 10.3815 58.9064 10.3815C42.0479 10.3815 28.3814 24.0481 28.3814 40.9065C28.3814 57.765 42.0479 71.4315 58.9064 71.4315ZM90.2627 40.9065C90.2627 58.2241 76.224 72.2628 58.9064 72.2628C41.5888 72.2628 27.5502 58.2241 27.5502 40.9065C27.5502 23.589 41.5888 9.55029 58.9064 9.55029C76.224 9.55029 90.2627 23.589 90.2627 40.9065Z" + fill="#A1A3A7" + /> + <Path + d="M41.6375 89.9253C58.5685 89.9253 72.2938 76.2 72.2938 59.269C72.2938 42.3381 58.5685 28.6128 41.6375 28.6128C24.7065 28.6128 10.9813 42.3381 10.9813 59.269C10.9813 76.2 24.7065 89.9253 41.6375 89.9253Z" + fill={color} + /> + <Path + d="M41.7625 90H41.6125C41.3875 90 41.2063 89.8125 41.2063 89.5938C41.2063 89.3687 41.3688 89.2125 41.6125 89.1875H41.7438C41.7438 89.1875 41.7437 89.1875 41.75 89.1875C42.725 89.1875 43.7125 89.1375 44.6875 89.0437C44.9063 89.0187 45.1063 89.1875 45.1313 89.4062C45.1563 89.6312 44.9875 89.8313 44.7688 89.85C43.775 89.9438 42.7563 89.9937 41.7563 89.9937L41.7625 90ZM39.125 89.8875C39.125 89.8875 39.1 89.8875 39.0875 89.8875C38.0375 89.8 36.9813 89.6562 35.95 89.4562C35.7313 89.4125 35.5875 89.2 35.625 88.9813C35.6688 88.7625 35.8813 88.6125 36.1 88.6562C37.1063 88.8438 38.1313 88.9875 39.15 89.075C39.375 89.0937 39.5375 89.2937 39.5187 89.5125C39.5 89.725 39.325 89.8813 39.1125 89.8813L39.125 89.8875ZM47.2125 89.5125C47.0187 89.5125 46.85 89.375 46.8125 89.1812C46.775 88.9625 46.9188 88.75 47.1375 88.7062C48.1438 88.525 49.1562 88.2875 50.1375 88.0063C50.3562 87.9438 50.5812 88.0687 50.6375 88.2812C50.7 88.5 50.575 88.725 50.3625 88.7875C49.3563 89.0813 48.3187 89.3187 47.2812 89.5062C47.2562 89.5062 47.2313 89.5125 47.2063 89.5125H47.2125ZM33.5938 88.9C33.5562 88.9 33.5188 88.9 33.4875 88.8875C32.475 88.6062 31.4625 88.2688 30.4875 87.8875C30.2812 87.8063 30.175 87.5687 30.2563 87.3625C30.3375 87.1562 30.575 87.05 30.7812 87.1312C31.7375 87.5062 32.7188 87.8312 33.7063 88.1062C33.925 88.1687 34.05 88.3875 33.9875 88.6062C33.9375 88.7875 33.775 88.9062 33.5938 88.9062V88.9ZM52.6188 88.0125C52.4562 88.0125 52.3 87.9125 52.2375 87.75C52.1562 87.5375 52.2625 87.3063 52.475 87.225C53.4313 86.8625 54.3812 86.4437 55.2937 85.9813C55.5 85.8812 55.7375 85.9625 55.8375 86.1625C55.9375 86.3625 55.8563 86.6063 55.6562 86.7063C54.7188 87.1813 53.7438 87.6125 52.7625 87.9812C52.7125 88 52.6688 88.0063 52.6188 88.0063V88.0125ZM28.3375 86.9062C28.275 86.9062 28.2188 86.8938 28.1562 86.8625C27.2125 86.4 26.2812 85.8812 25.3875 85.325C25.2 85.2062 25.1375 84.9563 25.2563 84.7625C25.375 84.575 25.625 84.5125 25.8188 84.6312C26.6875 85.175 27.5938 85.675 28.5125 86.125C28.7125 86.225 28.8 86.4688 28.7 86.6688C28.6313 86.8125 28.4875 86.8937 28.3375 86.8937V86.9062ZM57.6563 85.5375C57.5188 85.5375 57.3875 85.4687 57.3063 85.3437C57.1875 85.15 57.25 84.9 57.4437 84.7875C58.3125 84.2562 59.1688 83.6687 59.9875 83.05C60.1625 82.9125 60.4188 82.95 60.5563 83.125C60.6938 83.3062 60.6562 83.5562 60.4813 83.6937C59.6437 84.3312 58.7625 84.9312 57.8688 85.4812C57.8 85.5187 57.7313 85.5438 57.6563 85.5438V85.5375ZM23.5438 83.9875C23.4625 83.9875 23.375 83.9625 23.3 83.9062C22.4562 83.2812 21.6313 82.6 20.8625 81.8937C20.7 81.7437 20.6875 81.4875 20.8375 81.3187C20.9875 81.15 21.2437 81.1437 21.4125 81.2938C22.1687 81.9875 22.9688 82.6437 23.7875 83.2562C23.9688 83.3875 24.0062 83.6438 23.8687 83.825C23.7875 83.9313 23.6688 83.9875 23.5438 83.9875ZM62.1625 82.1812C62.05 82.1812 61.9375 82.1375 61.8625 82.0437C61.7125 81.875 61.725 81.6187 61.8938 81.4688C62.6563 80.7875 63.3875 80.0562 64.075 79.2937C64.225 79.125 64.4813 79.1188 64.65 79.2688C64.8187 79.4188 64.8312 79.675 64.675 79.8438C63.9688 80.625 63.2125 81.375 62.4313 82.075C62.3563 82.1437 62.2563 82.1812 62.1625 82.1812ZM19.3625 80.2375C19.2562 80.2375 19.1438 80.1937 19.0625 80.1062C18.35 79.3375 17.6625 78.5187 17.0312 77.6813C16.8937 77.5 16.9313 77.25 17.1125 77.1125C17.2938 76.975 17.55 77.0125 17.6813 77.1938C18.3 78.0125 18.9625 78.8063 19.6625 79.5563C19.8125 79.7188 19.8062 79.975 19.6438 80.1312C19.5688 80.2062 19.4688 80.2375 19.3687 80.2375H19.3625ZM65.975 78.0625C65.8875 78.0625 65.8 78.0375 65.725 77.9812C65.55 77.8437 65.5125 77.5875 65.65 77.4125C66.275 76.6 66.8625 75.7437 67.4 74.875C67.5187 74.6875 67.7687 74.625 67.9562 74.7437C68.15 74.8625 68.2062 75.1125 68.0875 75.3C67.5375 76.1937 66.9313 77.0688 66.2875 77.9062C66.2063 78.0125 66.0875 78.0625 65.9625 78.0625H65.975ZM15.9375 75.7875C15.8 75.7875 15.6688 75.7188 15.5938 75.6C15.0313 74.7125 14.5063 73.7875 14.0375 72.8437C13.9375 72.6437 14.0187 72.4 14.2188 72.3C14.425 72.2 14.6625 72.2812 14.7625 72.4813C15.2188 73.4 15.725 74.3 16.275 75.1625C16.3938 75.35 16.3375 75.6062 16.15 75.725C16.0813 75.7687 16.0062 75.7875 15.9312 75.7875H15.9375ZM68.975 73.3125C68.9125 73.3125 68.85 73.3 68.7938 73.2687C68.5938 73.1687 68.5125 72.925 68.6187 72.725C69.0812 71.8125 69.5062 70.8625 69.875 69.9125C69.9562 69.7 70.1938 69.6 70.4 69.6812C70.6063 69.7625 70.7125 70 70.6312 70.2063C70.25 71.1875 69.8188 72.1562 69.3438 73.0938C69.2687 73.2375 69.1312 73.3125 68.9813 73.3125H68.975ZM13.3812 70.7875C13.2187 70.7875 13.0688 70.6937 13.0063 70.5312C12.6125 69.55 12.275 68.5437 11.9875 67.5375C11.925 67.3187 12.05 67.1 12.2687 67.0375C12.4875 66.975 12.7125 67.1 12.7688 67.3187C13.05 68.3 13.3813 69.2812 13.7625 70.2375C13.8438 70.4437 13.7437 70.6813 13.5375 70.7625C13.4875 70.7812 13.4375 70.7937 13.3875 70.7937L13.3812 70.7875ZM71.0563 68.0938C71.0187 68.0938 70.9813 68.0938 70.9437 68.075C70.7313 68.0125 70.6063 67.7875 70.6688 67.5687C70.9563 66.5875 71.2 65.575 71.3875 64.575C71.425 64.3563 71.6375 64.2062 71.8625 64.25C72.0812 64.2937 72.2313 64.5 72.1875 64.725C71.9938 65.7562 71.75 66.7937 71.4562 67.8C71.4062 67.975 71.2438 68.0938 71.0688 68.0938H71.0563ZM11.7938 65.4062C11.6063 65.4062 11.4313 65.275 11.3938 65.0812C11.1938 64.05 11.0375 63 10.9438 61.95C10.925 61.725 11.0875 61.5312 11.3125 61.5062C11.525 61.4937 11.7313 61.65 11.7563 61.875C11.85 62.8937 12 63.925 12.1938 64.925C12.2375 65.1437 12.0937 65.3563 11.875 65.4C11.85 65.4 11.825 65.4062 11.7938 65.4062ZM72.1375 62.5875C72.1375 62.5875 72.1125 62.5875 72.0938 62.5875C71.8688 62.5625 71.7062 62.3688 71.7313 62.1437C71.8375 61.1312 71.8875 60.0938 71.8875 59.0688C71.8875 58.8375 71.8875 58.6063 71.8812 58.375C71.8812 58.15 72.0563 57.9625 72.275 57.9562C72.525 57.9562 72.6875 58.1313 72.6938 58.35C72.6938 58.5875 72.7 58.825 72.7 59.0688C72.7 60.125 72.6438 61.1875 72.5375 62.2313C72.5188 62.4375 72.3375 62.5938 72.1312 62.5938L72.1375 62.5875ZM11.2188 59.825C10.9938 59.825 10.8125 59.6438 10.8125 59.425V59.0688C10.8125 58.1313 10.85 57.1875 10.9375 56.2687C10.9562 56.0437 11.1625 55.8812 11.3812 55.9C11.6062 55.9187 11.7688 56.1188 11.75 56.3438C11.6687 57.2438 11.6313 58.1625 11.6313 59.075V59.425C11.6313 59.65 11.4563 59.8312 11.2313 59.8375L11.2188 59.825ZM72.125 56.275C71.9188 56.275 71.7438 56.1187 71.7188 55.9125C71.6125 54.8937 71.4563 53.8687 71.2438 52.8687C71.2 52.65 71.3375 52.4312 71.5563 52.3875C71.7812 52.3438 71.9937 52.4813 72.0375 52.7C72.25 53.7312 72.4187 54.7812 72.525 55.825C72.55 56.05 72.3875 56.25 72.1625 56.2687C72.15 56.2687 72.1313 56.2687 72.1188 56.2687L72.125 56.275ZM11.6625 54.2312C11.6625 54.2312 11.6187 54.2312 11.5938 54.2312C11.375 54.1937 11.225 53.9812 11.2625 53.7625C11.4438 52.725 11.675 51.6875 11.9625 50.675C12.025 50.4625 12.2438 50.3312 12.4625 50.3937C12.6813 50.4562 12.8063 50.6812 12.7438 50.8937C12.4688 51.875 12.2375 52.8875 12.0625 53.9C12.0312 54.1 11.8563 54.2375 11.6625 54.2375V54.2312ZM71.0312 50.7687C70.8563 50.7687 70.6938 50.6563 70.6438 50.475C70.3563 49.5 70.0063 48.5188 69.6188 47.5688C69.5313 47.3625 69.6313 47.125 69.8375 47.0375C70.0438 46.95 70.2812 47.05 70.3688 47.2563C70.7687 48.2313 71.125 49.2375 71.425 50.2375C71.4875 50.45 71.3688 50.6812 71.15 50.7437C71.1125 50.7562 71.075 50.7625 71.0375 50.7625L71.0312 50.7687ZM13.125 48.8125C13.075 48.8125 13.0313 48.8062 12.9813 48.7875C12.7688 48.7062 12.6625 48.475 12.7438 48.2625C13.1125 47.2812 13.5312 46.3 14 45.3563C14.1 45.1562 14.3438 45.075 14.5438 45.175C14.7438 45.275 14.825 45.5188 14.725 45.7188C14.2688 46.6375 13.8563 47.5938 13.5 48.55C13.4375 48.7125 13.2812 48.8125 13.1187 48.8125H13.125ZM68.9375 45.5562C68.7875 45.5562 68.65 45.475 68.575 45.3375C68.1062 44.4312 67.5875 43.5312 67.0313 42.6687C66.9063 42.4812 66.9625 42.2312 67.15 42.1062C67.3375 41.9812 67.5938 42.0375 67.7125 42.225C68.2875 43.1062 68.8188 44.0312 69.3 44.9625C69.4 45.1625 69.325 45.4062 69.125 45.5125C69.0688 45.5438 69 45.5562 68.9375 45.5562ZM15.5625 43.75C15.4937 43.75 15.4187 43.7312 15.3562 43.6937C15.1625 43.575 15.1 43.3312 15.2188 43.1375C15.7625 42.2375 16.3563 41.3562 16.9875 40.5125C17.1188 40.3313 17.375 40.2937 17.5563 40.4312C17.7375 40.5687 17.775 40.8187 17.6375 41C17.025 41.8187 16.4438 42.6812 15.9188 43.5562C15.8438 43.6812 15.7062 43.75 15.5687 43.75H15.5625ZM65.925 40.8187C65.8063 40.8187 65.6812 40.7625 65.6062 40.6625C64.9812 39.8562 64.3062 39.0687 63.6 38.325C63.4437 38.1625 63.45 37.9062 63.6125 37.75C63.775 37.5937 64.0312 37.6 64.1875 37.7625C64.9125 38.525 65.6063 39.3375 66.2438 40.1625C66.3813 40.3375 66.35 40.5937 66.1688 40.7312C66.0938 40.7875 66.0062 40.8187 65.9187 40.8187H65.925ZM18.8875 39.225C18.7938 39.225 18.6938 39.1937 18.6188 39.125C18.45 38.975 18.4375 38.7188 18.5875 38.55C19.2875 37.7625 20.0312 37.0062 20.8063 36.2937C20.975 36.1438 21.2312 36.15 21.3813 36.3187C21.5312 36.4812 21.525 36.7437 21.3562 36.8937C20.6 37.5875 19.875 38.325 19.1938 39.0938C19.1125 39.1875 19 39.2312 18.8875 39.2312V39.225ZM62.1 36.7062C62.0062 36.7062 61.9063 36.675 61.8313 36.6C61.0688 35.9125 60.2563 35.2625 59.4313 34.6625C59.25 34.5312 59.2062 34.275 59.3438 34.0937C59.475 33.9125 59.7312 33.8688 59.9125 34.0062C60.7625 34.6188 61.5875 35.2875 62.375 35.9938C62.5438 36.1438 62.5563 36.4 62.4062 36.5687C62.325 36.6562 62.2125 36.7062 62.1063 36.7062H62.1ZM22.9813 35.3812C22.8625 35.3812 22.7437 35.325 22.6625 35.225C22.525 35.05 22.5562 34.7937 22.7312 34.6562C23.5625 34.0125 24.4313 33.4 25.325 32.8375C25.5188 32.7187 25.7688 32.775 25.8875 32.9688C26.0063 33.1562 25.95 33.4125 25.7563 33.5312C24.8875 34.075 24.0375 34.6687 23.2313 35.3C23.1563 35.3562 23.0688 35.3875 22.9813 35.3875V35.3812ZM57.5875 33.3625C57.5188 33.3625 57.4437 33.3437 57.375 33.3062C56.5 32.775 55.5875 32.2812 54.6625 31.8437C54.4625 31.75 54.375 31.5062 54.4688 31.3C54.5687 31.1 54.8125 31.0125 55.0125 31.1062C55.9625 31.5562 56.9 32.0625 57.8 32.6125C57.9938 32.7313 58.05 32.9812 57.9375 33.1687C57.8625 33.2937 57.725 33.3625 57.5875 33.3625ZM27.7063 32.35C27.5563 32.35 27.4188 32.2687 27.3438 32.1312C27.2375 31.9312 27.3188 31.6875 27.5187 31.5812C28.45 31.1 29.4188 30.6563 30.4 30.2688C30.6063 30.1875 30.8438 30.2875 30.925 30.5C31.0063 30.7125 30.9062 30.9437 30.6938 31.025C29.7438 31.4 28.8 31.8313 27.8875 32.3C27.825 32.3313 27.7625 32.3438 27.7 32.3438L27.7063 32.35ZM52.5375 30.9C52.4875 30.9 52.4437 30.8937 52.3937 30.875C51.4375 30.5125 50.45 30.2 49.4563 29.9375C49.2375 29.8812 49.1125 29.6562 49.1688 29.4437C49.225 29.225 49.45 29.1 49.6625 29.1562C50.6813 29.425 51.6938 29.75 52.675 30.1187C52.8875 30.2 52.9938 30.4312 52.9125 30.6437C52.85 30.8062 52.6937 30.9062 52.5312 30.9062L52.5375 30.9ZM32.9063 30.2375C32.7313 30.2375 32.5688 30.125 32.5187 29.95C32.4562 29.7375 32.575 29.5062 32.7875 29.4437C33.7938 29.1375 34.825 28.8875 35.8625 28.6875C36.0813 28.6437 36.2938 28.7875 36.3375 29.0125C36.3813 29.2312 36.2375 29.4438 36.0125 29.4875C35.0063 29.6813 34 29.9312 33.0188 30.225C32.9813 30.2375 32.9375 30.2437 32.9 30.2437L32.9063 30.2375ZM47.125 29.4125C47.125 29.4125 47.075 29.4125 47.0562 29.4125C46.05 29.2312 45.0187 29.1062 44 29.0312C43.775 29.0125 43.6063 28.8188 43.625 28.5938C43.6437 28.3687 43.8375 28.2062 44.0625 28.2187C45.1125 28.2937 46.1688 28.4312 47.2 28.6125C47.4188 28.65 47.5687 28.8625 47.5312 29.0812C47.4938 29.275 47.325 29.4187 47.1312 29.4187L47.125 29.4125ZM38.4125 29.1188C38.2062 29.1188 38.0313 28.9625 38.0063 28.7562C37.9813 28.5312 38.1437 28.3312 38.3687 28.3062C39.4125 28.1937 40.475 28.1312 41.525 28.125C41.75 28.125 41.9313 28.3062 41.9313 28.5312C41.9313 28.7562 41.75 28.9375 41.525 28.9375C40.5 28.9437 39.4625 29.0062 38.45 29.1125C38.4375 29.1125 38.4188 29.1125 38.4062 29.1125L38.4125 29.1188Z" + fill="#A1A3A7" + /> + <Path + d="M36.6939 73.4436C44.4915 73.4436 50.8127 67.1224 50.8127 59.3248C50.8127 51.5272 44.4915 45.2061 36.6939 45.2061C28.8964 45.2061 22.5752 51.5272 22.5752 59.3248C22.5752 67.1224 28.8964 73.4436 36.6939 73.4436Z" + fill={color} + /> + <Path + d="M36.6936 68.5312C41.778 68.5312 45.8998 64.4094 45.8998 59.3249C45.8998 54.2404 41.778 50.1187 36.6936 50.1187C31.6091 50.1187 27.4873 54.2404 27.4873 59.3249C27.4873 64.4094 31.6091 68.5312 36.6936 68.5312Z" + fill={color} + /> + <Path + d="M46.6936 68.5312C51.778 68.5312 55.8998 64.4094 55.8998 59.3249C55.8998 54.2404 51.778 50.1187 46.6936 50.1187C41.6091 50.1187 37.4873 54.2404 37.4873 59.3249C37.4873 64.4094 41.6091 68.5312 46.6936 68.5312Z" + fill={color} + /> + <Path + d="M54.5625 55.8062L50.5372 49.0549C50.4245 48.8658 50.2206 48.75 50.0004 48.75H33.4996C33.2794 48.75 33.0755 48.8658 32.9628 49.0549L28.9375 55.8062M54.5625 55.8062L42.0784 73.633C41.8274 73.9913 41.2955 73.9876 41.0495 73.6258L28.9375 55.8062M54.5625 55.8062H28.9375" + stroke="#A1A3A7" + stroke-width="1.25" + /> + </Svg> + ) +} + +export default EmptyNft diff --git a/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx index 628372895e..1efc768a63 100644 --- a/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx +++ b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx @@ -4,19 +4,27 @@ import { NoFunds } from './NoFunds' describe('NoFunds', () => { it('renders the empty token component', () => { - render(<NoFunds />) + render(<NoFunds fundsType={'token'} />) // Check for the main elements - expect(screen.getByText('Add funds to get started')).toBeTruthy() + expect(screen.getByText('Top up your balance')).toBeTruthy() expect( screen.getByText('Send funds to your Safe Account from another wallet by copying your address.'), ).toBeTruthy() }) it('renders the EmptyToken component', () => { - render(<NoFunds />) + render(<NoFunds fundsType={'token'} />) // Check if EmptyToken is rendered by looking for its container expect(screen.getByTestId('empty-token')).toBeTruthy() }) + + it('renders the empty NFT component', () => { + render(<NoFunds fundsType={'nft'} />) + + // Check for the main elements + expect(screen.getByText('No NFTs')).toBeTruthy() + expect(screen.getByText('This account has no NFTs yet.')).toBeTruthy() + }) }) diff --git a/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx index 1c5baad6e8..9789b9fee0 100644 --- a/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx +++ b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx @@ -1,14 +1,31 @@ import React from 'react' -import { H3, Text, View } from 'tamagui' +import { H4, Text, View } from 'tamagui' import EmptyToken from './EmptyToken' +import EmptyNft from './EmptyNFT' -export function NoFunds() { +const texts = { + token: { + icon: <EmptyToken />, + title: 'Top up your balance', + description: 'Send funds to your Safe Account from another wallet by copying your address.', + }, + nft: { + icon: <EmptyNft />, + title: 'No NFTs', + description: 'This account has no NFTs yet.', + }, +} + +type Props = { + fundsType: 'token' | 'nft' +} +export const NoFunds = ({ fundsType }: Props) => { return ( - <View testID="empty-token" alignItems="center" gap="$4"> - <EmptyToken /> - <H3 fontWeight={600}>Add funds to get started</H3> + <View testID="empty-token" alignItems="center" gap="$2"> + {texts[fundsType].icon} + <H4 fontWeight={600}>{texts[fundsType].title}</H4> <Text textAlign="center" color="$colorSecondary" width="70%" fontSize="$4"> - Send funds to your Safe Account from another wallet by copying your address. + {texts[fundsType].description} </Text> </View> ) diff --git a/apps/mobile/src/features/Assets/components/ReadOnly/ReadOnly.container.test.tsx b/apps/mobile/src/features/Assets/components/ReadOnly/ReadOnly.container.test.tsx new file mode 100644 index 0000000000..12cab028ed --- /dev/null +++ b/apps/mobile/src/features/Assets/components/ReadOnly/ReadOnly.container.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@/src/tests/test-utils' +import { ReadOnlyContainer } from './ReadOnly.container' +import { RootState } from '@/src/store' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' + +describe('ReadOnlyContainer', () => { + const mockSafeAddress = '0x123' + const mockSigners: Record<string, AddressInfo> = { + '0x456': { value: '0x456', name: 'Signer 1' }, + '0x789': { value: '0x789', name: 'Signer 2' }, + } + + const mockSafeInfo: SafeOverview = { + address: { value: mockSafeAddress }, + chainId: '1', + owners: [{ value: '0x456' }, { value: '0x789' }], + threshold: 2, + fiatTotal: '0', + queued: 0, + } + + const createInitialState = (signers: Record<string, AddressInfo>, safeInfo: SafeOverview): Partial<RootState> => ({ + safes: { + [mockSafeAddress]: { + '1': safeInfo, + }, + }, + signers: signers, + activeSafe: { + address: mockSafeAddress, + chainId: '1', + }, + }) + + it('should render read-only message when there are no signers', () => { + const initialState = createInitialState( + {}, + { + ...mockSafeInfo, + }, + ) + render(<ReadOnlyContainer />, { initialStore: initialState }) + + expect(screen.getByText('This is a read-only account')).toBeTruthy() + }) + + it("should render read-only message when signers don't match owners", () => { + const initialState = createInitialState(mockSigners, { + ...mockSafeInfo, + owners: [{ value: '0x345' }], + }) + render(<ReadOnlyContainer />, { initialStore: initialState }) + + expect(screen.getByText('This is a read-only account')).toBeTruthy() + }) + + it('should not render read-only message when there are signers', () => { + const initialState = createInitialState(mockSigners, mockSafeInfo) + render(<ReadOnlyContainer />, { initialStore: initialState }) + + expect(screen.queryByText('This is a read-only account')).toBeNull() + }) +}) diff --git a/apps/mobile/src/features/Assets/components/ReadOnly/ReadOnly.container.tsx b/apps/mobile/src/features/Assets/components/ReadOnly/ReadOnly.container.tsx new file mode 100644 index 0000000000..17e54955ed --- /dev/null +++ b/apps/mobile/src/features/Assets/components/ReadOnly/ReadOnly.container.tsx @@ -0,0 +1,18 @@ +import { useSelector } from 'react-redux' +import { RootState } from '@/src/store' +import { selectSafeInfo } from '@/src/store/safesSlice' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { selectSigners } from '@/src/store/signersSlice' +import { getSafeSigners } from '@/src/utils/signer' +import { ReadOnly, ReadOnlyProps } from './ReadOnly' + +export const ReadOnlyContainer = ({ marginBottom, marginTop }: Omit<ReadOnlyProps, 'signers'>) => { + const activeSafe = useDefinedActiveSafe() + const safeInfo = useSelector((state: RootState) => selectSafeInfo(state, activeSafe?.address)) + const signers = useSelector(selectSigners) + + const chainSafe = safeInfo ? safeInfo[activeSafe.chainId] : undefined + const safeSigners = chainSafe ? getSafeSigners(chainSafe, signers) : [] + + return <ReadOnly signers={safeSigners} marginBottom={marginBottom} marginTop={marginTop} /> +} diff --git a/apps/mobile/src/features/Assets/components/ReadOnly/ReadOnly.tsx b/apps/mobile/src/features/Assets/components/ReadOnly/ReadOnly.tsx new file mode 100644 index 0000000000..a97718e879 --- /dev/null +++ b/apps/mobile/src/features/Assets/components/ReadOnly/ReadOnly.tsx @@ -0,0 +1,34 @@ +import { Container } from '@/src/components/Container' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { DimensionValue } from 'react-native' +import { View, Text } from 'tamagui' + +export interface ReadOnlyProps { + signers: string[] + marginBottom?: DimensionValue | string + marginTop?: DimensionValue | string +} + +export const ReadOnly = ({ signers, marginBottom = '$6', marginTop = '$2' }: ReadOnlyProps) => { + if (signers.length === 0) { + return ( + <Container + marginBottom={marginBottom} + marginTop={marginTop} + padding="$2" + justifyContent="center" + alignItems="center" + backgroundColor="$backgroundSecondary" + > + <View flexDirection="row" alignItems="center" gap="$2"> + <SafeFontIcon name="eye-n" size={16} color="$colorLight" /> + <Text color="$colorLight" fontSize="$4" fontWeight={600} lineHeight={20} letterSpacing={-0.1}> + This is a read-only account + </Text> + </View> + </Container> + ) + } + + return null +} diff --git a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx index d2892830ce..714b93f2ed 100644 --- a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx +++ b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx @@ -48,7 +48,7 @@ describe('TokensContainer', () => { // Then check for content const ethText = await screen.findByText('Ethereum') const ethAmount = await screen.findByText('1 ETH') - const ethValue = await screen.findByText('$2000') + const ethValue = await screen.findByText('$ 2,000.00') expect(ethText).toBeTruthy() expect(ethAmount).toBeTruthy() diff --git a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx index 2f3a671f35..a1594144a3 100644 --- a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx +++ b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx @@ -1,50 +1,76 @@ import React from 'react' import { ListRenderItem } from 'react-native' import { useSelector } from 'react-redux' -import { Text } from 'tamagui' +import { getTokenValue, Text } from 'tamagui' import { SafeTab } from '@/src/components/SafeTab' import { AssetsCard } from '@/src/components/transactions-list/Card/AssetsCard' import { POLLING_INTERVAL } from '@/src/config/constants' import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { Balance, useBalancesGetBalancesV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/balances' -import { formatValue } from '@/src/utils/formatters' - import { Fallback } from '../Fallback' - +import { skipToken } from '@reduxjs/toolkit/query' +import { formatCurrency, formatCurrencyPrecise } from '@safe-global/utils/utils/formatNumber' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' +import { shouldDisplayPreciseBalance } from '@/src/utils/balance' +import { NoFunds } from '@/src/features/Assets/components/NoFunds' +import { AssetError } from '@/src/features/Assets/Assets.error' +import { useAppSelector } from '@/src/store/hooks' +import { selectCurrency } from '@/src/store/settingsSlice' export function TokensContainer() { const activeSafe = useSelector(selectActiveSafe) - - const { data, isFetching, error } = useBalancesGetBalancesV1Query( - { - chainId: activeSafe.chainId, - fiatCode: 'USD', - safeAddress: activeSafe.address, - excludeSpam: false, - trusted: true, - }, + const currency = useAppSelector(selectCurrency) + const { data, isFetching, error, isLoading, refetch } = useBalancesGetBalancesV1Query( + !activeSafe + ? skipToken + : { + chainId: activeSafe.chainId, + fiatCode: currency, + safeAddress: activeSafe.address, + trusted: true, + }, { pollingInterval: POLLING_INTERVAL, }, ) - const renderItem: ListRenderItem<Balance> = React.useCallback(({ item }) => { + const renderItem: ListRenderItem<Balance> = React.useCallback( + ({ item }) => { + const fiatBalance = item.fiatBalance + return ( + <AssetsCard + name={item.tokenInfo.name} + logoUri={item.tokenInfo.logoUri} + description={`${formatVisualAmount(item.balance, item.tokenInfo.decimals as number)} ${ + item.tokenInfo.symbol + }`} + rightNode={ + <Text fontSize="$4" fontWeight={600} color="$color"> + {shouldDisplayPreciseBalance(fiatBalance, 7) + ? formatCurrencyPrecise(fiatBalance, currency) + : formatCurrency(fiatBalance, currency)} + </Text> + } + /> + ) + }, + [currency], + ) + + if (error) { return ( - <AssetsCard - name={item.tokenInfo.name} - logoUri={item.tokenInfo.logoUri} - description={`${formatValue(item.balance, item.tokenInfo.decimals as number)} ${item.tokenInfo.symbol}`} - rightNode={ - <Text fontSize="$4" fontWeight={400} color="$color"> - ${item.fiatBalance} - </Text> - } - /> + <Fallback loading={isFetching}> + <AssetError assetType={'token'} onRetry={() => refetch()} /> + </Fallback> ) - }, []) + } - if (isFetching || !data?.items.length || error) { - return <Fallback loading={isFetching} hasError={!!error} /> + if (isLoading || !data?.items.length) { + return ( + <Fallback loading={isFetching}> + <NoFunds fundsType={'token'} /> + </Fallback> + ) } return ( @@ -52,6 +78,7 @@ export function TokensContainer() { data={data?.items} renderItem={renderItem} keyExtractor={(item, index): string => item.tokenInfo.name + index} + style={{ marginTop: getTokenValue('$2') }} /> ) } diff --git a/apps/mobile/src/features/ChangeSignerSheet/ChangeSignerSheet.container.tsx b/apps/mobile/src/features/ChangeSignerSheet/ChangeSignerSheet.container.tsx new file mode 100644 index 0000000000..596ee2325c --- /dev/null +++ b/apps/mobile/src/features/ChangeSignerSheet/ChangeSignerSheet.container.tsx @@ -0,0 +1,94 @@ +import { SafeBottomSheet } from '@/src/components/SafeBottomSheet' +import React, { useMemo } from 'react' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { RootState } from '@/src/store' +import { SignersCard } from '@/src/components/transactions-list/Card/SignersCard' +import { Text, View } from 'tamagui' +import { Address } from 'blo' +import { SignerInfo } from '@/src/types/address' +import { selectActiveSigner, setActiveSigner } from '@/src/store/activeSignerSlice' +import { selectSigners } from '@/src/store/signersSlice' +import { useGetBalancesQuery } from '@/src/store/signersBalance' +import { selectChainById } from '@/src/store/chains' +import { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { MultisigExecutionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { formatValue } from '@/src/utils/formatters' +import { useTransactionsGetTransactionByIdV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { RouteProp, useRoute } from '@react-navigation/native' +import { extractAppSigners } from '../ConfirmTx/utils' +import { ContactDisplayNameContainer } from '../AddressBook' + +export const ChangeSignerSheetContainer = () => { + const dispatch = useAppDispatch() + const activeSafe = useDefinedActiveSafe() + const signers = useAppSelector(selectSigners) + const activeChain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + const activeSigner = useAppSelector((state: RootState) => selectActiveSigner(state, activeSafe.address)) + + const txId = useRoute<RouteProp<{ params: { txId: string } }>>().params.txId + const { data: txDetails, isLoading: isLoadingTxDetails } = useTransactionsGetTransactionByIdV1Query({ + chainId: activeSafe.chainId, + id: txId, + }) + + const detailedExecutionInfo = txDetails?.detailedExecutionInfo as MultisigExecutionDetails + + const storedSigners = useMemo(() => extractAppSigners(signers, detailedExecutionInfo), [txDetails, signers]) + + const { data, isLoading } = useGetBalancesQuery({ + addresses: storedSigners?.map((item) => item.value) || [], + chain: activeChain as ChainInfo, + }) + + const items = useMemo(() => { + if (!data) { + return [] + } + + const availableSigners = storedSigners.filter((signer) => { + return !detailedExecutionInfo?.confirmations?.some((confirmation) => confirmation.signer.value === signer.value) + }) + + return availableSigners?.map((item) => ({ + ...item, + balance: data[item.value], + })) + }, [data, storedSigners, detailedExecutionInfo]) + + const onSignerPress = (signer: SignerInfo, onClose: () => void) => () => { + if (activeSigner.value !== signer.value) { + dispatch(setActiveSigner({ safeAddress: activeSafe.address, signer })) + } + + onClose() + } + + return ( + <SafeBottomSheet + title="Select signer" + items={items} + loading={isLoading || isLoadingTxDetails} + keyExtractor={({ item }) => item.value} + renderItem={({ item, onClose }) => ( + <View + width="100%" + borderRadius={'$4'} + backgroundColor={activeSigner?.value === item.value ? '$backgroundSecondary' : 'transparent'} + > + <SignersCard + transparent + onPress={onSignerPress(item, onClose)} + name={<ContactDisplayNameContainer address={item.value as Address} />} + address={item.value as Address} + rightNode={ + <Text> + {formatValue(item.balance, activeChain.nativeCurrency.decimals)} {activeChain.nativeCurrency.symbol} + </Text> + } + /> + </View> + )} + /> + ) +} diff --git a/apps/mobile/src/features/ChangeSignerSheet/index.ts b/apps/mobile/src/features/ChangeSignerSheet/index.ts new file mode 100644 index 0000000000..12de7d7405 --- /dev/null +++ b/apps/mobile/src/features/ChangeSignerSheet/index.ts @@ -0,0 +1 @@ +export { ChangeSignerSheetContainer } from './ChangeSignerSheet.container' diff --git a/apps/mobile/src/features/ConfirmTx/ConfirmTx.container.tsx b/apps/mobile/src/features/ConfirmTx/ConfirmTx.container.tsx new file mode 100644 index 0000000000..44bc9cafa9 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/ConfirmTx.container.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { ScrollView, View } from 'tamagui' +import { useScrollableHeader } from '@/src/navigation/useScrollableHeader' +import { NavBarTitle } from '@/src/components/Title' +import { TransactionInfo } from './components/TransactionInfo' +import { + MultisigExecutionDetails, + useTransactionsGetTransactionByIdV1Query, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { RouteProp, useRoute } from '@react-navigation/native' +import { ConfirmationView } from './components/ConfirmationView' +import { LoadingTx } from './components/LoadingTx' +import { useTxSigner } from './hooks/useTxSigner' +import { Alert } from '@/src/components/Alert' +import { ConfirmTxForm } from './components/ConfirmTxForm' + +function ConfirmTxContainer() { + const activeSafe = useDefinedActiveSafe() + const txId = useRoute<RouteProp<{ params: { txId: string } }>>().params.txId + + const { handleScroll } = useScrollableHeader({ + children: <NavBarTitle paddingRight={5}>Confirm transaction</NavBarTitle>, + alwaysVisible: true, + }) + + const { data, isFetching, isError } = useTransactionsGetTransactionByIdV1Query({ + chainId: activeSafe.chainId, + id: txId, + }) + + const detailedExecutionInfo = data?.detailedExecutionInfo as MultisigExecutionDetails + const { activeSigner, hasSigned, canSign } = useTxSigner(detailedExecutionInfo) + const hasEnoughConfirmations = + detailedExecutionInfo?.confirmationsRequired <= detailedExecutionInfo?.confirmations?.length + + if (isFetching || !data) { + return <LoadingTx /> + } + + if (isError) { + return ( + <View margin="$4"> + <Alert type="error" message="Error fetching transaction details" /> + </View> + ) + } + + const isExpired = 'status' in data.txInfo && data.txInfo.status === 'expired' + + return ( + <View flex={1}> + <ScrollView onScroll={handleScroll}> + <View paddingHorizontal="$4"> + <ConfirmationView txDetails={data} /> + </View> + <TransactionInfo txId={txId} detailedExecutionInfo={detailedExecutionInfo} txDetails={data} /> + </ScrollView> + + <View paddingTop="$1"> + <ConfirmTxForm + hasSigned={Boolean(hasSigned)} + hasEnoughConfirmations={hasEnoughConfirmations} + activeSigner={activeSigner} + canSign={canSign} + isExpired={isExpired} + txId={txId} + /> + </View> + </View> + ) +} + +export default ConfirmTxContainer diff --git a/apps/mobile/src/features/ConfirmTx/components/CanNotSign/CanNotSign.test.tsx b/apps/mobile/src/features/ConfirmTx/components/CanNotSign/CanNotSign.test.tsx new file mode 100644 index 0000000000..1bbbf02e6a --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/CanNotSign/CanNotSign.test.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { CanNotSign } from './CanNotSign' +import { SelectSigner } from '@/src/components/SelectSigner' + +// Mock the SelectSigner component +jest.mock('@/src/components/SelectSigner', () => ({ + SelectSigner: jest.fn(() => null), +})) + +describe('CanNotSign', () => { + const defaultProps = { + address: '0x123' as const, + txId: 'tx123', + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders the correct message', () => { + const { getByText } = render(<CanNotSign {...defaultProps} />) + expect(getByText('Only signers of this safe can sign this transaction')).toBeTruthy() + }) + + it('renders SelectSigner when address is provided', () => { + render(<CanNotSign {...defaultProps} />) + + expect(SelectSigner).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0x123', + txId: 'tx123', + }), + undefined, + ) + }) + + it('does not render SelectSigner when address is undefined', () => { + render(<CanNotSign {...defaultProps} address={undefined} />) + + expect(SelectSigner).not.toHaveBeenCalled() + }) + + it('matches snapshot with address', () => { + const { toJSON } = render(<CanNotSign {...defaultProps} />) + expect(toJSON()).toMatchSnapshot() + }) + + it('matches snapshot without address', () => { + const { toJSON } = render(<CanNotSign {...defaultProps} address={undefined} />) + expect(toJSON()).toMatchSnapshot() + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/CanNotSign/CanNotSign.tsx b/apps/mobile/src/features/ConfirmTx/components/CanNotSign/CanNotSign.tsx new file mode 100644 index 0000000000..fdcac408ee --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/CanNotSign/CanNotSign.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { Text, YStack } from 'tamagui' +import { SelectSigner } from '@/src/components/SelectSigner' +import { Address } from '@/src/types/address' + +interface CanNotSignProps { + address: Address | undefined + txId: string +} + +export function CanNotSign({ address, txId }: CanNotSignProps) { + return ( + <YStack gap="$4" padding="$2" alignItems="center" justifyContent="center" testID="can-not-sign-container"> + {address && <SelectSigner address={address} txId={txId} />} + <Text fontSize="$4" fontWeight={400} width="70%" textAlign="center" color="$textSecondaryLight"> + Only signers of this safe can sign this transaction + </Text> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/CanNotSign/__snapshots__/CanNotSign.test.tsx.snap b/apps/mobile/src/features/ConfirmTx/components/CanNotSign/__snapshots__/CanNotSign.test.tsx.snap new file mode 100644 index 0000000000..c54c569adb --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/CanNotSign/__snapshots__/CanNotSign.test.tsx.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CanNotSign matches snapshot with address 1`] = ` +<View> + <View + style={ + { + "alignItems": "center", + "flexDirection": "column", + "gap": 16, + "justifyContent": "center", + "paddingBottom": 8, + "paddingLeft": 8, + "paddingRight": 8, + "paddingTop": 8, + } + } + testID="can-not-sign-container" + > + <Text + style={ + { + "color": "#A1A3A7", + "fontFamily": "DM Sans", + "fontSize": 14, + "textAlign": "center", + "width": "70%", + } + } + suppressHighlighting={true} + > + Only signers of this safe can sign this transaction + </Text> + </View> +</View> +`; + +exports[`CanNotSign matches snapshot without address 1`] = ` +<View> + <View + style={ + { + "alignItems": "center", + "flexDirection": "column", + "gap": 16, + "justifyContent": "center", + "paddingBottom": 8, + "paddingLeft": 8, + "paddingRight": 8, + "paddingTop": 8, + } + } + testID="can-not-sign-container" + > + <Text + style={ + { + "color": "#A1A3A7", + "fontFamily": "DM Sans", + "fontSize": 14, + "textAlign": "center", + "width": "70%", + } + } + suppressHighlighting={true} + > + Only signers of this safe can sign this transaction + </Text> + </View> +</View> +`; diff --git a/apps/mobile/src/features/ConfirmTx/components/CanNotSign/index.ts b/apps/mobile/src/features/ConfirmTx/components/CanNotSign/index.ts new file mode 100644 index 0000000000..f025d4e089 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/CanNotSign/index.ts @@ -0,0 +1 @@ +export { CanNotSign } from './CanNotSign' diff --git a/apps/mobile/src/features/ConfirmTx/components/ConfirmTxForm/ConfirmTxForm.test.tsx b/apps/mobile/src/features/ConfirmTx/components/ConfirmTxForm/ConfirmTxForm.test.tsx new file mode 100644 index 0000000000..7b5f659351 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ConfirmTxForm/ConfirmTxForm.test.tsx @@ -0,0 +1,148 @@ +import React from 'react' +import { render } from '@testing-library/react-native' +import { View, Text } from 'react-native' +import { ConfirmTxForm } from './ConfirmTxForm' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { AlreadySigned } from '../confirmation-views/AlreadySigned' +import { CanNotSign } from '../CanNotSign' +import { ExecuteForm } from '../ExecuteForm' +import { SignForm } from '../SignForm' + +// Mock the hooks and components +jest.mock('@/src/store/hooks/activeSafe') +jest.mock('../confirmation-views/AlreadySigned') +jest.mock('../CanNotSign') +jest.mock('../ExecuteForm') +jest.mock('../SignForm') + +describe('ConfirmTxForm', () => { + const mockActiveSafe = { + address: '0x123', + chainId: '1', + } + + beforeEach(() => { + // Reset all mocks before each test + jest.clearAllMocks() + + // Mock the useDefinedActiveSafe hook + ;(useDefinedActiveSafe as jest.Mock).mockReturnValue(mockActiveSafe) + + // Mock the components to return React Native components + ;(AlreadySigned as jest.Mock).mockReturnValue( + <View> + <Text>AlreadySigned</Text> + </View>, + ) + ;(CanNotSign as jest.Mock).mockReturnValue( + <View> + <Text>CanNotSign</Text> + </View>, + ) + ;(ExecuteForm as jest.Mock).mockReturnValue( + <View> + <Text>ExecuteForm</Text> + </View>, + ) + ;(SignForm as jest.Mock).mockReturnValue( + <View> + <Text>SignForm</Text> + </View>, + ) + }) + + const defaultProps = { + hasEnoughConfirmations: false, + activeSigner: { value: '0x456' }, + isExpired: false, + txId: 'tx123', + hasSigned: false, + canSign: true, + } + + it('renders AlreadySigned when hasSigned is true', () => { + const props = { ...defaultProps, hasSigned: true } + const { getByText } = render(<ConfirmTxForm {...props} />) + + expect(getByText('AlreadySigned')).toBeTruthy() + expect(AlreadySigned).toHaveBeenCalledWith( + expect.objectContaining({ + txId: 'tx123', + safeAddress: '0x123', + chainId: '1', + }), + undefined, + ) + }) + + it('renders CanNotSign when canSign is false', () => { + const props = { ...defaultProps, canSign: false } + const { getByText } = render(<ConfirmTxForm {...props} />) + + expect(getByText('CanNotSign')).toBeTruthy() + expect(CanNotSign).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0x456', + txId: 'tx123', + }), + undefined, + ) + }) + + it('renders ExecuteForm when hasEnoughConfirmations is true', () => { + const props = { ...defaultProps, hasEnoughConfirmations: true } + const { getByText } = render(<ConfirmTxForm {...props} />) + + expect(getByText('ExecuteForm')).toBeTruthy() + expect(ExecuteForm).toHaveBeenCalledWith( + expect.objectContaining({ + safeAddress: '0x123', + chainId: '1', + }), + undefined, + ) + }) + + it('renders SignForm when activeSigner exists and not expired', () => { + const props = { ...defaultProps } + const { getByText } = render(<ConfirmTxForm {...props} />) + + expect(getByText('SignForm')).toBeTruthy() + expect(SignForm).toHaveBeenCalledWith( + expect.objectContaining({ + txId: 'tx123', + address: '0x456', + }), + undefined, + ) + }) + + it('renders null when no conditions are met', () => { + const props = { + ...defaultProps, + activeSigner: undefined, + isExpired: true, + } + const { toJSON } = render(<ConfirmTxForm {...props} />) + + expect(toJSON()).toBeNull() + }) + + it('handles undefined activeSigner in CanNotSign', () => { + const props = { + ...defaultProps, + canSign: false, + activeSigner: undefined, + } + const { getByText } = render(<ConfirmTxForm {...props} />) + + expect(getByText('CanNotSign')).toBeTruthy() + expect(CanNotSign).toHaveBeenCalledWith( + expect.objectContaining({ + address: undefined, + txId: 'tx123', + }), + undefined, + ) + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/ConfirmTxForm/ConfirmTxForm.tsx b/apps/mobile/src/features/ConfirmTx/components/ConfirmTxForm/ConfirmTxForm.tsx new file mode 100644 index 0000000000..5b5e210b0b --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ConfirmTxForm/ConfirmTxForm.tsx @@ -0,0 +1,51 @@ +import { Address, SignerInfo } from '@/src/types/address' +import { SignForm } from '../SignForm' +import React from 'react' +import { ExecuteForm } from '../ExecuteForm' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { AlreadySigned } from '../confirmation-views/AlreadySigned' +import { CanNotSign } from '../CanNotSign' +interface ConfirmTxFormProps { + hasEnoughConfirmations: boolean + activeSigner?: SignerInfo | undefined + isExpired: boolean + txId: string + hasSigned: boolean + canSign: boolean +} + +export function ConfirmTxForm({ + hasEnoughConfirmations, + activeSigner, + isExpired, + txId, + hasSigned, + canSign, +}: ConfirmTxFormProps) { + const activeSafe = useDefinedActiveSafe() + + if (hasSigned) { + return ( + <AlreadySigned + hasEnoughConfirmations={hasEnoughConfirmations} + txId={txId} + safeAddress={activeSafe.address} + chainId={activeSafe.chainId} + /> + ) + } + + if (!canSign) { + return <CanNotSign address={activeSigner?.value as Address | undefined} txId={txId} /> + } + + if (hasEnoughConfirmations) { + return <ExecuteForm safeAddress={activeSafe.address} chainId={activeSafe.chainId} /> + } + + if (activeSigner && !isExpired) { + return <SignForm txId={txId} address={activeSigner?.value as Address} /> + } + + return null +} diff --git a/apps/mobile/src/features/ConfirmTx/components/ConfirmTxForm/index.ts b/apps/mobile/src/features/ConfirmTx/components/ConfirmTxForm/index.ts new file mode 100644 index 0000000000..56642b4a0a --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ConfirmTxForm/index.ts @@ -0,0 +1 @@ +export { ConfirmTxForm } from './ConfirmTxForm' diff --git a/apps/mobile/src/features/ConfirmTx/components/ConfirmationView/ConfirmationView.tsx b/apps/mobile/src/features/ConfirmTx/components/ConfirmationView/ConfirmationView.tsx new file mode 100644 index 0000000000..17cbe9f928 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ConfirmationView/ConfirmationView.tsx @@ -0,0 +1,168 @@ +import React from 'react' +import { + CustomTransactionInfo, + MultisigExecutionDetails, + SettingsChangeTransaction, + TransactionData, + TransactionDetails, + TransferTransactionInfo, + VaultDepositTransactionInfo, + VaultRedeemTransactionInfo, + NativeStakingDepositTransactionInfo, + NativeStakingValidatorsExitTransactionInfo, + NativeStakingWithdrawTransactionInfo, + BridgeAndSwapTransactionInfo, + SwapTransactionInfo, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TokenTransfer } from '../confirmation-views/TokenTransfer' +import { AddSigner } from '../confirmation-views/AddSigner' +import { ETxType } from '@/src/types/txType' +import { getTransactionType } from '@/src/utils/transactions' +import { Contract } from '../confirmation-views/Contract' +import { SendNFT } from '../confirmation-views/SendNFT' +import { SwapOrder } from '../confirmation-views/SwapOrder' +import { OrderTransactionInfo } from '@safe-global/store/gateway/types' +import { RemoveSigner } from '../confirmation-views/RemoveSigner' +import { GenericView } from '../confirmation-views/GenericView' +import { NormalizedSettingsChangeTransaction } from './types' +import { VaultDeposit } from '@/src/features/ConfirmTx/components/confirmation-views/VaultDeposit' +import { VaultRedeem } from '../confirmation-views/VaultRedeem' +import { CancelTx } from '@/src/features/ConfirmTx/components/confirmation-views/CancelTx' +import { StakingDeposit, StakingWithdrawRequest, StakingExit } from '../confirmation-views/Stake' +import { BridgeTransaction } from '../confirmation-views/BridgeTransaction' +import { LifiSwapTransaction } from '../confirmation-views/LifiSwapTransaction' + +interface ConfirmationViewProps { + txDetails: TransactionDetails +} + +export function ConfirmationView({ txDetails }: ConfirmationViewProps) { + const confirmationViewType = getTransactionType({ txInfo: txDetails.txInfo }) + + switch (confirmationViewType) { + case ETxType.TOKEN_TRANSFER: + return ( + <TokenTransfer + txId={txDetails.txId} + executedAt={txDetails.executedAt || 0} + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txInfo={txDetails.txInfo as TransferTransactionInfo} + /> + ) + case ETxType.NFT_TRANSFER: + return ( + <SendNFT + txId={txDetails.txId} + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txInfo={txDetails.txInfo as TransferTransactionInfo} + /> + ) + case ETxType.ADD_SIGNER: + return ( + <AddSigner + txId={txDetails.txId} + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txInfo={txDetails.txInfo as NormalizedSettingsChangeTransaction} + /> + ) + case ETxType.REMOVE_SIGNER: + return ( + <RemoveSigner + txId={txDetails.txId} + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txInfo={txDetails.txInfo as NormalizedSettingsChangeTransaction} + /> + ) + case ETxType.SWAP_ORDER: + return ( + <SwapOrder + txId={txDetails.txId} + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txInfo={txDetails.txInfo as OrderTransactionInfo} + decodedData={txDetails.txData?.dataDecoded} + /> + ) + case ETxType.CANCEL_TX: + return ( + <CancelTx + txId={txDetails.txId} + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txInfo={txDetails.txInfo as CustomTransactionInfo} + /> + ) + case ETxType.CONTRACT_INTERACTION: + return ( + <Contract + txId={txDetails.txId} + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txInfo={txDetails.txInfo as CustomTransactionInfo} + /> + ) + case ETxType.STAKE_DEPOSIT: + return ( + <StakingDeposit + txId={txDetails.txId} + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txInfo={txDetails.txInfo as NativeStakingDepositTransactionInfo} + /> + ) + case ETxType.VAULT_DEPOSIT: + return ( + <VaultDeposit + txId={txDetails.txId} + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txInfo={txDetails.txInfo as VaultDepositTransactionInfo} + decodedData={txDetails.txData?.dataDecoded} + /> + ) + case ETxType.VAULT_REDEEM: + return ( + <VaultRedeem + txId={txDetails.txId} + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txInfo={txDetails.txInfo as VaultRedeemTransactionInfo} + /> + ) + case ETxType.STAKE_WITHDRAW_REQUEST: + return ( + <StakingWithdrawRequest + txId={txDetails.txId} + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txInfo={txDetails.txInfo as NativeStakingValidatorsExitTransactionInfo} + /> + ) + case ETxType.STAKE_EXIT: + return ( + <StakingExit + txId={txDetails.txId} + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txInfo={txDetails.txInfo as NativeStakingWithdrawTransactionInfo} + /> + ) + case ETxType.BRIDGE_ORDER: + return ( + <BridgeTransaction + txId={txDetails.txId} + txInfo={txDetails.txInfo as BridgeAndSwapTransactionInfo} + decodedData={txDetails.txData?.dataDecoded} + /> + ) + case ETxType.LIFI_SWAP: + return ( + <LifiSwapTransaction + txId={txDetails.txId} + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txInfo={txDetails.txInfo as SwapTransactionInfo} + /> + ) + default: + return ( + <GenericView + executionInfo={txDetails.detailedExecutionInfo as MultisigExecutionDetails} + txId={txDetails.txId} + txInfo={txDetails.txInfo as SettingsChangeTransaction} + txData={txDetails.txData as TransactionData} + /> + ) + } +} diff --git a/apps/mobile/src/features/ConfirmTx/components/ConfirmationView/index.ts b/apps/mobile/src/features/ConfirmTx/components/ConfirmationView/index.ts new file mode 100644 index 0000000000..77fff56eb5 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ConfirmationView/index.ts @@ -0,0 +1 @@ +export { ConfirmationView } from './ConfirmationView' diff --git a/apps/mobile/src/features/ConfirmTx/components/ConfirmationView/types.ts b/apps/mobile/src/features/ConfirmTx/components/ConfirmationView/types.ts new file mode 100644 index 0000000000..33fce6a62a --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ConfirmationView/types.ts @@ -0,0 +1,10 @@ +import { SettingsChangeTransaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { Address } from '@/src/types/address' + +// TODO: fix it in the @safe-global/store/gateway/AUTO_GENERATED types +export type NormalizedSettingsChangeTransaction = SettingsChangeTransaction & { + settingsInfo: SettingsChangeTransaction['settingsInfo'] & { + owner: { value: Address; name: string } + threshold: number + } +} diff --git a/apps/mobile/src/features/ConfirmTx/components/ConfirmationsInfo/ConfirmationsInfo.tsx b/apps/mobile/src/features/ConfirmTx/components/ConfirmationsInfo/ConfirmationsInfo.tsx new file mode 100644 index 0000000000..4ffcd3b546 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ConfirmationsInfo/ConfirmationsInfo.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { Text, View } from 'tamagui' +import { Badge } from '@/src/components/Badge' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { SafeListItem } from '@/src/components/SafeListItem' +import { MultisigExecutionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useRouter } from 'expo-router' + +interface ConfirmationsInfoProps { + detailedExecutionInfo: MultisigExecutionDetails + txId: string +} + +export function ConfirmationsInfo({ detailedExecutionInfo, txId }: ConfirmationsInfoProps) { + const router = useRouter() + + const hasEnoughConfirmations = + detailedExecutionInfo?.confirmationsRequired === detailedExecutionInfo?.confirmations?.length + + const onConfirmationsPress = () => { + router.push({ + pathname: '/confirmations-sheet', + params: { txId }, + }) + } + + return ( + <SafeListItem + label="Confirmations" + onPress={onConfirmationsPress} + rightNode={ + <View alignItems="center" flexDirection="row" gap="$2"> + <Badge + circleProps={{ paddingHorizontal: 8, paddingVertical: 2 }} + circular={false} + content={ + <View alignItems="center" flexDirection="row" gap="$1"> + <SafeFontIcon size={12} name="owners" /> + + <Text fontWeight={600} color={'$color'} fontSize="$2" lineHeight={18}> + {detailedExecutionInfo?.confirmations?.length}/{detailedExecutionInfo?.confirmationsRequired} + </Text> + </View> + } + themeName={hasEnoughConfirmations ? 'badge_success_variant1' : 'badge_warning'} + /> + + <SafeFontIcon name="chevron-right" size={16} /> + </View> + } + /> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/ConfirmationsInfo/index.ts b/apps/mobile/src/features/ConfirmTx/components/ConfirmationsInfo/index.ts new file mode 100644 index 0000000000..a2991de314 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ConfirmationsInfo/index.ts @@ -0,0 +1 @@ +export { ConfirmationsInfo } from './ConfirmationsInfo' diff --git a/apps/mobile/src/features/ConfirmTx/components/ExecuteForm/ExecuteForm.tsx b/apps/mobile/src/features/ConfirmTx/components/ExecuteForm/ExecuteForm.tsx new file mode 100644 index 0000000000..05f0dae70e --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ExecuteForm/ExecuteForm.tsx @@ -0,0 +1,40 @@ +import { SafeButton } from '@/src/components/SafeButton' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { selectChainById } from '@/src/store/chains' +import { useAppSelector } from '@/src/store/hooks' +import { formatPrefixedAddress } from '@safe-global/utils/utils/addresses' +import React from 'react' +import { Anchor, Text, View, YStack } from 'tamagui' +interface ExecuteFormProps { + safeAddress: string + chainId: string +} + +export function ExecuteForm({ safeAddress, chainId }: ExecuteFormProps) { + const chain = useAppSelector((state) => selectChainById(state, chainId)) + + return ( + <YStack justifyContent="center" gap="$4" alignItems="center" paddingHorizontal={'$4'}> + <Text fontSize="$4" fontWeight={400} width="70%" textAlign="center" color="$textSecondaryLight"> + Transactions can be executed only in the web app at the moment. + </Text> + + <View display="flex" flexDirection="row" alignItems="center" justifyContent="center" gap="$2"> + <Anchor + href={`https://app.safe.global/home?safe=${formatPrefixedAddress(safeAddress, chain?.shortName)}`} + target={'_blank'} + > + Go to web app + </Anchor> + + <SafeFontIcon name="external-link" size={14} /> + </View> + + <View height={50} width="100%"> + <SafeButton height="100%" rounded fullscreen fontWeight={600} disabled> + Confirm + </SafeButton> + </View> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/ExecuteForm/index.ts b/apps/mobile/src/features/ConfirmTx/components/ExecuteForm/index.ts new file mode 100644 index 0000000000..b6a023c577 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ExecuteForm/index.ts @@ -0,0 +1 @@ +export { ExecuteForm } from './ExecuteForm' diff --git a/apps/mobile/src/features/ConfirmTx/components/ListTable/ListTable.tsx b/apps/mobile/src/features/ConfirmTx/components/ListTable/ListTable.tsx new file mode 100644 index 0000000000..2b00a5d356 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ListTable/ListTable.tsx @@ -0,0 +1,70 @@ +import { Container } from '@/src/components/Container' +import React from 'react' +import { Text, View } from 'tamagui' + +type BaseItem = { + direction?: 'row' | 'column' + alignItems?: 'center' | 'flex-start' +} + +type RenderRowItem = BaseItem & { + renderRow: () => React.ReactNode +} + +type LabelValueItem = BaseItem & { + label: string + value?: string + render?: () => React.ReactNode +} + +export type ListTableItem = RenderRowItem | LabelValueItem + +interface ListTableProps { + items: ListTableItem[] + children?: React.ReactNode + padding?: string + gap?: string +} + +const isRenderRowItem = (item: ListTableItem): item is RenderRowItem => { + return (item as RenderRowItem).renderRow !== undefined +} + +export const ListTable = ({ items, children, padding = '$4', gap = '$5' }: ListTableProps) => { + return ( + <Container padding={padding} gap={gap} borderRadius="$3"> + {items.map((item, index) => { + return ( + <View + key={index} + alignItems={item.alignItems || 'center'} + flexDirection={item.direction || 'row'} + justifyContent="space-between" + gap={'$2'} + flexWrap="wrap" + > + {isRenderRowItem(item) ? ( + item.renderRow() + ) : ( + <> + <Text color="$textSecondaryLight" fontSize="$4" flex={1}> + {item.label} + </Text> + + {item.render ? ( + item.render() + ) : ( + <Text fontSize="$4" flex={2} textAlign="right"> + {item.value} + </Text> + )} + </> + )} + </View> + ) + })} + + {children} + </Container> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/ListTable/index.ts b/apps/mobile/src/features/ConfirmTx/components/ListTable/index.ts new file mode 100644 index 0000000000..edeb658f6b --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ListTable/index.ts @@ -0,0 +1 @@ +export { ListTable, type ListTableItem } from './ListTable' diff --git a/apps/mobile/src/features/ConfirmTx/components/LoadingTx/LoadingTx.tsx b/apps/mobile/src/features/ConfirmTx/components/LoadingTx/LoadingTx.tsx new file mode 100644 index 0000000000..8988849dca --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/LoadingTx/LoadingTx.tsx @@ -0,0 +1,11 @@ +import { Loader } from '@/src/components/Loader' +import React from 'react' +import { View } from 'tamagui' + +export function LoadingTx() { + return ( + <View flex={1} width="100%" justifyContent="center" alignItems="center"> + <Loader size={64} color="#12FF80" /> + </View> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/LoadingTx/index.ts b/apps/mobile/src/features/ConfirmTx/components/LoadingTx/index.ts new file mode 100644 index 0000000000..7b18c040a5 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/LoadingTx/index.ts @@ -0,0 +1 @@ +export { LoadingTx } from './LoadingTx' diff --git a/apps/mobile/src/features/ConfirmTx/components/ParametersButton/ParametersButton.tsx b/apps/mobile/src/features/ConfirmTx/components/ParametersButton/ParametersButton.tsx new file mode 100644 index 0000000000..39d7dbed4b --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ParametersButton/ParametersButton.tsx @@ -0,0 +1,34 @@ +import { router } from 'expo-router' +import React from 'react' +import { Button, View } from 'tamagui' + +interface ParametersButtonProps { + txId: string +} + +export function ParametersButton({ txId }: ParametersButtonProps) { + const goToAdvancedDetails = () => { + router.push({ + pathname: '/transaction-parameters', + params: { txId }, + }) + } + + return ( + <View height="$10" alignItems="center"> + <Button + paddingHorizontal="$2" + height="$9" + borderRadius={8} + borderWidth={0} + backgroundColor="$borderLight" + fontWeight="700" + size="$4" + fullscreen + onPress={goToAdvancedDetails} + > + Transaction details + </Button> + </View> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/ParametersButton/index.ts b/apps/mobile/src/features/ConfirmTx/components/ParametersButton/index.ts new file mode 100644 index 0000000000..91cbdd568d --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ParametersButton/index.ts @@ -0,0 +1 @@ +export { ParametersButton } from './ParametersButton' diff --git a/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/ReviewAndConfirmContainer.tsx b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/ReviewAndConfirmContainer.tsx new file mode 100644 index 0000000000..b32075ff27 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/ReviewAndConfirmContainer.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { useLocalSearchParams } from 'expo-router' +import { useTransactionsGetTransactionByIdV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useAppSelector } from '@/src/store/hooks' +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { Loader } from '@/src/components/Loader' +import { Text, View } from 'tamagui' +import { ReviewAndConfirmView } from './ReviewAndConfirmView' +import { Address } from '@/src/types/address' + +export function ReviewAndConfirmContainer() { + const { txId, signerAddress } = useLocalSearchParams<{ + txId: string + signerAddress: Address + }>() + + const activeSafe = useAppSelector(selectActiveSafe) + + const { + data: txDetails, + isFetching: isLoading, + isError: error, + } = useTransactionsGetTransactionByIdV1Query( + { + chainId: activeSafe?.chainId || '', + id: txId || '', + }, + { skip: !txId || !activeSafe?.chainId }, + ) + + if (isLoading) { + return ( + <View flex={1} justifyContent="center" alignItems="center"> + <Loader /> + </View> + ) + } + + if (error || !txDetails) { + return ( + <View flex={1} justifyContent="center" alignItems="center"> + <Text>Error loading transaction details</Text> + </View> + ) + } + + return <ReviewAndConfirmView txDetails={txDetails} signerAddress={signerAddress} /> +} diff --git a/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/ReviewAndConfirmView.tsx b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/ReviewAndConfirmView.tsx new file mode 100644 index 0000000000..a213fd02d8 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/ReviewAndConfirmView.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { useTheme, View } from 'tamagui' +import { Tabs, MaterialTabBar } from 'react-native-collapsible-tab-view' +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ReviewHeader } from './ReviewHeader' +import { ReviewFooter } from './ReviewFooter' +import { DataTab } from './tabs/DataTab' +import { JSONTab } from './tabs/JSONTab' +import { Address } from '@/src/types/address' +import { useTheme as useCurrentTheme } from '@/src/theme/hooks/useTheme' + +interface ReviewAndConfirmViewProps { + txDetails: TransactionDetails + signerAddress: Address +} + +export function ReviewAndConfirmView({ txDetails, signerAddress }: ReviewAndConfirmViewProps) { + const { currentTheme } = useCurrentTheme() + const theme = useTheme() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const renderTabBar = (props: any) => ( + <MaterialTabBar + {...props} + indicatorStyle={{ + backgroundColor: theme.color.get(), + }} + style={{ backgroundColor: currentTheme === 'light' ? theme.backgroundPaper.get() : theme.background.get() }} + labelStyle={{ color: theme.color.get(), fontSize: 16, fontWeight: '600' }} + activeColor={theme.color.get()} + inactiveColor={theme.colorSecondary.get()} + width={200} + /> + ) + + return ( + <View flex={1}> + <Tabs.Container + renderTabBar={renderTabBar} + headerContainerStyle={{ + backgroundColor: 'transparent', + paddingHorizontal: 16, + paddingBottom: 16, + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + }} + renderHeader={() => <ReviewHeader />} + > + <Tabs.Tab name="Data" label="Data"> + <DataTab /> + </Tabs.Tab> + <Tabs.Tab name="JSON" label="JSON"> + <JSONTab txDetails={txDetails} /> + </Tabs.Tab> + </Tabs.Container> + + <ReviewFooter signerAddress={signerAddress} txId={txDetails.txId} /> + </View> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/ReviewFooter.tsx b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/ReviewFooter.tsx new file mode 100644 index 0000000000..bbe805d163 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/ReviewFooter.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import { YStack } from 'tamagui' +import { router } from 'expo-router' +import { SafeButton } from '@/src/components/SafeButton' +import { SelectSigner } from '@/src/components/SelectSigner' +import { useBiometrics } from '@/src/hooks/useBiometrics' +import { useGuard } from '@/src/context/GuardProvider' +import { Address } from '@/src/types/address' +import { useSafeAreaInsets } from 'react-native-safe-area-context' + +interface ReviewFooterProps { + signerAddress: Address + txId: string +} + +export function ReviewFooter({ signerAddress, txId }: ReviewFooterProps) { + const { isBiometricsEnabled } = useBiometrics() + const { setGuard } = useGuard() + const insets = useSafeAreaInsets() + const handleConfirmPress = async () => { + try { + // Set the signing guard to true before navigating to sign transaction + setGuard('signing', true) + + if (isBiometricsEnabled) { + router.push({ + pathname: '/sign-transaction', + params: { txId, signerAddress }, + }) + } else { + router.push({ + pathname: '/biometrics-opt-in', + params: { txId, signerAddress, caller: '/sign-transaction' }, + }) + } + } catch (error) { + console.error('Error confirming transaction:', error) + } + } + + return ( + <YStack + backgroundColor="$background" + paddingHorizontal="$4" + paddingVertical="$3" + borderTopWidth={1} + borderTopColor="$borderLight" + space="$3" + paddingBottom={insets.bottom ? insets.bottom : '$4'} + > + <SelectSigner address={signerAddress} txId={txId} /> + + <SafeButton onPress={handleConfirmPress} width="100%"> + Confirm transaction + </SafeButton> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/ReviewHeader.tsx b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/ReviewHeader.tsx new file mode 100644 index 0000000000..881ebdefca --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/ReviewHeader.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import { Text, YStack } from 'tamagui' + +export function ReviewHeader() { + return ( + <YStack space="$4" paddingTop="$4"> + <YStack space="$2"> + <Text color="$colorSecondary"> + Review this transaction data and make sure it matches with the details on the web app. + </Text> + </YStack> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/index.ts b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/index.ts new file mode 100644 index 0000000000..9c92f145db --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/index.ts @@ -0,0 +1,4 @@ +export { ReviewAndConfirmContainer } from './ReviewAndConfirmContainer' +export { ReviewAndConfirmView } from './ReviewAndConfirmView' +export { ReviewHeader } from './ReviewHeader' +export { ReviewFooter } from './ReviewFooter' diff --git a/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/tabs/DataTab.tsx b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/tabs/DataTab.tsx new file mode 100644 index 0000000000..595b204b13 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/tabs/DataTab.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import { Tabs } from 'react-native-collapsible-tab-view' +import { TxDataContainer } from '@/src/features/AdvancedDetails' + +export function DataTab() { + return ( + <Tabs.ScrollView contentContainerStyle={{ padding: 16 }}> + <TxDataContainer /> + </Tabs.ScrollView> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/tabs/JSONTab.tsx b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/tabs/JSONTab.tsx new file mode 100644 index 0000000000..9b6cc82040 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/ReviewAndConfirm/tabs/JSONTab.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { Text, View } from 'tamagui' +import { Tabs } from 'react-native-collapsible-tab-view' +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { Container } from '@/src/components/Container' +import { CopyButton } from '@/src/components/CopyButton' + +interface JSONTabProps { + txDetails: TransactionDetails +} + +export function JSONTab({ txDetails }: JSONTabProps) { + const jsonData = JSON.stringify(txDetails, null, 2) + + return ( + <Tabs.ScrollView contentContainerStyle={{ padding: 16, marginTop: 16 }}> + <Container> + <View position="absolute" right={10} top={10} zIndex={1000}> + <CopyButton value={jsonData} color="$colorSecondary" size={16} text="JSON value copied to clipboard" /> + </View> + <Text>{jsonData}</Text> + </Container> + </Tabs.ScrollView> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/SignForm/SignForm.tsx b/apps/mobile/src/features/ConfirmTx/components/SignForm/SignForm.tsx new file mode 100644 index 0000000000..474f9af46a --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/SignForm/SignForm.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { SafeAreaView } from 'react-native' +import { View } from 'tamagui' +import { SafeButton } from '@/src/components/SafeButton' +import { router } from 'expo-router' +import { Address } from '@/src/types/address' + +export interface SignFormProps { + address: Address + txId: string +} + +export function SignForm({ address, txId }: SignFormProps) { + const onSignPress = () => { + router.push({ + pathname: '/review-and-confirm', + params: { txId, signerAddress: address }, + }) + } + + return ( + <SafeAreaView style={{ gap: 24 }}> + <View paddingHorizontal={'$3'} height={48} gap="$2" flexDirection="row"> + <SafeButton flex={1} height="100%" onPress={onSignPress}> + Continue + </SafeButton> + </View> + </SafeAreaView> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/SignForm/index.ts b/apps/mobile/src/features/ConfirmTx/components/SignForm/index.ts new file mode 100644 index 0000000000..2ea5051a21 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/SignForm/index.ts @@ -0,0 +1 @@ +export { SignForm } from './SignForm' diff --git a/apps/mobile/src/features/ConfirmTx/components/SignTransaction/SignError.tsx b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/SignError.tsx new file mode 100644 index 0000000000..9f2f4d0877 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/SignError.tsx @@ -0,0 +1,68 @@ +import { StyleSheet } from 'react-native' +import { LinearGradient } from 'expo-linear-gradient' +import React from 'react' +import { SafeAreaView } from 'react-native-safe-area-context' +import { ScrollView, Text, useTheme, View } from 'tamagui' +import { Badge } from '@/src/components/Badge' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { LargeHeaderTitle } from '@/src/components/Title' +import { SafeButton } from '@/src/components/SafeButton' +import { router } from 'expo-router' + +export default function SignError({ onRetryPress, description }: { onRetryPress: () => void; description?: string }) { + const theme = useTheme() + const colors: [string, string] = [theme.errorDark.get(), 'transparent'] + + return ( + <SafeAreaView edges={['bottom']} style={{ flex: 1 }}> + <LinearGradient colors={colors} style={styles.background} /> + <View flex={1} justifyContent="space-between"> + <View flex={1}> + <ScrollView contentContainerStyle={{ flexGrow: 1 }}> + <View + flex={1} + flexGrow={1} + alignItems="center" + marginTop="$10" + justifyContent="center" + paddingHorizontal="$3" + > + <Badge + themeName="badge_error" + circleSize={64} + content={<SafeFontIcon size={32} color="$error" name="close-filled" />} + /> + + <View margin="$4" width="100%" alignItems="center" gap="$4"> + <LargeHeaderTitle textAlign="center" size="$8" lineHeight={32} maxWidth={200} fontWeight={600}> + Couldn't sign the transaction + </LargeHeaderTitle> + + <Text textAlign="center" fontSize="$4" width="80%"> + {description || 'There was an error executing this transaction.'} + </Text> + </View> + </View> + </ScrollView> + </View> + + <View paddingHorizontal="$4" gap="$4"> + <SafeButton onPress={onRetryPress}>Retry</SafeButton> + <SafeButton text onPress={router.back}> + View transaction + </SafeButton> + </View> + </View> + </SafeAreaView> + ) +} + +const styles = StyleSheet.create({ + background: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + height: 300, + }, +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/SignTransaction/SignSuccess.tsx b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/SignSuccess.tsx new file mode 100644 index 0000000000..87aa6e3f91 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/SignSuccess.tsx @@ -0,0 +1,65 @@ +import { StyleSheet } from 'react-native' +import { LinearGradient } from 'expo-linear-gradient' +import React from 'react' +import { SafeAreaView } from 'react-native-safe-area-context' +import { H3, ScrollView, useTheme, View } from 'tamagui' +import { Badge } from '@/src/components/Badge' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { SafeButton } from '@/src/components/SafeButton' +import { cgwApi } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +import { router } from 'expo-router' +import { useDispatch } from 'react-redux' + +export default function SignSuccess() { + const dispatch = useDispatch() + const theme = useTheme() + const colors: [string, string] = [theme.success.get(), 'transparent'] + + const handleDonePress = () => { + dispatch(cgwApi.util.invalidateTags(['transactions'])) + + // Go back twice to the confirm transaction screen + router.back() + router.back() + } + + return ( + <SafeAreaView edges={['bottom']} style={{ flex: 1 }}> + <LinearGradient colors={colors} style={styles.background} /> + <View flex={1} justifyContent="space-between"> + <View flex={1}> + <ScrollView contentContainerStyle={{ flexGrow: 1 }}> + <View flex={1} flexGrow={1} alignItems="center" justifyContent="center" paddingHorizontal="$3"> + <Badge + themeName="badge_success" + circleSize={64} + content={<SafeFontIcon size={32} color="$primary" name="check-filled" />} + /> + + <View margin="$4" width="100%" alignItems="center" gap="$4" padding="$4"> + <H3 textAlign="center" fontWeight={'600'} lineHeight={32}> + You successfully signed this transaction. + </H3> + </View> + </View> + </ScrollView> + </View> + + <View paddingHorizontal="$4"> + <SafeButton onPress={handleDonePress}>Done</SafeButton> + </View> + </View> + </SafeAreaView> + ) +} + +const styles = StyleSheet.create({ + background: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + height: 300, + }, +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/SignTransaction/SignTransaction.test.tsx b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/SignTransaction.test.tsx new file mode 100644 index 0000000000..2a5bdbae20 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/SignTransaction.test.tsx @@ -0,0 +1,330 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react-native' +import { useLocalSearchParams } from 'expo-router' +import { SignTransaction } from './SignTransaction' +import { useSigningGuard } from './hooks/useSigningGuard' +import { useTransactionSigning } from './hooks/useTransactionSigning' + +// Mock the hooks +jest.mock('expo-router', () => ({ + useLocalSearchParams: jest.fn(), +})) + +jest.mock('./hooks/useSigningGuard', () => ({ + useSigningGuard: jest.fn(), +})) + +jest.mock('./hooks/useTransactionSigning', () => ({ + useTransactionSigning: jest.fn(), +})) + +// Mock the child components using string components instead of React Native components +jest.mock('./SignError', () => ({ + __esModule: true, + default: ({ description, onRetryPress }: { description: string; onRetryPress: () => void }) => { + const React = require('react') + return React.createElement('View', { testID: 'sign-error' }, [ + React.createElement('Text', { testID: 'error-description', key: 'description' }, description), + React.createElement( + 'TouchableOpacity', + { testID: 'retry-button', onPress: onRetryPress, key: 'retry' }, + React.createElement('Text', null, 'Retry'), + ), + ]) + }, +})) + +jest.mock('./SignSuccess', () => ({ + __esModule: true, + default: () => { + const React = require('react') + return React.createElement('View', { testID: 'sign-success' }, React.createElement('Text', null, 'Success!')) + }, +})) + +jest.mock('@/src/components/LoadingScreen', () => ({ + LoadingScreen: ({ title, description }: { title: string; description: string }) => { + const React = require('react') + return React.createElement('View', { testID: 'loading-screen' }, [ + React.createElement('Text', { testID: 'loading-title', key: 'title' }, title), + React.createElement('Text', { testID: 'loading-description', key: 'description' }, description), + ]) + }, +})) + +const mockUseLocalSearchParams = useLocalSearchParams as jest.MockedFunction<typeof useLocalSearchParams> +const mockUseSigningGuard = useSigningGuard as jest.MockedFunction<typeof useSigningGuard> +const mockUseTransactionSigning = useTransactionSigning as jest.MockedFunction<typeof useTransactionSigning> + +describe('SignTransaction', () => { + const mockExecuteSign = jest.fn() + const mockRetry = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + + // Default mocks + mockUseLocalSearchParams.mockReturnValue({ + txId: 'test-tx-id', + signerAddress: '0x456', + }) + + mockUseSigningGuard.mockReturnValue({ + canSign: true, + }) + + mockUseTransactionSigning.mockReturnValue({ + status: 'idle', + executeSign: mockExecuteSign, + retry: mockRetry, + reset: jest.fn(), + isApiLoading: false, + apiData: undefined, + isApiError: false, + hasTriggeredAutoSign: false, + }) + }) + + describe('parameter validation', () => { + it('should render error when txId is missing', () => { + mockUseLocalSearchParams.mockReturnValue({ + txId: '', + signerAddress: '0x456', + }) + + render(<SignTransaction />) + + expect(screen.getByTestId('sign-error')).toBeOnTheScreen() + expect(screen.getByTestId('error-description')).toHaveTextContent('Missing transaction ID or signer address') + }) + + it('should render error when signerAddress is missing', () => { + mockUseLocalSearchParams.mockReturnValue({ + txId: 'test-tx-id', + signerAddress: '', + }) + + render(<SignTransaction />) + + expect(screen.getByTestId('sign-error')).toBeOnTheScreen() + expect(screen.getByTestId('error-description')).toHaveTextContent('Missing transaction ID or signer address') + }) + + it('should render error when both parameters are missing', () => { + mockUseLocalSearchParams.mockReturnValue({}) + + render(<SignTransaction />) + + expect(screen.getByTestId('sign-error')).toBeOnTheScreen() + }) + }) + + describe('auto-signing logic', () => { + it('should call executeSign when user can sign and status is idle', async () => { + mockUseSigningGuard.mockReturnValue({ + canSign: true, + }) + + mockUseTransactionSigning.mockReturnValue({ + status: 'idle', + executeSign: mockExecuteSign, + retry: mockRetry, + reset: jest.fn(), + isApiLoading: false, + apiData: undefined, + isApiError: false, + hasTriggeredAutoSign: false, + }) + + render(<SignTransaction />) + + await waitFor(() => { + expect(mockExecuteSign).toHaveBeenCalled() + }) + }) + + it('should not call executeSign when user cannot sign', () => { + mockUseSigningGuard.mockReturnValue({ + canSign: false, + }) + + render(<SignTransaction />) + + expect(mockExecuteSign).not.toHaveBeenCalled() + }) + + it('should not call executeSign when status is not idle', () => { + mockUseTransactionSigning.mockReturnValue({ + status: 'loading', + executeSign: mockExecuteSign, + retry: mockRetry, + reset: jest.fn(), + isApiLoading: false, + apiData: undefined, + isApiError: false, + hasTriggeredAutoSign: false, + }) + + render(<SignTransaction />) + + expect(mockExecuteSign).not.toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + it('should render SignError for API errors', () => { + mockUseTransactionSigning.mockReturnValue({ + status: 'idle', + executeSign: mockExecuteSign, + retry: mockRetry, + reset: jest.fn(), + isApiLoading: false, + apiData: undefined, + isApiError: true, + hasTriggeredAutoSign: false, + }) + + render(<SignTransaction />) + + expect(screen.getByTestId('sign-error')).toBeOnTheScreen() + expect(screen.getByTestId('error-description')).toHaveTextContent('Failed to submit transaction confirmation') + }) + + it('should render SignError for signing errors', () => { + mockUseTransactionSigning.mockReturnValue({ + status: 'error', + executeSign: mockExecuteSign, + retry: mockRetry, + reset: jest.fn(), + isApiLoading: false, + apiData: undefined, + isApiError: false, + hasTriggeredAutoSign: false, + }) + + render(<SignTransaction />) + + expect(screen.getByTestId('sign-error')).toBeOnTheScreen() + expect(screen.getByTestId('error-description')).toHaveTextContent('There was an error signing the transaction.') + }) + + it('should call retry when retry button is pressed', () => { + mockUseTransactionSigning.mockReturnValue({ + status: 'error', + executeSign: mockExecuteSign, + retry: mockRetry, + reset: jest.fn(), + isApiLoading: false, + apiData: undefined, + isApiError: false, + hasTriggeredAutoSign: false, + }) + + render(<SignTransaction />) + + const retryButton = screen.getByTestId('retry-button') + retryButton.props.onPress() + + expect(mockRetry).toHaveBeenCalled() + }) + }) + + describe('success state', () => { + it('should render SignSuccess when signing is successful', () => { + mockUseTransactionSigning.mockReturnValue({ + status: 'success', + executeSign: mockExecuteSign, + retry: mockRetry, + reset: jest.fn(), + isApiLoading: false, + apiData: undefined, + isApiError: false, + hasTriggeredAutoSign: false, + }) + + render(<SignTransaction />) + + expect(screen.getByTestId('sign-success')).toBeOnTheScreen() + expect(screen.getByText('Success!')).toBeOnTheScreen() + }) + }) + + describe('loading states', () => { + it('should render LoadingScreen when signing is in progress', () => { + mockUseTransactionSigning.mockReturnValue({ + status: 'loading', + executeSign: mockExecuteSign, + retry: mockRetry, + reset: jest.fn(), + isApiLoading: false, + apiData: undefined, + isApiError: false, + hasTriggeredAutoSign: false, + }) + + render(<SignTransaction />) + + expect(screen.getByTestId('loading-screen')).toBeOnTheScreen() + expect(screen.getByTestId('loading-title')).toHaveTextContent('Signing transaction...') + expect(screen.getByTestId('loading-description')).toHaveTextContent('It may take a few seconds...') + }) + + it('should render LoadingScreen when API is loading', () => { + mockUseTransactionSigning.mockReturnValue({ + status: 'idle', + executeSign: mockExecuteSign, + retry: mockRetry, + reset: jest.fn(), + isApiLoading: true, + apiData: undefined, + isApiError: false, + hasTriggeredAutoSign: false, + }) + + render(<SignTransaction />) + + expect(screen.getByTestId('loading-screen')).toBeOnTheScreen() + expect(screen.getByTestId('loading-title')).toHaveTextContent('Signing transaction...') + }) + + it('should render preparation loading screen for idle authorized state', () => { + mockUseSigningGuard.mockReturnValue({ + canSign: true, + }) + + mockUseTransactionSigning.mockReturnValue({ + status: 'idle', + executeSign: mockExecuteSign, + retry: mockRetry, + reset: jest.fn(), + isApiLoading: false, + apiData: undefined, + isApiError: false, + hasTriggeredAutoSign: false, + }) + + render(<SignTransaction />) + + expect(screen.getByTestId('loading-screen')).toBeOnTheScreen() + expect(screen.getByTestId('loading-title')).toHaveTextContent('Preparing to sign...') + expect(screen.getByTestId('loading-description')).toHaveTextContent('Initializing signing process...') + }) + }) + + describe('hook integration', () => { + it('should pass correct parameters to useTransactionSigning', () => { + render(<SignTransaction />) + + expect(mockUseTransactionSigning).toHaveBeenCalledWith({ + txId: 'test-tx-id', + signerAddress: '0x456', + }) + }) + + it('should call useSigningGuard with no parameters', () => { + render(<SignTransaction />) + + expect(mockUseSigningGuard).toHaveBeenCalledWith() + }) + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/SignTransaction/SignTransaction.tsx b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/SignTransaction.tsx new file mode 100644 index 0000000000..2f89a295e3 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/SignTransaction.tsx @@ -0,0 +1,52 @@ +import { LoadingScreen } from '@/src/components/LoadingScreen' +import React, { useEffect } from 'react' +import { useLocalSearchParams } from 'expo-router' +import SignError from './SignError' +import SignSuccess from './SignSuccess' +import { useSigningGuard } from './hooks/useSigningGuard' +import { useTransactionSigning } from './hooks/useTransactionSigning' + +export function SignTransaction() { + const { txId, signerAddress } = useLocalSearchParams<{ txId: string; signerAddress: string }>() + + if (!txId || !signerAddress) { + const handleRetry = () => { + // Cannot retry missing parameters - this is a configuration error + console.error('Cannot retry: missing transaction parameters') + } + return <SignError description="Missing transaction ID or signer address" onRetryPress={handleRetry} /> + } + + const { canSign } = useSigningGuard() + const { status, executeSign, retry, isApiLoading, isApiError } = useTransactionSigning({ txId, signerAddress }) + + // Auto-sign when component mounts if user can sign + useEffect(() => { + if (canSign && status === 'idle') { + executeSign() + } + }, [canSign, status, executeSign]) + + // Handle API errors + if (isApiError) { + return <SignError onRetryPress={retry} description="Failed to submit transaction confirmation" /> + } + + // Handle signing errors + if (status === 'error') { + return <SignError onRetryPress={retry} description="There was an error signing the transaction." /> + } + + // Handle success + if (status === 'success') { + return <SignSuccess /> + } + + // Show loading state + if (status === 'loading' || isApiLoading) { + return <LoadingScreen title="Signing transaction..." description="It may take a few seconds..." /> + } + + // This should rarely be reached (idle state while authorized) + return <LoadingScreen title="Preparing to sign..." description="Initializing signing process..." /> +} diff --git a/apps/mobile/src/features/ConfirmTx/components/SignTransaction/hooks/useSigningGuard.test.ts b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/hooks/useSigningGuard.test.ts new file mode 100644 index 0000000000..b60de7eb15 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/hooks/useSigningGuard.test.ts @@ -0,0 +1,167 @@ +import { renderHook } from '@testing-library/react-native' +import { Alert } from 'react-native' +import { useRouter } from 'expo-router' +import { useGuard } from '@/src/context/GuardProvider' +import { useSigningGuard } from './useSigningGuard' + +// Mock dependencies +jest.mock('react-native', () => ({ + Alert: { + alert: jest.fn(), + }, +})) + +jest.mock('expo-router', () => ({ + useRouter: jest.fn(), +})) + +jest.mock('@/src/context/GuardProvider', () => ({ + useGuard: jest.fn(), +})) + +const mockRouter = { + back: jest.fn(), +} + +const mockGuard = { + getGuard: jest.fn(), +} + +describe('useSigningGuard', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(useRouter as jest.Mock).mockReturnValue(mockRouter) + ;(useGuard as jest.Mock).mockReturnValue(mockGuard) + }) + + describe('when user can sign initially', () => { + beforeEach(() => { + mockGuard.getGuard.mockReturnValue(true) + }) + + it('should return canSign as true', () => { + const { result } = renderHook(() => useSigningGuard()) + + expect(result.current.canSign).toBe(true) + }) + + it('should not show alert', () => { + renderHook(() => useSigningGuard()) + + expect(Alert.alert).not.toHaveBeenCalled() + }) + }) + + describe('when user cannot sign initially', () => { + beforeEach(() => { + mockGuard.getGuard.mockReturnValue(false) + }) + + it('should return canSign as false', () => { + const { result } = renderHook(() => useSigningGuard()) + + expect(result.current.canSign).toBe(false) + }) + + it('should show security alert for initial unauthorized access', () => { + renderHook(() => useSigningGuard()) + + expect(Alert.alert).toHaveBeenCalledWith( + 'Something is fishy!', + 'You somehow got here, but you did not look at the transaction details. Go Back, inspect the transaction details and try again.', + [ + { + text: 'Go Back', + onPress: expect.any(Function), + }, + ], + ) + }) + + it('should navigate back when alert button is pressed', () => { + renderHook(() => useSigningGuard()) + + // Get the onPress function from the Alert.alert call + const alertCall = (Alert.alert as jest.Mock).mock.calls[0] + const onPressHandler = alertCall[2][0].onPress + + // Simulate button press + onPressHandler() + + expect(mockRouter.back).toHaveBeenCalled() + }) + + it('should not show alert multiple times on re-renders', () => { + const { rerender } = renderHook(() => useSigningGuard(), {}) + + // First render shows alert + expect(Alert.alert).toHaveBeenCalledTimes(1) + + // Re-render should not show alert again + rerender({}) + expect(Alert.alert).toHaveBeenCalledTimes(1) + }) + }) + + describe('when guard status changes after being authorized', () => { + it('should NOT show alert when going from authorized to unauthorized (post-signing)', () => { + // Start with canSign = true (authorized) + mockGuard.getGuard.mockReturnValue(true) + const { result, rerender } = renderHook(() => useSigningGuard()) + + expect(result.current.canSign).toBe(true) + expect(Alert.alert).not.toHaveBeenCalled() + + // Change to canSign = false (simulating guard reset after signing) + mockGuard.getGuard.mockReturnValue(false) + rerender({}) + + expect(result.current.canSign).toBe(false) + // Alert should NOT be shown because we were previously authorized + expect(Alert.alert).not.toHaveBeenCalled() + }) + + it('should track authorization state across multiple changes', () => { + // Start unauthorized + mockGuard.getGuard.mockReturnValue(false) + const { result, rerender } = renderHook(() => useSigningGuard()) + + expect(result.current.canSign).toBe(false) + expect(Alert.alert).toHaveBeenCalledTimes(1) // Shows alert for initial unauthorized + + // Become authorized + mockGuard.getGuard.mockReturnValue(true) + rerender({}) + + expect(result.current.canSign).toBe(true) + expect(Alert.alert).toHaveBeenCalledTimes(1) // No new alert + + // Become unauthorized again (simulating guard reset after signing) + mockGuard.getGuard.mockReturnValue(false) + rerender({}) + + expect(result.current.canSign).toBe(false) + expect(Alert.alert).toHaveBeenCalledTimes(1) // Still no new alert + }) + }) + + describe('edge cases', () => { + it('should handle rapid state changes correctly', () => { + // Multiple rapid changes + mockGuard.getGuard.mockReturnValue(false) + const { rerender } = renderHook(() => useSigningGuard()) + + mockGuard.getGuard.mockReturnValue(true) + rerender({}) + + mockGuard.getGuard.mockReturnValue(false) + rerender({}) + + mockGuard.getGuard.mockReturnValue(true) + rerender({}) + + // Should only show alert once for initial unauthorized state + expect(Alert.alert).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/SignTransaction/hooks/useSigningGuard.ts b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/hooks/useSigningGuard.ts new file mode 100644 index 0000000000..3447472624 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/hooks/useSigningGuard.ts @@ -0,0 +1,39 @@ +import { useLayoutEffect, useRef } from 'react' +import { Alert } from 'react-native' +import { useRouter } from 'expo-router' +import { useGuard } from '@/src/context/GuardProvider' + +export function useSigningGuard() { + const { getGuard } = useGuard() + const router = useRouter() + const hasShownAlert = useRef(false) + const hasEverBeenAuthorized = useRef(false) + const canSign = getGuard('signing') + + useLayoutEffect(() => { + // Track if we've ever been authorized + if (canSign) { + hasEverBeenAuthorized.current = true + } + + // Only show alert if: + // 1. User cannot sign AND + // 2. We haven't shown alert before AND + // 3. We've never been authorized (prevents alert after successful signing) + if (!canSign && !hasShownAlert.current && !hasEverBeenAuthorized.current) { + Alert.alert( + 'Something is fishy!', + 'You somehow got here, but you did not look at the transaction details. Go Back, inspect the transaction details and try again.', + [ + { + text: 'Go Back', + onPress: () => router.back(), + }, + ], + ) + hasShownAlert.current = true + } + }, [canSign, router]) + + return { canSign } +} diff --git a/apps/mobile/src/features/ConfirmTx/components/SignTransaction/hooks/useTransactionSigning.test.ts b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/hooks/useTransactionSigning.test.ts new file mode 100644 index 0000000000..2856c16409 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/hooks/useTransactionSigning.test.ts @@ -0,0 +1,302 @@ +import { renderHook, waitFor, act } from '@testing-library/react-native' +import { useTransactionSigning } from './useTransactionSigning' +import { getPrivateKey } from '@/src/hooks/useSign/useSign' +import { signTx } from '@/src/services/tx/tx-sender/sign' +import { useTransactionsAddConfirmationV1Mutation } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useAppSelector } from '@/src/store/hooks' +import { useGuard } from '@/src/context/GuardProvider' +import logger from '@/src/utils/logger' + +// Mock dependencies +jest.mock('@/src/hooks/useSign/useSign') +jest.mock('@/src/services/tx/tx-sender/sign') +jest.mock('@safe-global/store/gateway/AUTO_GENERATED/transactions') +jest.mock('@/src/store/hooks/activeSafe') +jest.mock('@/src/store/hooks') +jest.mock('@/src/context/GuardProvider') +jest.mock('@/src/utils/logger') + +const mockGetPrivateKey = getPrivateKey as jest.MockedFunction<typeof getPrivateKey> +const mockSignTx = signTx as jest.MockedFunction<typeof signTx> +const mockUseTransactionsAddConfirmationV1Mutation = useTransactionsAddConfirmationV1Mutation as jest.MockedFunction< + typeof useTransactionsAddConfirmationV1Mutation +> +const mockUseDefinedActiveSafe = useDefinedActiveSafe as jest.MockedFunction<typeof useDefinedActiveSafe> +const mockUseAppSelector = useAppSelector as jest.MockedFunction<typeof useAppSelector> +const mockUseGuard = useGuard as jest.MockedFunction<typeof useGuard> + +const mockActiveSafe = { + chainId: '1', + address: '0x123' as const, +} + +const mockActiveChain = { + chainId: '1', + chainName: 'Ethereum', +} + +const mockAddConfirmation = jest.fn() +const mockResetGuard = jest.fn() +const mockGetGuard = jest.fn() +const mockSetGuard = jest.fn() +const mockResetAllGuards = jest.fn() + +const mockSignedTx = { + safeTransactionHash: '0xabcd', + signature: '0xsignature', +} + +const mockMutationResult = { + isLoading: false, + data: null, + isError: false, + reset: jest.fn(), +} + +describe('useTransactionSigning', () => { + const defaultProps = { + txId: 'test-tx-id', + signerAddress: '0x456', + } + + beforeEach(() => { + jest.clearAllMocks() + + mockUseDefinedActiveSafe.mockReturnValue(mockActiveSafe) + mockUseAppSelector.mockReturnValue(mockActiveChain) + mockUseGuard.mockReturnValue({ + resetGuard: mockResetGuard, + getGuard: mockGetGuard, + setGuard: mockSetGuard, + resetAllGuards: mockResetAllGuards, + guards: {}, + }) + mockUseTransactionsAddConfirmationV1Mutation.mockReturnValue([mockAddConfirmation, mockMutationResult]) + }) + + describe('initial state', () => { + it('should return idle status initially', () => { + const { result } = renderHook(() => useTransactionSigning(defaultProps)) + + expect(result.current.status).toBe('idle') + expect(result.current.hasTriggeredAutoSign).toBe(false) + }) + + it('should provide all required methods', () => { + const { result } = renderHook(() => useTransactionSigning(defaultProps)) + + expect(typeof result.current.executeSign).toBe('function') + expect(typeof result.current.retry).toBe('function') + expect(typeof result.current.reset).toBe('function') + }) + }) + + describe('executeSign', () => { + it('should successfully sign transaction', async () => { + mockGetPrivateKey.mockResolvedValue('private-key') + mockSignTx.mockResolvedValue(mockSignedTx) + mockAddConfirmation.mockResolvedValue({ data: 'success' }) + + const { result } = renderHook(() => useTransactionSigning(defaultProps)) + + await act(async () => { + await result.current.executeSign() + }) + + await waitFor(() => { + expect(result.current.status).toBe('success') + }) + + expect(mockGetPrivateKey).toHaveBeenCalledWith('0x456') + expect(mockSignTx).toHaveBeenCalledWith({ + chain: mockActiveChain, + activeSafe: mockActiveSafe, + txId: 'test-tx-id', + privateKey: 'private-key', + }) + expect(mockAddConfirmation).toHaveBeenCalledWith({ + chainId: '1', + safeTxHash: '0xabcd', + addConfirmationDto: { + signature: '0xsignature', + }, + }) + expect(mockResetGuard).toHaveBeenCalledWith('signing') + }) + + it('should handle missing private key', async () => { + mockGetPrivateKey.mockResolvedValue(undefined) + + const { result } = renderHook(() => useTransactionSigning(defaultProps)) + + await act(async () => { + await result.current.executeSign() + }) + + await waitFor(() => { + expect(result.current.status).toBe('error') + }) + + expect(mockSignTx).not.toHaveBeenCalled() + expect(mockAddConfirmation).not.toHaveBeenCalled() + expect(mockResetGuard).not.toHaveBeenCalled() + }) + + it('should handle signing errors', async () => { + const signingError = new Error('Signing failed') + mockGetPrivateKey.mockResolvedValue('private-key') + mockSignTx.mockRejectedValue(signingError) + + const { result } = renderHook(() => useTransactionSigning(defaultProps)) + + await act(async () => { + await result.current.executeSign() + }) + + await waitFor(() => { + expect(result.current.status).toBe('error') + }) + + expect(logger.error).toHaveBeenCalledWith('Error signing transaction:', signingError) + expect(mockAddConfirmation).not.toHaveBeenCalled() + expect(mockResetGuard).not.toHaveBeenCalled() + }) + + it('should handle API confirmation errors', async () => { + const apiError = new Error('API failed') + mockGetPrivateKey.mockResolvedValue('private-key') + mockSignTx.mockResolvedValue(mockSignedTx) + mockAddConfirmation.mockRejectedValue(apiError) + + const { result } = renderHook(() => useTransactionSigning(defaultProps)) + + await act(async () => { + await result.current.executeSign() + }) + + await waitFor(() => { + expect(result.current.status).toBe('error') + }) + + expect(logger.error).toHaveBeenCalledWith('Error signing transaction:', apiError) + expect(mockResetGuard).not.toHaveBeenCalled() + }) + + it('should prevent multiple executions', async () => { + mockGetPrivateKey.mockResolvedValue('private-key') + mockSignTx.mockResolvedValue(mockSignedTx) + mockAddConfirmation.mockResolvedValue({ data: 'success' }) + + const { result } = renderHook(() => useTransactionSigning(defaultProps)) + + // Call executeSign multiple times + await act(async () => { + result.current.executeSign() + result.current.executeSign() + result.current.executeSign() + }) + + await waitFor(() => { + expect(result.current.status).toBe('success') + }) + + // Should only call once + expect(mockGetPrivateKey).toHaveBeenCalledTimes(1) + expect(mockSignTx).toHaveBeenCalledTimes(1) + expect(mockAddConfirmation).toHaveBeenCalledTimes(1) + }) + }) + + describe('retry', () => { + it('should reset state and re-execute signing', async () => { + mockGetPrivateKey.mockResolvedValue('private-key') + mockSignTx.mockResolvedValue(mockSignedTx) + mockAddConfirmation.mockResolvedValue({ data: 'success' }) + + const { result } = renderHook(() => useTransactionSigning(defaultProps)) + + // First execution + await act(async () => { + await result.current.executeSign() + }) + await waitFor(() => { + expect(result.current.status).toBe('success') + }) + + // Retry should allow re-execution + await act(async () => { + await result.current.retry() + }) + + await waitFor(() => { + expect(result.current.status).toBe('success') + }) + + expect(mockGetPrivateKey).toHaveBeenCalledTimes(2) + expect(mockSignTx).toHaveBeenCalledTimes(2) + }) + }) + + describe('reset', () => { + it('should reset to idle state', async () => { + mockGetPrivateKey.mockResolvedValue('private-key') + mockSignTx.mockResolvedValue(mockSignedTx) + mockAddConfirmation.mockResolvedValue({ data: 'success' }) + + const { result } = renderHook(() => useTransactionSigning(defaultProps)) + + await act(async () => { + await result.current.executeSign() + }) + await waitFor(() => { + expect(result.current.status).toBe('success') + }) + + act(() => { + result.current.reset() + }) + + await waitFor(() => { + expect(result.current.status).toBe('idle') + expect(result.current.hasTriggeredAutoSign).toBe(false) + }) + }) + }) + + describe('API state forwarding', () => { + it('should forward API loading state', () => { + mockUseTransactionsAddConfirmationV1Mutation.mockReturnValue([ + mockAddConfirmation, + { isLoading: true, data: null, isError: false, reset: jest.fn() }, + ]) + + const { result } = renderHook(() => useTransactionSigning(defaultProps)) + + expect(result.current.isApiLoading).toBe(true) + }) + + it('should forward API error state', () => { + mockUseTransactionsAddConfirmationV1Mutation.mockReturnValue([ + mockAddConfirmation, + { isLoading: false, data: null, isError: true, reset: jest.fn() }, + ]) + + const { result } = renderHook(() => useTransactionSigning(defaultProps)) + + expect(result.current.isApiError).toBe(true) + }) + + it('should forward API data', () => { + const mockData = { result: 'success' } + mockUseTransactionsAddConfirmationV1Mutation.mockReturnValue([ + mockAddConfirmation, + { isLoading: false, data: mockData, isError: false, reset: jest.fn() }, + ]) + + const { result } = renderHook(() => useTransactionSigning(defaultProps)) + + expect(result.current.apiData).toBe(mockData) + }) + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/SignTransaction/hooks/useTransactionSigning.ts b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/hooks/useTransactionSigning.ts new file mode 100644 index 0000000000..8947e7a7b1 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/hooks/useTransactionSigning.ts @@ -0,0 +1,92 @@ +import { useCallback, useRef, useState } from 'react' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useAppSelector } from '@/src/store/hooks' +import { selectChainById } from '@/src/store/chains' +import { RootState } from '@/src/store' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { getPrivateKey } from '@/src/hooks/useSign/useSign' +import { signTx } from '@/src/services/tx/tx-sender/sign' +import { useTransactionsAddConfirmationV1Mutation } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import logger from '@/src/utils/logger' +import { useGuard } from '@/src/context/GuardProvider' + +export type SigningStatus = 'idle' | 'loading' | 'success' | 'error' + +interface UseTransactionSigningProps { + txId: string + signerAddress: string +} + +export function useTransactionSigning({ txId, signerAddress }: UseTransactionSigningProps) { + const [status, setStatus] = useState<SigningStatus>('idle') + const activeSafe = useDefinedActiveSafe() + const activeChain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + const { resetGuard } = useGuard() + const hasTriggeredAutoSign = useRef(false) + + const [addConfirmation, { isLoading: isApiLoading, data: apiData, isError: isApiError }] = + useTransactionsAddConfirmationV1Mutation() + + const executeSign = useCallback(async () => { + if (hasTriggeredAutoSign.current) { + return + } + + setStatus('loading') + hasTriggeredAutoSign.current = true + + try { + const privateKey = await getPrivateKey(signerAddress) + + if (!privateKey) { + setStatus('error') + return + } + + const signedTx = await signTx({ + chain: activeChain as ChainInfo, + activeSafe, + txId, + privateKey, + }) + + await addConfirmation({ + chainId: activeSafe.chainId, + safeTxHash: signedTx.safeTransactionHash, + addConfirmationDto: { + // TODO: we need to add this signature type in the auto generated types, because it was included recently in CGW + // @ts-ignore + signature: signedTx.signature, + }, + }) + + // CRITICAL: Reset guard immediately after successful signing + resetGuard('signing') + setStatus('success') + } catch (error) { + logger.error('Error signing transaction:', error) + setStatus('error') + } + }, [activeChain, activeSafe, txId, signerAddress, addConfirmation, resetGuard]) + + const retry = useCallback(() => { + hasTriggeredAutoSign.current = false + executeSign() + }, [executeSign]) + + const reset = useCallback(() => { + setStatus('idle') + hasTriggeredAutoSign.current = false + }, []) + + return { + status, + executeSign, + retry, + reset, + isApiLoading, + apiData, + isApiError, + hasTriggeredAutoSign: hasTriggeredAutoSign.current, + } +} diff --git a/apps/mobile/src/features/ConfirmTx/components/SignTransaction/index.ts b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/index.ts new file mode 100644 index 0000000000..6119a1b753 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/SignTransaction/index.ts @@ -0,0 +1 @@ +export { SignTransaction } from './SignTransaction' diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/TransactionChecks.tsx b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/TransactionChecks.tsx new file mode 100644 index 0000000000..8c23569e59 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/TransactionChecks.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { SafeListItem } from '@/src/components/SafeListItem' +import { useRouter } from 'expo-router' +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useTransactionSecurity } from './hooks/useTransactionSecurity' +import { getTransactionChecksLabel, shouldShowBottomContent } from './utils/transactionChecksUtils' +import { TransactionChecksLeftNode } from './components/TransactionChecksLeftNode' +import { TransactionChecksBottomContent } from './components/TransactionChecksBottomContent' + +interface TransactionChecksProps { + txId: string + txDetails?: TransactionDetails +} + +export function TransactionChecks({ txId, txDetails }: TransactionChecksProps) { + const router = useRouter() + const security = useTransactionSecurity(txDetails) + + const handleTransactionChecksPress = () => { + router.push({ + pathname: '/transaction-checks', + params: { txId }, + }) + } + + return ( + <SafeListItem + onPress={handleTransactionChecksPress} + leftNode={<TransactionChecksLeftNode security={security} />} + label={getTransactionChecksLabel(security.isScanning)} + rightNode={<SafeFontIcon name="chevron-right" size={16} />} + bottomContent={shouldShowBottomContent(security) ? <TransactionChecksBottomContent security={security} /> : null} + /> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/__tests__/TransactionChecks.test.tsx b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/__tests__/TransactionChecks.test.tsx new file mode 100644 index 0000000000..148f843f0e --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/__tests__/TransactionChecks.test.tsx @@ -0,0 +1,200 @@ +import React from 'react' +import { render, userEvent } from '@/src/tests/test-utils' +import { TransactionChecks } from '../TransactionChecks' +import { useTransactionSecurity } from '../hooks/useTransactionSecurity' +import { useRouter } from 'expo-router' + +type SecurityHookReturn = ReturnType<typeof useTransactionSecurity> + +interface MockSafeListItemProps { + onPress: () => void + leftNode: React.ReactNode + label: string + rightNode: React.ReactNode + bottomContent?: React.ReactNode +} + +interface MockTransactionChecksLeftNodeProps { + security: SecurityHookReturn +} + +interface MockTransactionChecksBottomContentProps { + security: SecurityHookReturn +} + +// Mock the dependencies +jest.mock('../hooks/useTransactionSecurity') +jest.mock('expo-router') +jest.mock('@/src/components/SafeListItem', () => { + const React = require('react') + const { Pressable, View, Text } = require('react-native') + return { + SafeListItem: ({ onPress, leftNode, label, rightNode, bottomContent }: MockSafeListItemProps) => + React.createElement( + Pressable, + { testID: 'safe-list-item', onPress }, + React.createElement(View, { testID: 'left-node' }, leftNode), + React.createElement(Text, { testID: 'label' }, label), + React.createElement(View, { testID: 'right-node' }, rightNode), + bottomContent && React.createElement(View, { testID: 'bottom-content' }, bottomContent), + ), + } +}) +jest.mock('../components/TransactionChecksLeftNode', () => { + const React = require('react') + const { View } = require('react-native') + return { + TransactionChecksLeftNode: ({ security }: MockTransactionChecksLeftNodeProps) => + React.createElement(View, { + testID: 'transaction-checks-left-node', + accessibilityLabel: security.isScanning ? 'scanning' : 'idle', + }), + } +}) +jest.mock('../components/TransactionChecksBottomContent', () => { + const React = require('react') + const { View } = require('react-native') + return { + TransactionChecksBottomContent: ({ security }: MockTransactionChecksBottomContentProps) => + security.hasIssues || security.hasContractManagement || security.error + ? React.createElement(View, { testID: 'transaction-checks-bottom-content' }) + : null, + } +}) + +const mockUseTransactionSecurity = jest.mocked(useTransactionSecurity) +const mockUseRouter = jest.mocked(useRouter) + +describe('TransactionChecks', () => { + const mockPush = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + const mockRouter = { + push: mockPush, + } + mockUseRouter.mockReturnValue(mockRouter as never) + }) + + const createSecurityState = (overrides: Partial<SecurityHookReturn> = {}): SecurityHookReturn => ({ + enabled: true, + isScanning: false, + hasError: false, + payload: undefined, + error: undefined, + isHighRisk: false, + isMediumRisk: false, + hasWarnings: false, + hasIssues: false, + hasContractManagement: false, + ...overrides, + }) + + it('should render with correct label when not scanning', () => { + const security = createSecurityState() + mockUseTransactionSecurity.mockReturnValue(security) + + const { getByTestId } = render(<TransactionChecks txId="test-tx-id" />) + + expect(getByTestId('label')).toHaveTextContent('Transaction checks') + }) + + it('should render with scanning label when scanning', () => { + const security = createSecurityState({ isScanning: true }) + mockUseTransactionSecurity.mockReturnValue(security) + + const { getByTestId } = render(<TransactionChecks txId="test-tx-id" />) + + expect(getByTestId('label')).toHaveTextContent('Checking transaction...') + }) + + it('should render left node with security state', () => { + const security = createSecurityState({ isScanning: true }) + mockUseTransactionSecurity.mockReturnValue(security) + + const { getByTestId } = render(<TransactionChecks txId="test-tx-id" />) + + const leftNode = getByTestId('transaction-checks-left-node') + expect(leftNode.props.accessibilityLabel).toBe('scanning') + }) + + it('should render bottom content when security has issues', () => { + const security = createSecurityState({ hasIssues: true }) + mockUseTransactionSecurity.mockReturnValue(security) + + const { getByTestId } = render(<TransactionChecks txId="test-tx-id" />) + + expect(getByTestId('transaction-checks-bottom-content')).toBeTruthy() + }) + + it('should render bottom content when contract management detected', () => { + const security = createSecurityState({ hasContractManagement: true }) + mockUseTransactionSecurity.mockReturnValue(security) + + const { getByTestId } = render(<TransactionChecks txId="test-tx-id" />) + + expect(getByTestId('transaction-checks-bottom-content')).toBeTruthy() + }) + + it('should render bottom content when security error exists', () => { + const security = createSecurityState({ error: new Error('Test error') }) + mockUseTransactionSecurity.mockReturnValue(security) + + const { getByTestId } = render(<TransactionChecks txId="test-tx-id" />) + + expect(getByTestId('transaction-checks-bottom-content')).toBeTruthy() + }) + + it('should not render bottom content when no issues', () => { + const security = createSecurityState() + mockUseTransactionSecurity.mockReturnValue(security) + + const { queryByTestId } = render(<TransactionChecks txId="test-tx-id" />) + + expect(queryByTestId('transaction-checks-bottom-content')).toBeFalsy() + }) + + it('should navigate to transaction checks page on press', async () => { + const user = userEvent.setup() + const security = createSecurityState() + mockUseTransactionSecurity.mockReturnValue(security) + + const { getByTestId } = render(<TransactionChecks txId="test-tx-123" />) + + await user.press(getByTestId('safe-list-item')) + + expect(mockPush).toHaveBeenCalledWith({ + pathname: '/transaction-checks', + params: { txId: 'test-tx-123' }, + }) + }) + + it('should pass txDetails to useTransactionSecurity hook', () => { + const txDetails = { txId: 'test-tx', txInfo: {} } as Parameters<typeof useTransactionSecurity>[0] + const security = createSecurityState() + mockUseTransactionSecurity.mockReturnValue(security) + + render(<TransactionChecks txId="test-tx-id" txDetails={txDetails} />) + + expect(mockUseTransactionSecurity).toHaveBeenCalledWith(txDetails) + }) + + it('should handle undefined txDetails', () => { + const security = createSecurityState() + mockUseTransactionSecurity.mockReturnValue(security) + + render(<TransactionChecks txId="test-tx-id" />) + + expect(mockUseTransactionSecurity).toHaveBeenCalledWith(undefined) + }) + + it('should not render bottomContent if security is disabled', () => { + const security = createSecurityState({ enabled: false, hasIssues: true }) + mockUseTransactionSecurity.mockReturnValue(security) + + const { queryByTestId } = render(<TransactionChecks txId="test-tx-id" />) + + const bottomContent = queryByTestId('transaction-checks-bottom-content') + expect(bottomContent).toBeNull() + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/components/TransactionChecksBottomContent.tsx b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/components/TransactionChecksBottomContent.tsx new file mode 100644 index 0000000000..958a353894 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/components/TransactionChecksBottomContent.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { Alert } from '@/src/components/Alert' +import { getAlertType, SecurityState } from '../utils/transactionChecksUtils' + +interface TransactionChecksBottomContentProps { + security: SecurityState +} + +export const TransactionChecksBottomContent = ({ security }: TransactionChecksBottomContentProps) => { + // Show warnings for security issues (malicious/warning) + if (security.hasIssues) { + return ( + <Alert type={getAlertType(security)} info="Potential risk detected" message="Review details before signing" /> + ) + } + + // Show warnings for contract management changes (proxy upgrades, ownership changes, etc.) + if (security.hasContractManagement) { + return <Alert type="warning" info="Review details first" message="Contract changes detected!" orientation="left" /> + } + + // Show error if blockaid check failed + if (security.error) { + return ( + <Alert + type="warning" + message="Proceed with caution" + info="The transaction could not be checked for security alerts. Verify the details and addresses before proceeding." + /> + ) + } + + return null +} diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/components/TransactionChecksLeftNode.tsx b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/components/TransactionChecksLeftNode.tsx new file mode 100644 index 0000000000..44fea50a70 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/components/TransactionChecksLeftNode.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { CircleSnail } from 'react-native-progress' +import { getTransactionChecksIcon, SecurityState } from '../utils/transactionChecksUtils' + +interface TransactionChecksLeftNodeProps { + security: SecurityState +} + +export const TransactionChecksLeftNode = ({ security }: TransactionChecksLeftNodeProps) => { + if (security.isScanning) { + return <CircleSnail size={16} borderWidth={0} thickness={1} /> + } + + return <SafeFontIcon name={getTransactionChecksIcon(security)} size={16} /> +} diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/components/__tests__/TransactionChecksBottomContent.test.tsx b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/components/__tests__/TransactionChecksBottomContent.test.tsx new file mode 100644 index 0000000000..41d0550932 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/components/__tests__/TransactionChecksBottomContent.test.tsx @@ -0,0 +1,147 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { TransactionChecksBottomContent } from '../TransactionChecksBottomContent' +import { SecurityState } from '../../utils/transactionChecksUtils' +import { AlertType } from '@/src/components/Alert' + +interface MockAlertProps { + type: AlertType + info?: string + message: string +} + +// Mock the Alert component +jest.mock('@/src/components/Alert', () => { + const React = require('react') + const { View, Text } = require('react-native') + return { + Alert: ({ type, info, message }: MockAlertProps) => + React.createElement( + View, + { testID: 'alert', accessibilityLabel: `${type}-alert` }, + info && React.createElement(Text, { testID: 'alert-info' }, info), + React.createElement(Text, { testID: 'alert-message' }, message), + ), + } +}) + +describe('TransactionChecksBottomContent', () => { + const createSecurityState = (overrides: Partial<SecurityState> = {}): SecurityState => ({ + hasError: false, + isMediumRisk: false, + isHighRisk: false, + hasContractManagement: false, + isScanning: false, + enabled: true, + hasIssues: false, + hasWarnings: false, + error: undefined, + payload: null, + ...overrides, + }) + + it('should render nothing when no conditions are met', () => { + const security = createSecurityState() + const { queryByTestId } = render(<TransactionChecksBottomContent security={security} />) + + expect(queryByTestId('alert')).toBeFalsy() + }) + + it('should render error alert for high risk issues', () => { + const security = createSecurityState({ + hasIssues: true, + isHighRisk: true, + }) + const { getByTestId } = render(<TransactionChecksBottomContent security={security} />) + + const alert = getByTestId('alert') + expect(alert.props.accessibilityLabel).toBe('error-alert') + expect(getByTestId('alert-info')).toHaveTextContent('Potential risk detected') + expect(getByTestId('alert-message')).toHaveTextContent('Review details before signing') + }) + + it('should render warning alert for medium risk issues', () => { + const security = createSecurityState({ + hasIssues: true, + isMediumRisk: true, + }) + const { getByTestId } = render(<TransactionChecksBottomContent security={security} />) + + const alert = getByTestId('alert') + expect(alert.props.accessibilityLabel).toBe('warning-alert') + expect(getByTestId('alert-info')).toHaveTextContent('Potential risk detected') + expect(getByTestId('alert-message')).toHaveTextContent('Review details before signing') + }) + + it('should render info alert for low risk issues', () => { + const security = createSecurityState({ hasIssues: true }) + const { getByTestId } = render(<TransactionChecksBottomContent security={security} />) + + const alert = getByTestId('alert') + expect(alert.props.accessibilityLabel).toBe('info-alert') + expect(getByTestId('alert-info')).toHaveTextContent('Potential risk detected') + expect(getByTestId('alert-message')).toHaveTextContent('Review details before signing') + }) + + it('should render contract management warning', () => { + const security = createSecurityState({ hasContractManagement: true }) + const { getByTestId } = render(<TransactionChecksBottomContent security={security} />) + + const alert = getByTestId('alert') + expect(alert.props.accessibilityLabel).toBe('warning-alert') + expect(getByTestId('alert-info')).toHaveTextContent('Review details first') + expect(getByTestId('alert-message')).toHaveTextContent('Contract changes detected!') + }) + + it('should render error alert when security check fails', () => { + const security = createSecurityState({ error: new Error('Blockaid failed') }) + const { getByTestId } = render(<TransactionChecksBottomContent security={security} />) + + const alert = getByTestId('alert') + expect(alert.props.accessibilityLabel).toBe('warning-alert') + expect(getByTestId('alert-message')).toHaveTextContent('Proceed with caution') + expect(getByTestId('alert-info')).toHaveTextContent( + 'The transaction could not be checked for security alerts. Verify the details and addresses before proceeding.', + ) + }) + + it('should prioritize security issues over contract management', () => { + const security = createSecurityState({ + hasIssues: true, + hasContractManagement: true, + isMediumRisk: true, + }) + const { getByTestId } = render(<TransactionChecksBottomContent security={security} />) + + const alert = getByTestId('alert') + expect(alert.props.accessibilityLabel).toBe('warning-alert') + expect(getByTestId('alert-info')).toHaveTextContent('Potential risk detected') + expect(getByTestId('alert-message')).toHaveTextContent('Review details before signing') + }) + + it('should prioritize security issues over errors', () => { + const security = createSecurityState({ + hasIssues: true, + error: new Error('Test error'), + isHighRisk: true, + }) + const { getByTestId } = render(<TransactionChecksBottomContent security={security} />) + + const alert = getByTestId('alert') + expect(alert.props.accessibilityLabel).toBe('error-alert') + expect(getByTestId('alert-info')).toHaveTextContent('Potential risk detected') + }) + + it('should prioritize contract management over errors', () => { + const security = createSecurityState({ + hasContractManagement: true, + error: new Error('Test error'), + }) + const { getByTestId } = render(<TransactionChecksBottomContent security={security} />) + + const alert = getByTestId('alert') + expect(alert.props.accessibilityLabel).toBe('warning-alert') + expect(getByTestId('alert-info')).toHaveTextContent('Review details first') + expect(getByTestId('alert-message')).toHaveTextContent('Contract changes detected!') + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/components/__tests__/TransactionChecksLeftNode.test.tsx b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/components/__tests__/TransactionChecksLeftNode.test.tsx new file mode 100644 index 0000000000..2f9798a5e0 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/components/__tests__/TransactionChecksLeftNode.test.tsx @@ -0,0 +1,103 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { TransactionChecksLeftNode } from '../TransactionChecksLeftNode' +import { SecurityState } from '../../utils/transactionChecksUtils' + +interface MockSafeFontIconProps { + name: string + testID?: string +} + +interface MockCircleSnailProps { + size: number + borderWidth?: number + thickness?: number +} + +// Mock the external components +jest.mock('@/src/components/SafeFontIcon', () => { + const React = require('react') + const { View } = require('react-native') + return { + SafeFontIcon: ({ name, testID = 'safe-font-icon' }: MockSafeFontIconProps) => + React.createElement(View, { testID, accessibilityLabel: name }), + } +}) + +jest.mock('react-native-progress', () => { + const React = require('react') + const { View } = require('react-native') + return { + CircleSnail: ({ size }: MockCircleSnailProps) => + React.createElement(View, { testID: 'circle-snail', accessibilityLabel: `spinner-${size}` }), + } +}) + +describe('TransactionChecksLeftNode', () => { + const createSecurityState = (overrides: Partial<SecurityState> = {}): SecurityState => ({ + hasError: false, + isMediumRisk: false, + isHighRisk: false, + hasContractManagement: false, + isScanning: false, + enabled: true, + hasIssues: false, + hasWarnings: false, + error: undefined, + payload: null, + ...overrides, + }) + + it('should render CircleSnail when scanning', () => { + const security = createSecurityState({ isScanning: true }) + const { getByTestId } = render(<TransactionChecksLeftNode security={security} />) + + const spinner = getByTestId('circle-snail') + expect(spinner).toBeTruthy() + }) + + it('should render shield icon by default', () => { + const security = createSecurityState() + const { getByTestId } = render(<TransactionChecksLeftNode security={security} />) + + const icon = getByTestId('safe-font-icon') + expect(icon).toBeTruthy() + expect(icon.props.accessibilityLabel).toBe('shield') + }) + + it('should render shield-crossed icon when hasError', () => { + const security = createSecurityState({ hasError: true }) + const { getByTestId } = render(<TransactionChecksLeftNode security={security} />) + + const icon = getByTestId('safe-font-icon') + expect(icon.props.accessibilityLabel).toBe('shield-crossed') + }) + + it('should render alert-triangle icon when isMediumRisk', () => { + const security = createSecurityState({ isMediumRisk: true }) + const { getByTestId } = render(<TransactionChecksLeftNode security={security} />) + + const icon = getByTestId('safe-font-icon') + expect(icon.props.accessibilityLabel).toBe('alert-triangle') + }) + + it('should render alert-triangle icon when hasContractManagement', () => { + const security = createSecurityState({ hasContractManagement: true }) + const { getByTestId } = render(<TransactionChecksLeftNode security={security} />) + + const icon = getByTestId('safe-font-icon') + expect(icon.props.accessibilityLabel).toBe('alert-triangle') + }) + + it('should prioritize scanning state over icon state', () => { + const security = createSecurityState({ + isScanning: true, + hasError: true, + isMediumRisk: true, + }) + const { getByTestId, queryByTestId } = render(<TransactionChecksLeftNode security={security} />) + + expect(getByTestId('circle-snail')).toBeTruthy() + expect(queryByTestId('safe-font-icon')).toBeFalsy() + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/hooks/__tests__/useTransactionSecurity.test.ts b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/hooks/__tests__/useTransactionSecurity.test.ts new file mode 100644 index 0000000000..4721c7cf38 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/hooks/__tests__/useTransactionSecurity.test.ts @@ -0,0 +1,389 @@ +import { renderHook, waitFor } from '@testing-library/react-native' +import { useTransactionSecurity } from '../useTransactionSecurity' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useBlockaid } from '@/src/features/TransactionChecks/blockaid/useBlockaid' +import { useSafeInfo } from '@/src/hooks/useSafeInfo' +import { useHasFeature } from '@/src/hooks/useHasFeature' +import { createExistingTx } from '@/src/services/tx/tx-sender' +import extractTxInfo from '@/src/services/tx/extractTx' +import { SecuritySeverity } from '@safe-global/utils/services/security/modules/types' +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import type { SafeTransaction } from '@safe-global/types-kit' + +// Mock all dependencies +jest.mock('@/src/store/hooks/activeSafe') +jest.mock('@/src/features/TransactionChecks/blockaid/useBlockaid') +jest.mock('@/src/hooks/useSafeInfo') +jest.mock('@/src/hooks/useHasFeature') +jest.mock('@/src/services/tx/tx-sender') +jest.mock('@/src/services/tx/extractTx') + +const mockUseDefinedActiveSafe = useDefinedActiveSafe as jest.MockedFunction<typeof useDefinedActiveSafe> +const mockUseBlockaid = useBlockaid as jest.MockedFunction<typeof useBlockaid> +const mockUseSafeInfo = useSafeInfo as jest.MockedFunction<typeof useSafeInfo> +const mockUseHasFeature = useHasFeature as jest.MockedFunction<typeof useHasFeature> +const mockCreateExistingTx = createExistingTx as jest.MockedFunction<typeof createExistingTx> +const mockExtractTxInfo = extractTxInfo as jest.MockedFunction<typeof extractTxInfo> + +describe('useTransactionSecurity', () => { + const mockTxDetails: TransactionDetails = { + txId: 'test-tx-id', + safeAddress: '0xSafeAddress', + executedAt: null, + txStatus: 'AWAITING_CONFIRMATIONS', + txInfo: { + type: 'Transfer', + humanDescription: 'Test transaction', + sender: { value: '0xSender' }, + recipient: { value: '0xRecipient' }, + direction: 'OUTGOING', + transferInfo: { + type: 'NATIVE_COIN', + value: '1000000000000000000', + }, + }, + detailedExecutionInfo: { + type: 'MULTISIG', + submittedAt: Date.now(), + nonce: 1, + confirmationsRequired: 2, + confirmationsSubmitted: 1, + }, + } as unknown as TransactionDetails + + const mockScanTransaction = jest.fn() + const mockBlockaidPayload = { + severity: SecuritySeverity.MEDIUM, + payload: { + issues: [ + { + severity: SecuritySeverity.MEDIUM, + description: 'Test security issue', + }, + ], + contractManagement: [ + { + type: 'PROXY_UPGRADE' as const, + after: { address: '0x456' }, + before: { address: '0x789' }, + }, + ], + balanceChange: [], + error: undefined, + }, + } + + const mockSafeInfo = { + safe: { + owners: [{ value: '0xOwner1' }, { value: '0xOwner2' }], + }, + } as ReturnType<typeof useSafeInfo> + + const mockSafeTx = { + data: { + to: '0xRecipient', + value: '1000000000000000000', + data: '0x', + }, + } as SafeTransaction + + beforeEach(() => { + jest.clearAllMocks() + + mockUseDefinedActiveSafe.mockReturnValue({ + address: '0xSafeAddress', + chainId: '1', + }) + + mockUseSafeInfo.mockReturnValue(mockSafeInfo) + + mockUseHasFeature.mockReturnValue(true) + + mockUseBlockaid.mockReturnValue({ + scanTransaction: mockScanTransaction, + blockaidPayload: mockBlockaidPayload, + error: undefined, + loading: false, + resetBlockaid: jest.fn(), + }) + + mockExtractTxInfo.mockReturnValue({ + txParams: { + to: '0xRecipient', + value: '1000000000000000000', + data: '0x', + operation: 0, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: 1, + }, + signatures: {}, + }) + + mockCreateExistingTx.mockResolvedValue(mockSafeTx) + }) + + it('should return initial state when blockaid is disabled', () => { + mockUseHasFeature.mockReturnValue(false) + + const { result } = renderHook(() => useTransactionSecurity(mockTxDetails)) + + expect(result.current.enabled).toBe(false) + expect(result.current.isScanning).toBe(false) + expect(result.current.hasError).toBe(false) + expect(result.current.isHighRisk).toBe(false) // MEDIUM severity is not HIGH + expect(result.current.isMediumRisk).toBe(true) + expect(result.current.hasWarnings).toBe(true) + expect(result.current.hasIssues).toBe(true) + expect(result.current.hasContractManagement).toBe(true) + }) + + it('should not scan when txDetails is undefined', () => { + const { result } = renderHook(() => useTransactionSecurity(undefined)) + + expect(mockScanTransaction).not.toHaveBeenCalled() + expect(result.current.enabled).toBe(true) + }) + + it('should scan transaction when enabled and txDetails provided', async () => { + renderHook(() => useTransactionSecurity(mockTxDetails)) + + await waitFor(() => { + expect(mockScanTransaction).toHaveBeenCalledWith({ + data: expect.any(Object), + signer: '0xOwner1', + }) + }) + }) + + it('should handle scanning errors gracefully', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { + // Mock implementation for console.error + }) + mockCreateExistingTx.mockRejectedValue(new Error('Scan failed')) + + renderHook(() => useTransactionSecurity(mockTxDetails)) + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith('Error running blockaid scan:', expect.any(Error)) + }) + + consoleErrorSpy.mockRestore() + }) + + it('should correctly identify high risk transactions', () => { + mockUseBlockaid.mockReturnValue({ + scanTransaction: mockScanTransaction, + blockaidPayload: { + severity: SecuritySeverity.HIGH, + payload: { + issues: [], + contractManagement: [], + balanceChange: [], + error: undefined, + }, + }, + error: undefined, + loading: false, + resetBlockaid: jest.fn(), + }) + + const { result } = renderHook(() => useTransactionSecurity(mockTxDetails)) + + expect(result.current.isHighRisk).toBe(true) + expect(result.current.isMediumRisk).toBe(false) + }) + + it('should correctly identify medium risk transactions', () => { + mockUseBlockaid.mockReturnValue({ + scanTransaction: mockScanTransaction, + blockaidPayload: { + severity: SecuritySeverity.MEDIUM, + payload: { + issues: [], + contractManagement: [], + balanceChange: [], + error: undefined, + }, + }, + error: undefined, + loading: false, + resetBlockaid: jest.fn(), + }) + + const { result } = renderHook(() => useTransactionSecurity(mockTxDetails)) + + expect(result.current.isHighRisk).toBe(false) + expect(result.current.isMediumRisk).toBe(true) + }) + + it('should detect security issues correctly', () => { + mockUseBlockaid.mockReturnValue({ + scanTransaction: mockScanTransaction, + blockaidPayload: { + severity: SecuritySeverity.MEDIUM, + payload: { + issues: [ + { + severity: SecuritySeverity.MEDIUM, + description: 'Suspicious activity detected', + }, + ], + contractManagement: [], + balanceChange: [], + error: undefined, + }, + }, + error: undefined, + loading: false, + resetBlockaid: jest.fn(), + }) + + const { result } = renderHook(() => useTransactionSecurity(mockTxDetails)) + + expect(result.current.hasIssues).toBe(true) + expect(result.current.hasContractManagement).toBe(false) + expect(result.current.hasWarnings).toBe(true) + }) + + it('should detect contract management warnings correctly', () => { + mockUseBlockaid.mockReturnValue({ + scanTransaction: mockScanTransaction, + blockaidPayload: { + severity: SecuritySeverity.NONE, + payload: { + issues: [], + contractManagement: [ + { + type: 'OWNERSHIP_CHANGE' as const, + after: { owners: ['0x456'] }, + before: { owners: ['0x789'] }, + }, + ], + balanceChange: [], + error: undefined, + }, + }, + error: undefined, + loading: false, + resetBlockaid: jest.fn(), + }) + + const { result } = renderHook(() => useTransactionSecurity(mockTxDetails)) + + expect(result.current.hasIssues).toBe(false) + expect(result.current.hasContractManagement).toBe(true) + expect(result.current.hasWarnings).toBe(true) + }) + + it('should handle both issues and contract management warnings', () => { + mockUseBlockaid.mockReturnValue({ + scanTransaction: mockScanTransaction, + blockaidPayload: { + severity: SecuritySeverity.MEDIUM, + payload: { + issues: [ + { + severity: SecuritySeverity.MEDIUM, + description: 'Security issue', + }, + ], + contractManagement: [ + { + type: 'MODULES_CHANGE' as const, + after: { modules: ['0x123'] }, + before: { modules: ['0x456'] }, + }, + ], + balanceChange: [], + error: undefined, + }, + }, + error: undefined, + loading: false, + resetBlockaid: jest.fn(), + }) + + const { result } = renderHook(() => useTransactionSecurity(mockTxDetails)) + + expect(result.current.hasIssues).toBe(true) + expect(result.current.hasContractManagement).toBe(true) + expect(result.current.hasWarnings).toBe(true) + }) + + it('should handle scanning state correctly', () => { + mockUseBlockaid.mockReturnValue({ + scanTransaction: mockScanTransaction, + blockaidPayload: undefined, + error: undefined, + loading: true, + resetBlockaid: jest.fn(), + }) + + const { result } = renderHook(() => useTransactionSecurity(mockTxDetails)) + + expect(result.current.isScanning).toBe(true) + expect(result.current.payload).toBeUndefined() + }) + + it('should handle blockaid errors correctly', () => { + const mockError = new Error('Blockaid API error') + mockUseBlockaid.mockReturnValue({ + scanTransaction: mockScanTransaction, + blockaidPayload: undefined, + error: mockError, + loading: false, + resetBlockaid: jest.fn(), + }) + + const { result } = renderHook(() => useTransactionSecurity(mockTxDetails)) + + expect(result.current.hasError).toBe(true) + expect(result.current.error).toBe(mockError) + }) + + it('should not scan twice on re-renders', async () => { + const { rerender } = renderHook((txDetails: TransactionDetails | undefined) => useTransactionSecurity(txDetails), { + initialProps: mockTxDetails, + }) + + await waitFor(() => { + expect(mockScanTransaction).toHaveBeenCalledTimes(1) + }) + + // Re-render the hook + rerender(mockTxDetails) + + // Should not scan again + expect(mockScanTransaction).toHaveBeenCalledTimes(1) + }) + + it('should handle empty scan results correctly', () => { + mockUseBlockaid.mockReturnValue({ + scanTransaction: mockScanTransaction, + blockaidPayload: { + severity: SecuritySeverity.NONE, + payload: { + issues: [], + contractManagement: [], + balanceChange: [], + error: undefined, + }, + }, + error: undefined, + loading: false, + resetBlockaid: jest.fn(), + }) + + const { result } = renderHook(() => useTransactionSecurity(mockTxDetails)) + + expect(result.current.isHighRisk).toBe(false) + expect(result.current.isMediumRisk).toBe(false) + expect(result.current.hasIssues).toBe(false) + expect(result.current.hasContractManagement).toBe(false) + expect(result.current.hasWarnings).toBe(false) + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/hooks/useTransactionSecurity.ts b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/hooks/useTransactionSecurity.ts new file mode 100644 index 0000000000..526b1d33b0 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/hooks/useTransactionSecurity.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useBlockaid } from '@/src/features/TransactionChecks/blockaid/useBlockaid' +import { createExistingTx } from '@/src/services/tx/tx-sender' +import extractTxInfo from '@/src/services/tx/extractTx' +import { useSafeInfo } from '@/src/hooks/useSafeInfo' +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { FEATURES } from '@safe-global/utils/utils/chains' +import { useHasFeature } from '@/src/hooks/useHasFeature' +import { SecuritySeverity } from '@safe-global/utils/services/security/modules/types' + +export const useTransactionSecurity = (txDetails?: TransactionDetails) => { + const activeSafe = useDefinedActiveSafe() + const safeInfo = useSafeInfo() + const blockaidEnabled = useHasFeature(FEATURES.RISK_MITIGATION) ?? false + const [hasScanned, setHasScanned] = useState(false) + + const { scanTransaction, blockaidPayload, error: blockaidError, loading: blockaidLoading } = useBlockaid() + + useEffect(() => { + const runBlockaidScan = async () => { + if (!blockaidEnabled || !txDetails || hasScanned) { + return + } + + try { + const { txParams, signatures } = extractTxInfo(txDetails, activeSafe.address) + const safeTx = await createExistingTx(txParams, signatures) + const executionOwner = safeInfo.safe.owners[0].value + + await scanTransaction({ + data: safeTx, + signer: executionOwner, + }) + + setHasScanned(true) + } catch (error) { + console.error('Error running blockaid scan:', error) + setHasScanned(true) + } + } + + runBlockaidScan() + }, [blockaidEnabled, txDetails, hasScanned, activeSafe.address, safeInfo.safe.owners, scanTransaction]) + + // Process scan results + const isHighRisk = blockaidPayload?.severity === SecuritySeverity.HIGH + const isMediumRisk = blockaidPayload?.severity === SecuritySeverity.MEDIUM + const hasIssues = Boolean(blockaidPayload?.payload?.issues && blockaidPayload.payload.issues.length > 0) + const hasContractManagement = Boolean( + blockaidPayload?.payload?.contractManagement && blockaidPayload.payload.contractManagement.length > 0, + ) + const hasWarnings = hasIssues || hasContractManagement + + return { + enabled: blockaidEnabled, + isScanning: blockaidLoading, + hasError: Boolean(blockaidError), + payload: blockaidPayload, + error: blockaidError, + isHighRisk, + isMediumRisk, + hasWarnings, + hasIssues, + hasContractManagement, + } +} diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/index.ts b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/index.ts new file mode 100644 index 0000000000..87631768ae --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/index.ts @@ -0,0 +1,4 @@ +export { TransactionChecks } from './TransactionChecks' +export { TransactionChecksLeftNode } from './components/TransactionChecksLeftNode' +export { TransactionChecksBottomContent } from './components/TransactionChecksBottomContent' +export * from './utils/transactionChecksUtils' diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/utils/__tests__/transactionChecksUtils.test.ts b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/utils/__tests__/transactionChecksUtils.test.ts new file mode 100644 index 0000000000..06b20fd00c --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/utils/__tests__/transactionChecksUtils.test.ts @@ -0,0 +1,128 @@ +import { + getTransactionChecksIcon, + getTransactionChecksLabel, + getAlertType, + shouldShowBottomContent, + SecurityState, +} from '../transactionChecksUtils' + +describe('transactionChecksUtils', () => { + const createSecurityState = (overrides: Partial<SecurityState> = {}): SecurityState => ({ + hasError: false, + isMediumRisk: false, + isHighRisk: false, + hasContractManagement: false, + isScanning: false, + enabled: true, + hasIssues: false, + hasWarnings: false, + error: undefined, + payload: null, + ...overrides, + }) + + describe('getTransactionChecksIcon', () => { + it('should return shield-crossed when hasError is true', () => { + const security = createSecurityState({ hasError: true }) + expect(getTransactionChecksIcon(security)).toBe('shield-crossed') + }) + + it('should return alert-triangle when isMediumRisk is true', () => { + const security = createSecurityState({ isMediumRisk: true }) + expect(getTransactionChecksIcon(security)).toBe('alert-triangle') + }) + + it('should return alert-triangle when hasContractManagement is true', () => { + const security = createSecurityState({ hasContractManagement: true }) + expect(getTransactionChecksIcon(security)).toBe('alert-triangle') + }) + + it('should return shield as default', () => { + const security = createSecurityState() + expect(getTransactionChecksIcon(security)).toBe('shield') + }) + + it('should prioritize hasError over other conditions', () => { + const security = createSecurityState({ + hasError: true, + isMediumRisk: true, + hasContractManagement: true, + }) + expect(getTransactionChecksIcon(security)).toBe('shield-crossed') + }) + }) + + describe('getTransactionChecksLabel', () => { + it('should return scanning message when isScanning is true', () => { + expect(getTransactionChecksLabel(true)).toBe('Checking transaction...') + }) + + it('should return default message when isScanning is false', () => { + expect(getTransactionChecksLabel(false)).toBe('Transaction checks') + }) + }) + + describe('getAlertType', () => { + it('should return error when isHighRisk is true', () => { + const security = createSecurityState({ isHighRisk: true }) + expect(getAlertType(security)).toBe('error') + }) + + it('should return warning when isMediumRisk is true', () => { + const security = createSecurityState({ isMediumRisk: true }) + expect(getAlertType(security)).toBe('warning') + }) + + it('should return info as default', () => { + const security = createSecurityState() + expect(getAlertType(security)).toBe('info') + }) + + it('should prioritize isHighRisk over isMediumRisk', () => { + const security = createSecurityState({ + isHighRisk: true, + isMediumRisk: true, + }) + expect(getAlertType(security)).toBe('error') + }) + }) + + describe('shouldShowBottomContent', () => { + it('should return false when security is disabled', () => { + const security = createSecurityState({ + enabled: false, + hasIssues: true, + }) + expect(shouldShowBottomContent(security)).toBe(false) + }) + + it('should return true when hasIssues is true and enabled', () => { + const security = createSecurityState({ hasIssues: true }) + expect(shouldShowBottomContent(security)).toBe(true) + }) + + it('should return true when hasContractManagement is true and enabled', () => { + const security = createSecurityState({ hasContractManagement: true }) + expect(shouldShowBottomContent(security)).toBe(true) + }) + + it('should return true when error exists and enabled', () => { + const security = createSecurityState({ error: new Error('Test error') }) + expect(shouldShowBottomContent(security)).toBe(true) + }) + + it('should return false when no conditions are met', () => { + const security = createSecurityState() + expect(shouldShowBottomContent(security)).toBe(false) + }) + + it('should return true when multiple conditions are true', () => { + const security = createSecurityState({ + hasIssues: true, + hasContractManagement: true, + error: new Error('Test error'), + }) + expect(shouldShowBottomContent(security)).toBe(true) + }) + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/utils/transactionChecksUtils.ts b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/utils/transactionChecksUtils.ts new file mode 100644 index 0000000000..24e50a409a --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionChecks/utils/transactionChecksUtils.ts @@ -0,0 +1,49 @@ +import { IconName } from '@/src/types/iconTypes' +import { AlertType } from '@/src/components/Alert' + +export interface SecurityState { + enabled: boolean + isScanning: boolean + hasError: boolean + payload: unknown + error: Error | undefined + isHighRisk: boolean + isMediumRisk: boolean + hasWarnings: boolean + hasIssues: boolean + hasContractManagement: boolean +} + +export const getTransactionChecksIcon = (security: SecurityState): IconName => { + if (security.hasError) { + return 'shield-crossed' + } + if (security.isMediumRisk || security.hasContractManagement) { + return 'alert-triangle' + } + return 'shield' +} + +export const getTransactionChecksLabel = (isScanning: boolean): string => { + if (isScanning) { + return 'Checking transaction...' + } + return 'Transaction checks' +} + +export const getAlertType = (security: SecurityState): AlertType => { + if (security.isHighRisk) { + return 'error' + } + if (security.isMediumRisk) { + return 'warning' + } + return 'info' +} + +export const shouldShowBottomContent = (security: SecurityState): boolean => { + if (!security.enabled) { + return false + } + return security.hasIssues || security.hasContractManagement || !!security.error +} diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionHeader/TransactionHeader.tsx b/apps/mobile/src/features/ConfirmTx/components/TransactionHeader/TransactionHeader.tsx new file mode 100644 index 0000000000..6eab752d59 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionHeader/TransactionHeader.tsx @@ -0,0 +1,65 @@ +import { H3, Text, View } from 'tamagui' +import { Logo } from '@/src/components/Logo' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import React from 'react' +import { YStack } from 'tamagui' +import { IconName } from '@/src/types/iconTypes' +import { BadgeThemeTypes } from '@/src/components/Logo/Logo' +import { Identicon } from '@/src/components/Identicon' +import { Address } from 'blo' +import { formatWithSchema } from '@/src/utils/date' + +interface TransactionHeaderProps { + logo?: string + customLogo?: React.ReactNode + badgeIcon: IconName + badgeThemeName?: BadgeThemeTypes + badgeColor: string + title: string | React.ReactNode + isIdenticon?: boolean + submittedAt: number +} + +export function TransactionHeader({ + logo, + customLogo, + badgeIcon, + badgeThemeName, + badgeColor, + title, + isIdenticon, + submittedAt, +}: TransactionHeaderProps) { + const date = formatWithSchema(submittedAt, 'd MMM yyyy') + const time = formatWithSchema(submittedAt, 'hh:mm a') + + return ( + <YStack position="relative" alignItems="center" gap="$2" marginTop="$4"> + {isIdenticon ? ( + <Identicon address={logo as Address} size={44} /> + ) : ( + (customLogo ?? ( + <Logo + logoUri={logo} + size="$10" + badgeContent={<SafeFontIcon name={badgeIcon} color={badgeColor} size={12} />} + badgeThemeName={badgeThemeName} + /> + )) + )} + + <View alignItems="center" gap="$2"> + {typeof title === 'string' ? ( + <H3 fontWeight={600} fontSize="$7"> + {title} + </H3> + ) : ( + title + )} + <Text color="$textSecondaryLight" fontSize="$2" lineHeight={16}> + {date}, {time} + </Text> + </View> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionHeader/index.tsx b/apps/mobile/src/features/ConfirmTx/components/TransactionHeader/index.tsx new file mode 100644 index 0000000000..14cd8eccc1 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionHeader/index.tsx @@ -0,0 +1 @@ +export { TransactionHeader } from './TransactionHeader' diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionInfo/TransactionInfo.tsx b/apps/mobile/src/features/ConfirmTx/components/TransactionInfo/TransactionInfo.tsx new file mode 100644 index 0000000000..3dca8a8063 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionInfo/TransactionInfo.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { YStack } from 'tamagui' +import { MultisigExecutionDetails, TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TransactionChecks } from '../TransactionChecks' +import { ConfirmationsInfo } from '../ConfirmationsInfo' + +export function TransactionInfo({ + detailedExecutionInfo, + txId, + txDetails, +}: { + detailedExecutionInfo: MultisigExecutionDetails + txId: string + txDetails?: TransactionDetails +}) { + return ( + <YStack paddingHorizontal="$4" gap="$4" marginTop="$4"> + <TransactionChecks txId={txId} txDetails={txDetails} /> + + <ConfirmationsInfo detailedExecutionInfo={detailedExecutionInfo} txId={txId} /> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/TransactionInfo/index.ts b/apps/mobile/src/features/ConfirmTx/components/TransactionInfo/index.ts new file mode 100644 index 0000000000..6875a483af --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/TransactionInfo/index.ts @@ -0,0 +1 @@ +export { TransactionInfo } from './TransactionInfo' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AddSigner/AddSigner.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AddSigner/AddSigner.tsx new file mode 100644 index 0000000000..e452e300e2 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AddSigner/AddSigner.tsx @@ -0,0 +1,48 @@ +import React, { useMemo } from 'react' +import { YStack } from 'tamagui' +import { formatAddSignerItems, getSignerName } from './utils' +import { MultisigExecutionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useAppSelector } from '@/src/store/hooks' +import { RootState } from '@/src/store' +import { selectChainById } from '@/src/store/chains' + +import { ListTable } from '../../ListTable' +import { TransactionHeader } from '../../TransactionHeader' +import { ParametersButton } from '../../ParametersButton' +import { NormalizedSettingsChangeTransaction } from '../../ConfirmationView/types' +import { useOpenExplorer } from '@/src/features/ConfirmTx/hooks/useOpenExplorer' + +interface AddSignerProps { + txInfo: NormalizedSettingsChangeTransaction + executionInfo: MultisigExecutionDetails + txId: string +} + +export function AddSigner({ txInfo, executionInfo, txId }: AddSignerProps) { + const activeSafe = useDefinedActiveSafe() + const activeChain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + const viewOnExplorer = useOpenExplorer(txInfo.settingsInfo?.owner?.value) + const items = useMemo( + () => formatAddSignerItems(txInfo, activeChain, executionInfo, viewOnExplorer), + [txInfo, activeChain, executionInfo, viewOnExplorer], + ) + const newSignerAddress = getSignerName(txInfo) + + return ( + <YStack gap="$4"> + <TransactionHeader + submittedAt={executionInfo.submittedAt} + logo={txInfo.settingsInfo?.owner?.value} + isIdenticon + badgeIcon="transaction-contract" + badgeColor="$textSecondaryLight" + title={newSignerAddress} + /> + + <ListTable items={items}> + <ParametersButton txId={txId} /> + </ListTable> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AddSigner/index.ts b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AddSigner/index.ts new file mode 100644 index 0000000000..f7654c7ad4 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AddSigner/index.ts @@ -0,0 +1 @@ +export { AddSigner } from './AddSigner' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AddSigner/utils.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AddSigner/utils.tsx new file mode 100644 index 0000000000..8a5702ea41 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AddSigner/utils.tsx @@ -0,0 +1,80 @@ +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Logo } from '@/src/components/Logo' +import { ellipsis } from '@/src/utils/formatters' +import { Text, View } from 'tamagui' + +import { MultisigExecutionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { shortenAddress } from '@safe-global/utils/utils/formatters' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { Identicon } from '@/src/components/Identicon' +import { NormalizedSettingsChangeTransaction } from '../../ConfirmationView/types' +import { CopyButton } from '@/src/components/CopyButton' +import { TouchableOpacity } from 'react-native' + +export const getSignerName = (txInfo: NormalizedSettingsChangeTransaction) => { + if (!txInfo.settingsInfo) { + return '' + } + + const newSigner = 'owner' in txInfo.settingsInfo && txInfo.settingsInfo.owner + + if (!newSigner) { + return '' + } + + return newSigner.name ? ellipsis(newSigner.name, 18) : shortenAddress(newSigner.value) +} + +export const formatAddSignerItems = ( + txInfo: NormalizedSettingsChangeTransaction, + chain: Chain, + executionInfo: MultisigExecutionDetails, + viewOnExplorer: () => void, +) => { + const newSignerAddress = getSignerName(txInfo) + + const items = [ + { + label: 'New signer', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Identicon address={txInfo.settingsInfo?.owner?.value} size={24} /> + <Text fontSize="$4">{newSignerAddress}</Text> + <CopyButton value={txInfo.settingsInfo?.owner?.value} color={'$textSecondaryLight'} /> + + <TouchableOpacity onPress={viewOnExplorer}> + <SafeFontIcon name="external-link" size={14} color="$textSecondaryLight" /> + </TouchableOpacity> + </View> + ), + }, + { + label: 'Network', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Logo logoUri={chain.chainLogoUri} size="$6" /> + <Text fontSize="$4">{chain.chainName}</Text> + </View> + ), + }, + ] + + const hasThresholdChanged = txInfo.settingsInfo?.threshold !== executionInfo.confirmationsRequired + if (hasThresholdChanged) { + items.push({ + label: 'Threshold change', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Text fontSize="$4"> + {txInfo.settingsInfo?.threshold}/{executionInfo.signers.length} + </Text> + <Text textDecorationLine="line-through" color="$textSecondaryLight" fontSize="$4"> + {executionInfo.confirmationsRequired}/{executionInfo.signers.length} + </Text> + </View> + ), + }) + } + + return items +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AlreadySigned/AlreadySigned.test.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AlreadySigned/AlreadySigned.test.tsx new file mode 100644 index 0000000000..99f33d4df8 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AlreadySigned/AlreadySigned.test.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import { render, fireEvent } from '@/src/tests/test-utils' +import { AlreadySigned } from './AlreadySigned' +import { Linking } from 'react-native' +import { GATEWAY_URL, SAFE_WEB_TRANSACTIONS_URL } from '@/src/config/constants' +import { http, HttpResponse } from 'msw' +import { server } from '@/src/tests/server' +import { apiSliceWithChainsConfig } from '@safe-global/store/gateway/chains' +import { makeStore } from '@/src/store' + +// Mock Linking.openURL +const mockOpenURL = jest.fn() +Linking.openURL = mockOpenURL + +describe('AlreadySigned', () => { + const mockProps = { + txId: 'test-tx-id', + safeAddress: '0x123', + chainId: '1', + } + + const mockChain = { + shortName: 'eth', + } + + beforeEach(() => { + jest.clearAllMocks() + server.use( + http.get(`${GATEWAY_URL}/v1/chains`, () => { + return HttpResponse.json({ + results: [ + { + chainId: '1', + shortName: 'eth', + }, + ], + }) + }), + ) + }) + + const renderWithStore = async (ui: React.ReactElement) => { + const store = makeStore() + await store.dispatch(apiSliceWithChainsConfig.endpoints.getChainsConfig.initiate()) + return render(ui, { initialStore: store.getState() }) + } + + it('renders correctly with all required elements', async () => { + const { getByText } = await renderWithStore(<AlreadySigned hasEnoughConfirmations={true} {...mockProps} />) + + expect(getByText('This transaction can be executed in the web app only.')).toBeTruthy() + expect(getByText('Go to Web app')).toBeTruthy() + expect(getByText('Confirm')).toBeTruthy() + }) + + it('opens web app URL when "Go to Web app" is pressed', async () => { + const { getByText } = await renderWithStore(<AlreadySigned hasEnoughConfirmations={true} {...mockProps} />) + + const expectedUrl = SAFE_WEB_TRANSACTIONS_URL.replace( + ':safeAddressWithChainPrefix', + `${mockChain.shortName}:${mockProps.safeAddress}`, + ).replace(':txId', mockProps.txId) + + fireEvent.press(getByText('Go to Web app')) + expect(mockOpenURL).toHaveBeenCalledWith(expectedUrl) + }) + + it('renders correctly with all required elements', async () => { + const { getByText } = await renderWithStore(<AlreadySigned hasEnoughConfirmations={false} {...mockProps} />) + + expect(getByText('Can be executed once the threshold is reached')).toBeTruthy() + }) + + it('matches snapshot', async () => { + const { toJSON } = await renderWithStore(<AlreadySigned hasEnoughConfirmations={true} {...mockProps} />) + expect(toJSON()).toMatchSnapshot() + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AlreadySigned/AlreadySigned.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AlreadySigned/AlreadySigned.tsx new file mode 100644 index 0000000000..0111572176 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AlreadySigned/AlreadySigned.tsx @@ -0,0 +1,56 @@ +import { SafeButton } from '@/src/components/SafeButton' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { SAFE_WEB_TRANSACTIONS_URL } from '@/src/config/constants' +import { selectChainById } from '@/src/store/chains' +import { useAppSelector } from '@/src/store/hooks' +import React, { useCallback } from 'react' +import { Linking, TouchableOpacity } from 'react-native' +import { Text, View, YStack } from 'tamagui' + +type Props = { + txId: string + safeAddress: string + chainId: string + hasEnoughConfirmations: boolean +} + +export function AlreadySigned({ txId, safeAddress, chainId, hasEnoughConfirmations }: Props) { + const chain = useAppSelector((state) => selectChainById(state, chainId)) + const onPressGoToWebApp = useCallback(() => { + const url = SAFE_WEB_TRANSACTIONS_URL.replace( + ':safeAddressWithChainPrefix', + `${chain?.shortName}:${safeAddress}`, + ).replace(':txId', txId) + + Linking.openURL(url) + }, [txId, safeAddress, chainId]) + + return ( + <YStack justifyContent="center" gap="$4" alignItems="center" paddingHorizontal={'$4'}> + <Text fontSize="$4" fontWeight={400} textAlign="center" color="$textSecondaryLight"> + {hasEnoughConfirmations + ? 'This transaction can be executed in the web app only.' + : 'Can be executed once the threshold is reached'} + </Text> + + {hasEnoughConfirmations && ( + <> + <TouchableOpacity onPress={onPressGoToWebApp}> + <View flexDirection="row" alignItems="center" gap="$2"> + <Text fontSize="$4" fontWeight={700} textAlign="center" color="$color"> + Go to Web app + </Text> + <SafeFontIcon name="external-link" size={16} color="$color" /> + </View> + </TouchableOpacity> + + <View height={50} width="100%"> + <SafeButton height="100%" rounded fullscreen fontWeight={600} disabled testID="confirm-button"> + Confirm + </SafeButton> + </View> + </> + )} + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AlreadySigned/__snapshots__/AlreadySigned.test.tsx.snap b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AlreadySigned/__snapshots__/AlreadySigned.test.tsx.snap new file mode 100644 index 0000000000..7f13e6d581 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AlreadySigned/__snapshots__/AlreadySigned.test.tsx.snap @@ -0,0 +1,189 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AlreadySigned matches snapshot 1`] = ` +<View> + <View + style={ + { + "alignItems": "center", + "flexDirection": "column", + "gap": 16, + "justifyContent": "center", + "paddingLeft": 16, + "paddingRight": 16, + } + } + > + <Text + style={ + { + "color": "#A1A3A7", + "fontFamily": "DM Sans", + "fontSize": 14, + "textAlign": "center", + } + } + suppressHighlighting={true} + > + This transaction can be executed in the web app only. + </Text> + <View + accessibilityState={ + { + "busy": undefined, + "checked": undefined, + "disabled": undefined, + "expanded": undefined, + "selected": undefined, + } + } + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + onClick={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "opacity": 1, + } + } + > + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "gap": 8, + } + } + > + <Text + style={ + { + "color": "#121312", + "fontFamily": "DMSans-Bold", + "fontSize": 14, + "textAlign": "center", + } + } + suppressHighlighting={true} + > + Go to Web app + </Text> + <Text + allowFontScaling={false} + selectable={false} + style={ + [ + { + "color": "#121312", + "fontSize": 16, + }, + undefined, + { + "fontFamily": "SafeIcons", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + </Text> + </View> + </View> + <View + style={ + { + "height": 50, + "width": "100%", + } + } + > + <View + focusVisibleStyle={ + { + "borderColor": "$background", + } + } + pointerEvents="none" + role="button" + style={ + { + "alignItems": "center", + "backgroundColor": "#7878801F", + "borderBottomColor": "transparent", + "borderBottomLeftRadius": 8, + "borderBottomRightRadius": 8, + "borderBottomWidth": 1, + "borderLeftColor": "transparent", + "borderLeftWidth": 1, + "borderRightColor": "transparent", + "borderRightWidth": 1, + "borderStyle": "solid", + "borderTopColor": "transparent", + "borderTopLeftRadius": 8, + "borderTopRightRadius": 8, + "borderTopWidth": 1, + "bottom": 0, + "color": "#636669", + "flexDirection": "row", + "flexWrap": "nowrap", + "fontWeight": 600, + "height": "100%", + "justifyContent": "center", + "left": 0, + "marginBottom": 0, + "marginLeft": 0, + "marginRight": 0, + "marginTop": 0, + "paddingBottom": 14, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 14, + "position": "absolute", + "right": 0, + "top": 0, + } + } + testID="confirm-button" + > + <Text + lineBreakMode="clip" + numberOfLines={1} + style={ + { + "color": "#636669", + "flexGrow": 0, + "flexShrink": 1, + "fontFamily": "DMSans-SemiBold", + "fontSize": 14, + "letterSpacing": -0.1, + "lineHeight": 15.400000000000002, + "marginBottom": -2.5, + "userSelect": "none", + } + } + suppressHighlighting={true} + > + Confirm + </Text> + </View> + </View> + </View> +</View> +`; diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AlreadySigned/index.ts b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AlreadySigned/index.ts new file mode 100644 index 0000000000..4ac5419271 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/AlreadySigned/index.ts @@ -0,0 +1 @@ +export { AlreadySigned } from './AlreadySigned' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/BridgeTransaction/BridgeRecipientWarnings.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/BridgeTransaction/BridgeRecipientWarnings.tsx new file mode 100644 index 0000000000..e24d2ac25f --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/BridgeTransaction/BridgeRecipientWarnings.tsx @@ -0,0 +1,117 @@ +import React, { useMemo } from 'react' +import { Alert2 } from '@/src/components/Alert2' +import { BridgeAndSwapTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useAppSelector } from '@/src/store/hooks' +import { selectAllChains } from '@/src/store/chains' +import { selectSafeInfo } from '@/src/store/safesSlice' +import { selectContactByAddress } from '@/src/store/addressBookSlice' +import { useSafesGetSafeV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import { haveSameSetup } from '@safe-global/utils/utils/safe-setup-comparison' +import { Address } from '@/src/types/address' +import { type BridgeWarning } from '@safe-global/utils/components/confirmation-views/BridgeTransaction/BridgeWarnings' +import { + useBridgeWarningLogic, + type BridgeWarningData, +} from '@safe-global/utils/components/confirmation-views/BridgeTransaction/useBridgeWarningLogic' + +interface WarningAlertProps { + warning: BridgeWarning +} + +const WarningAlert = ({ warning }: WarningAlertProps) => ( + <Alert2 + type={warning.severity} + title={warning.title} + message={warning.description} + testID={`bridge-warning-${warning.severity}`} + /> +) + +interface BridgeRecipientWarningsProps { + txInfo: BridgeAndSwapTransactionInfo +} + +export const BridgeRecipientWarnings = ({ txInfo }: BridgeRecipientWarningsProps) => { + const activeSafe = useDefinedActiveSafe() + const allChains = useAppSelector(selectAllChains) + const activeSafeInfo = useAppSelector((state) => selectSafeInfo(state, activeSafe.address as Address)) + const destinationContact = useAppSelector((state) => selectContactByAddress(txInfo.recipient.value)(state)) + + const isSameAddress = sameAddress(txInfo.recipient.value, activeSafe.address) + + // Check if destination chain is supported + const isDestinationChainSupported = allChains?.some((chain) => chain.chainId === txInfo.toChain) ?? false + + // For simplicity in mobile, we'll assume modern Safes support multichain + // This could be enhanced with version checking in the future + const isMultiChainSafe = true + + const { data: otherSafe, error: otherSafeError } = useSafesGetSafeV1Query( + { chainId: txInfo.toChain, safeAddress: activeSafe.address }, + { skip: !isSameAddress }, + ) + + const otherSafeExists = otherSafe !== undefined + + const hasSameSetup = useMemo(() => { + if (!otherSafeExists || otherSafeError || !activeSafeInfo) { + return false + } + + // Get safe info for current chain + const currentSafeInfo = activeSafeInfo[activeSafe.chainId] + if (!currentSafeInfo) { + return false + } + + return haveSameSetup(otherSafe, currentSafeInfo) + }, [otherSafeExists, otherSafeError, activeSafeInfo, activeSafe.chainId, otherSafe]) + + // Check if recipient is in address book + const isRecipientInAddressBook = destinationContact !== null + + // Check if recipient is an owned safe on destination chain + const isRecipientOwnedSafe = useMemo(() => { + if (!activeSafeInfo) { + return false + } + + // Check if we have this address as a safe on the destination chain + const destinationSafeInfo = activeSafeInfo[txInfo.toChain] + return destinationSafeInfo !== undefined && sameAddress(destinationSafeInfo.address.value, txInfo.recipient.value) + }, [activeSafeInfo, txInfo.toChain, txInfo.recipient.value]) + + // Prepare data for shared warning logic + const warningData: BridgeWarningData = useMemo( + () => ({ + isSameAddress, + isDestinationChainSupported, + isMultiChainSafe, + otherSafeExists, + hasSameSetup, + isRecipientInAddressBook, + isRecipientOwnedSafe, + }), + [ + isSameAddress, + isDestinationChainSupported, + isMultiChainSafe, + otherSafeExists, + hasSameSetup, + isRecipientInAddressBook, + isRecipientOwnedSafe, + ], + ) + + // Use shared warning logic + const warning = useBridgeWarningLogic(warningData) + + // Render warning if one exists + if (warning) { + return <WarningAlert warning={warning} /> + } + + return null +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/BridgeTransaction/BridgeTransaction.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/BridgeTransaction/BridgeTransaction.tsx new file mode 100644 index 0000000000..c0025ea719 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/BridgeTransaction/BridgeTransaction.tsx @@ -0,0 +1,195 @@ +import React, { useMemo } from 'react' +import { YStack, Text, View } from 'tamagui' +import { ListTable } from '../../ListTable' +import { BridgeAndSwapTransactionInfo, DataDecoded } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useAppSelector } from '@/src/store/hooks' +import { selectChainById } from '@/src/store/chains' +import { TokenAmount } from '@/src/components/TokenAmount' +import { formatUnits } from 'ethers' +import { EthAddress } from '@/src/components/EthAddress' +import { type ListTableItem } from '../../ListTable' +import { BridgeRecipientWarnings } from './BridgeRecipientWarnings' +import { ChainIndicator } from '@/src/components/ChainIndicator' +import { ParametersButton } from '../../ParametersButton' +import { useRouter } from 'expo-router' +import { isMultiSendData } from '@/src/utils/transaction-guards' +import { SafeListItem } from '@/src/components/SafeListItem' +import { Badge } from '@/src/components/Badge' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { formatAmount } from '@safe-global/utils/utils/formatNumber' + +interface BridgeTransactionProps { + txId: string + txInfo: BridgeAndSwapTransactionInfo + decodedData?: DataDecoded | null +} + +export function BridgeTransaction({ txId, txInfo, decodedData }: BridgeTransactionProps) { + const activeSafe = useDefinedActiveSafe() + const chain = useAppSelector((state) => selectChainById(state, activeSafe.chainId)) + const router = useRouter() + + const bridgeItems = useMemo(() => { + const items: ListTableItem[] = [] + + // Amount section + const actualFromAmount = + BigInt(txInfo.fromAmount) + BigInt(txInfo.fees?.integratorFee ?? 0n) + BigInt(txInfo.fees?.lifiFee ?? 0n) + + if (txInfo.status === 'PENDING' || txInfo.status === 'AWAITING_EXECUTION') { + items.push({ + label: 'Amount', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2" flexWrap="wrap" justifyContent="center"> + <Text>Sending</Text> + <TokenAmount + value={actualFromAmount.toString()} + decimals={txInfo.fromToken.decimals} + tokenSymbol={txInfo.fromToken.symbol} + /> + <Text>to</Text> + <ChainIndicator chainId={txInfo.toChain} onlyLogo /> + </View> + ), + }) + } else if (txInfo.status === 'FAILED') { + items.push({ + label: 'Amount', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2" flexWrap="wrap"> + <Text>Failed to send</Text> + <TokenAmount + value={actualFromAmount.toString()} + decimals={txInfo.fromToken.decimals} + tokenSymbol={txInfo.fromToken.symbol} + /> + <Text>to {txInfo.toChain}</Text> + </View> + ), + }) + + if (txInfo.substatus) { + items.push({ + label: 'Substatus', + render: () => <Text>{txInfo.substatus}</Text>, + }) + } + } else if (txInfo.status === 'DONE') { + const fromAmountDecimals = formatUnits(actualFromAmount, txInfo.fromToken.decimals) + const toAmountDecimals = + txInfo.toAmount && txInfo.toToken ? formatUnits(txInfo.toAmount, txInfo.toToken.decimals) : undefined + const exchangeRate = toAmountDecimals ? Number(toAmountDecimals) / Number(fromAmountDecimals) : undefined + + items.push({ + label: 'Amount', + render: () => ( + <YStack gap="$2"> + <View flexDirection="row" alignItems="center" gap="$2" flexWrap="wrap"> + <Text>Sell</Text> + <TokenAmount + value={actualFromAmount.toString()} + decimals={txInfo.fromToken.decimals} + tokenSymbol={txInfo.fromToken.symbol} + /> + <Text>on {chain?.chainName ?? 'Unknown Chain'}</Text> + </View> + {txInfo.toToken && txInfo.toAmount ? ( + <View flexDirection="row" alignItems="center" gap="$2" flexWrap="wrap"> + <Text>For</Text> + <TokenAmount + value={txInfo.toAmount} + decimals={txInfo.toToken.decimals} + tokenSymbol={txInfo.toToken.symbol} + /> + <Text>on {txInfo.toChain}</Text> + </View> + ) : ( + <Text>Could not find buy token information.</Text> + )} + </YStack> + ), + }) + + if (exchangeRate && txInfo.toToken) { + items.push({ + label: 'Exchange Rate', + render: () => ( + <Text> + 1 {txInfo.fromToken.symbol} = {formatAmount(exchangeRate)} {txInfo.toToken?.symbol} + </Text> + ), + }) + } + } + + // Recipient + items.push({ + label: 'Recipient', + render: () => ( + <EthAddress + address={txInfo.recipient.value as `0x${string}`} + copy + copyProps={{ color: '$textSecondaryLight' }} + /> + ), + }) + + // Fees + const totalFee = formatUnits( + BigInt(txInfo.fees?.integratorFee ?? 0n) + BigInt(txInfo.fees?.lifiFee ?? 0n), + txInfo.fromToken.decimals, + ) + + items.push({ + label: 'Fees', + render: () => ( + <Text> + {Number(totalFee).toFixed(6)} {txInfo.fromToken.symbol} + </Text> + ), + }) + + return items + }, [txInfo, chain]) + + const handleViewActions = () => { + router.push({ + pathname: '/transaction-actions', + params: { txId }, + }) + } + + return ( + <YStack gap="$4"> + <ListTable items={bridgeItems}> + <ParametersButton txId={txId} /> + </ListTable> + + <BridgeRecipientWarnings txInfo={txInfo} /> + + {decodedData && isMultiSendData(decodedData) && ( + <SafeListItem + label="Actions" + rightNode={ + <View flexDirection="row" alignItems="center" gap="$2"> + {decodedData.parameters?.[0]?.valueDecoded && ( + <Badge + themeName="badge_background_inverted" + content={ + Array.isArray(decodedData.parameters[0].valueDecoded) + ? decodedData.parameters[0].valueDecoded.length.toString() + : '1' + } + /> + )} + + <SafeFontIcon name={'chevron-right'} /> + </View> + } + onPress={handleViewActions} + /> + )} + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/BridgeTransaction/__tests__/BridgeRecipientWarnings.test.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/BridgeTransaction/__tests__/BridgeRecipientWarnings.test.tsx new file mode 100644 index 0000000000..a8c34abca4 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/BridgeTransaction/__tests__/BridgeRecipientWarnings.test.tsx @@ -0,0 +1,216 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react' +import { render, screen } from '@testing-library/react-native' +import { TamaguiProvider } from 'tamagui' +import config from '@/src/theme/tamagui.config' +import { BridgeRecipientWarnings } from '../BridgeRecipientWarnings' +import { BridgeWarnings } from '@safe-global/utils/components/confirmation-views/BridgeTransaction/BridgeWarnings' +import { BridgeAndSwapTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { faker } from '@faker-js/faker' + +// Mock the RTK Query hook +jest.mock('@safe-global/store/gateway/AUTO_GENERATED/safes', () => ({ + useSafesGetSafeV1Query: jest.fn(), +})) + +// Mock the hooks and selectors +jest.mock('@/src/store/hooks/activeSafe', () => ({ + useDefinedActiveSafe: jest.fn(), +})) + +jest.mock('@/src/store/hooks', () => ({ + useAppSelector: jest.fn(), +})) + +const { useSafesGetSafeV1Query } = require('@safe-global/store/gateway/AUTO_GENERATED/safes') +const { useDefinedActiveSafe } = require('@/src/store/hooks/activeSafe') +const { useAppSelector } = require('@/src/store/hooks') + +// Helper to wrap component with required providers +const renderWithProviders = (ui: React.ReactElement) => { + return render(<TamaguiProvider config={config}>{ui}</TamaguiProvider>) +} + +const mockActiveSafe = { + address: faker.finance.ethereumAddress() as `0x${string}`, + chainId: '1', +} + +const mockTxInfo: BridgeAndSwapTransactionInfo = { + type: 'SwapAndBridge', + humanDescription: null, + fromToken: { + address: '0x0000000000000000000000000000000000000000', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + logoUri: '', + trusted: true, + }, + recipient: { value: mockActiveSafe.address }, + explorerUrl: null, + status: 'PENDING', + substatus: 'WAIT_SOURCE_CONFIRMATIONS', + fees: null, + fromAmount: '1000000000000000000', + toChain: '100', // Gnosis Chain + toToken: null, + toAmount: null, +} + +describe('BridgeRecipientWarnings', () => { + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks() + + useDefinedActiveSafe.mockReturnValue(mockActiveSafe) + + useSafesGetSafeV1Query.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: false, + isError: false, + isSuccess: false, + isFetching: false, + refetch: jest.fn(), + }) + + // Mock default state + useAppSelector.mockImplementation((selector: any) => { + // Mock selectAllChains to return supported chains + if (selector.toString().includes('selectAllChains')) { + return [ + { chainId: '1', chainName: 'Ethereum' }, + { chainId: '100', chainName: 'Gnosis Chain' }, + ] + } + // Mock selectSafeInfo to return safe info + if (selector.toString().includes('selectSafeInfo')) { + return { + [mockActiveSafe.chainId]: { + address: { value: mockActiveSafe.address }, + chainId: mockActiveSafe.chainId, + threshold: 2, + owners: [{ value: faker.finance.ethereumAddress() }, { value: faker.finance.ethereumAddress() }], + fiatTotal: '0', + queued: 0, + awaitingConfirmation: null, + }, + } + } + // Mock selectContactByAddress to return null (no contact) + if (selector.toString().includes('selectContactByAddress')) { + return null + } + return null + }) + }) + + it('should export bridge warnings constants', () => { + expect(BridgeWarnings.DIFFERENT_SETUP).toBeDefined() + expect(BridgeWarnings.NO_MULTICHAIN_SUPPORT).toBeDefined() + expect(BridgeWarnings.SAFE_NOT_DEPLOYED).toBeDefined() + expect(BridgeWarnings.DIFFERENT_ADDRESS).toBeDefined() + expect(BridgeWarnings.UNKNOWN_CHAIN).toBeDefined() + + expect(BridgeWarnings.DIFFERENT_SETUP.title).toBe('Different Safe setup on target chain') + expect(BridgeWarnings.DIFFERENT_SETUP.severity).toBe('warning') + }) + + it('should render component without crashing when no warning is needed', () => { + // Mock that safe exists with same setup + useSafesGetSafeV1Query.mockReturnValue({ + data: { + address: { value: mockActiveSafe.address }, + chainId: '100', + threshold: 2, + owners: [{ value: faker.finance.ethereumAddress() }, { value: faker.finance.ethereumAddress() }], + fiatTotal: '0', + queued: 0, + awaitingConfirmation: null, + }, + error: undefined, + isLoading: false, + isError: false, + isSuccess: true, + isFetching: false, + refetch: jest.fn(), + }) + + renderWithProviders(<BridgeRecipientWarnings txInfo={mockTxInfo} />) + + // Component should render without any warnings + expect(screen.queryByText('Different Safe setup on target chain')).not.toBeOnTheScreen() + }) + + it('should show warning when bridging to different address not in address book', () => { + const differentAddressTxInfo = { + ...mockTxInfo, + recipient: { value: faker.finance.ethereumAddress() }, + } + + renderWithProviders(<BridgeRecipientWarnings txInfo={differentAddressTxInfo} />) + + expect(screen.getByText('Unknown address')).toBeOnTheScreen() + expect(screen.getByText(BridgeWarnings.DIFFERENT_ADDRESS.description)).toBeOnTheScreen() + }) + + it('should show error when bridging to unsupported chain', () => { + const unsupportedChainTxInfo = { + ...mockTxInfo, + toChain: '999', // Unsupported chain + } + + // Mock that the chain is not supported + useAppSelector.mockImplementation((selector: any) => { + if (selector.toString().includes('selectAllChains')) { + return [ + { chainId: '1', chainName: 'Ethereum' }, + { chainId: '100', chainName: 'Gnosis Chain' }, + ] + // Note: chain 999 is not in this list + } + if (selector.toString().includes('selectSafeInfo')) { + return { + [mockActiveSafe.chainId]: { + address: { value: mockActiveSafe.address }, + chainId: mockActiveSafe.chainId, + threshold: 2, + owners: [{ value: faker.finance.ethereumAddress() }, { value: faker.finance.ethereumAddress() }], + fiatTotal: '0', + queued: 0, + awaitingConfirmation: null, + }, + } + } + if (selector.toString().includes('selectContactByAddress')) { + return null + } + return null + }) + + renderWithProviders(<BridgeRecipientWarnings txInfo={unsupportedChainTxInfo} />) + + expect(screen.getByText('The target network is not supported')).toBeOnTheScreen() + expect(screen.getByText(BridgeWarnings.UNKNOWN_CHAIN.description)).toBeOnTheScreen() + }) + + it('should show warnings correctly when Safe data is unavailable', () => { + useSafesGetSafeV1Query.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: false, + isError: false, + isSuccess: true, + isFetching: false, + refetch: jest.fn(), + }) + + renderWithProviders(<BridgeRecipientWarnings txInfo={mockTxInfo} />) + + // When safe data is unavailable and chains are not properly loaded, + // the component should show some warning (either unsupported chain or safe not deployed) + const warningElement = screen.getByTestId(/bridge-warning-/i) + expect(warningElement).toBeOnTheScreen() + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/BridgeTransaction/index.ts b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/BridgeTransaction/index.ts new file mode 100644 index 0000000000..6568d1e9aa --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/BridgeTransaction/index.ts @@ -0,0 +1,2 @@ +export { BridgeTransaction } from './BridgeTransaction' +export { BridgeRecipientWarnings } from './BridgeRecipientWarnings' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/CancelTx/CancelTx.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/CancelTx/CancelTx.tsx new file mode 100644 index 0000000000..2227a44783 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/CancelTx/CancelTx.tsx @@ -0,0 +1,74 @@ +import React, { useMemo } from 'react' +import { View, YStack, Text } from 'tamagui' +import { TransactionHeader } from '../../TransactionHeader' +import { ListTable } from '../../ListTable' +import { formatCancelTxItems } from './utils' +import { CustomTransactionInfo, MultisigExecutionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { RootState } from '@/src/store' +import { selectChainById } from '@/src/store/chains' +import { useAppSelector } from '@/src/store/hooks' +import { SafeListItem } from '@/src/components/SafeListItem' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Badge } from '@/src/components/Badge' +import { ParametersButton } from '../../ParametersButton' +import { router } from 'expo-router' + +interface CancelTxProps { + txInfo: CustomTransactionInfo + executionInfo: MultisigExecutionDetails + txId: string +} + +export function CancelTx({ txInfo, executionInfo, txId }: CancelTxProps) { + const activeSafe = useDefinedActiveSafe() + const chain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + + const items = useMemo(() => formatCancelTxItems(chain), [chain]) + + const handleViewActions = () => { + router.push({ + pathname: '/transaction-actions', + params: { txId }, + }) + } + + return ( + <YStack gap="$4"> + <TransactionHeader + customLogo={ + <View borderRadius={100} padding="$2" backgroundColor="$errorDark"> + <SafeFontIcon color="$error" name="close-outlined" /> + </View> + } + badgeIcon="transaction-contract" + badgeColor="$textSecondaryLight" + title={txInfo.methodName ?? 'On-chain rejection'} + submittedAt={executionInfo.submittedAt} + /> + + <Text fontSize="$4"> + This is an on-chain rejection that didn’t send any funds. This on-chain rejection replaced all transactions with + nonce {executionInfo.nonce}. + </Text> + + <ListTable items={items}> + <ParametersButton txId={txId} /> + </ListTable> + + {txInfo.actionCount && ( + <SafeListItem + label="Actions" + rightNode={ + <View flexDirection="row" alignItems="center" gap="$2"> + <Badge themeName="badge_background_inverted" content={txInfo.actionCount.toString()} circleSize="$6" /> + + <SafeFontIcon name={'chevron-right'} /> + </View> + } + onPress={handleViewActions} + /> + )} + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/CancelTx/index.ts b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/CancelTx/index.ts new file mode 100644 index 0000000000..1af1d7f397 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/CancelTx/index.ts @@ -0,0 +1 @@ +export { CancelTx } from './CancelTx' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/CancelTx/utils.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/CancelTx/utils.tsx new file mode 100644 index 0000000000..1cd04352bc --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/CancelTx/utils.tsx @@ -0,0 +1,17 @@ +import { Logo } from '@/src/components/Logo' +import { Text, View } from 'tamagui' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' + +export const formatCancelTxItems = (chain: Chain) => { + return [ + { + label: 'Network', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Logo logoUri={chain.chainLogoUri} size="$6" /> + <Text fontSize="$4">{chain.chainName}</Text> + </View> + ), + }, + ] +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Contract/Contract.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Contract/Contract.tsx new file mode 100644 index 0000000000..e325e83fbb --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Contract/Contract.tsx @@ -0,0 +1,68 @@ +import React, { useMemo } from 'react' +import { View, YStack } from 'tamagui' +import { TransactionHeader } from '../../TransactionHeader' +import { ListTable } from '../../ListTable' +import { formatContractItems } from './utils' +import { CustomTransactionInfo, MultisigExecutionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { RootState } from '@/src/store' +import { selectChainById } from '@/src/store/chains' +import { useAppSelector } from '@/src/store/hooks' +import { SafeListItem } from '@/src/components/SafeListItem' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Badge } from '@/src/components/Badge' +import { ParametersButton } from '../../ParametersButton' +import { router } from 'expo-router' +import { useOpenExplorer } from '@/src/features/ConfirmTx/hooks/useOpenExplorer' + +interface ContractProps { + txInfo: CustomTransactionInfo + executionInfo: MultisigExecutionDetails + txId: string +} + +export function Contract({ txInfo, executionInfo, txId }: ContractProps) { + const activeSafe = useDefinedActiveSafe() + const chain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + const viewOnExplorer = useOpenExplorer(txInfo.to.value) + + const items = useMemo(() => formatContractItems(txInfo, chain, viewOnExplorer), [txInfo, chain, viewOnExplorer]) + + const handleViewActions = () => { + router.push({ + pathname: '/transaction-actions', + params: { txId }, + }) + } + + return ( + <YStack gap="$4"> + <TransactionHeader + logo={txInfo.to.logoUri || txInfo.to.value} + isIdenticon={!txInfo.to.logoUri} + badgeIcon="transaction-contract" + badgeColor="$textSecondaryLight" + title={txInfo.methodName ?? 'Contract interaction'} + submittedAt={executionInfo.submittedAt} + /> + + <ListTable items={items}> + <ParametersButton txId={txId} /> + </ListTable> + + {txInfo.actionCount && ( + <SafeListItem + label="Actions" + rightNode={ + <View flexDirection="row" alignItems="center" gap="$2"> + <Badge themeName="badge_background_inverted" content={txInfo.actionCount.toString()} circleSize="$6" /> + + <SafeFontIcon name={'chevron-right'} size={16} /> + </View> + } + onPress={handleViewActions} + /> + )} + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Contract/index.ts b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Contract/index.ts new file mode 100644 index 0000000000..64f189dc2c --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Contract/index.ts @@ -0,0 +1 @@ +export { Contract } from './Contract' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Contract/utils.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Contract/utils.tsx new file mode 100644 index 0000000000..fceabbeb41 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Contract/utils.tsx @@ -0,0 +1,58 @@ +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Logo } from '@/src/components/Logo' + +import { Badge } from '@/src/components/Badge' +import { ellipsis } from '@/src/utils/formatters' +import { CircleProps, Text, View } from 'tamagui' +import { CustomTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { shortenAddress } from '@safe-global/utils/utils/formatters' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { CopyButton } from '@/src/components/CopyButton' +import { TouchableOpacity } from 'react-native' + +const mintBadgeProps: CircleProps = { borderRadius: '$2', paddingHorizontal: '$2', paddingVertical: '$1' } + +export const formatContractItems = (txInfo: CustomTransactionInfo, chain: Chain, viewOnExplorer: () => void) => { + const contractName = txInfo.to.name ? ellipsis(txInfo.to.name, 18) : shortenAddress(txInfo.to.value) + + return [ + { + label: 'Call', + render: () => ( + <Badge + circleProps={mintBadgeProps} + themeName="badge_background" + fontSize={13} + textContentProps={{ fontFamily: 'DM Mono' }} + circular={false} + content={txInfo.methodName ?? ''} + /> + ), + }, + { + label: 'Contract', + render: () => { + return ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Logo logoUri={txInfo.to.logoUri} size="$6" /> + <Text fontSize="$4">{contractName}</Text> + <CopyButton value={txInfo.to.value} color={'$textSecondaryLight'} /> + + <TouchableOpacity onPress={viewOnExplorer}> + <SafeFontIcon name="external-link" size={14} color="$textSecondaryLight" /> + </TouchableOpacity> + </View> + ) + }, + }, + { + label: 'Network', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Logo logoUri={chain.chainLogoUri} size="$6" /> + <Text fontSize="$4">{chain.chainName}</Text> + </View> + ), + }, + ] +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/GenericView/GenericView.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/GenericView/GenericView.tsx new file mode 100644 index 0000000000..bfd61a9c30 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/GenericView/GenericView.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from 'react' +import { View, YStack } from 'tamagui' +import { formatGenericViewItems } from './utils' +import { + SettingsChangeTransaction, + MultisigExecutionDetails, + TransactionData, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { RootState } from '@/src/store' +import { selectChainById } from '@/src/store/chains' +import { useAppSelector } from '@/src/store/hooks' +import { SafeListItem } from '@/src/components/SafeListItem' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Badge } from '@/src/components/Badge' + +import { ListTable } from '../../ListTable' +import { TransactionHeader } from '../../TransactionHeader' +import { ParametersButton } from '../../ParametersButton' +import { router } from 'expo-router' +import { useOpenExplorer } from '@/src/features/ConfirmTx/hooks/useOpenExplorer' +interface GenericViewProps { + txInfo: SettingsChangeTransaction + executionInfo: MultisigExecutionDetails + txData: TransactionData + txId: string +} + +export function GenericView({ txInfo, txData, executionInfo, txId }: GenericViewProps) { + const activeSafe = useDefinedActiveSafe() + const chain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + const viewOnExplorer = useOpenExplorer(txData.to.value) + const items = useMemo( + () => formatGenericViewItems({ txInfo, txData, chain, executionInfo, viewOnExplorer }), + [txInfo, executionInfo, txData, chain, viewOnExplorer], + ) + + const handleViewActions = () => { + router.push({ + pathname: '/transaction-actions', + params: { txId }, + }) + } + + return ( + <YStack gap="$4"> + <TransactionHeader + logo={txData.to.logoUri || txData.to.value} + isIdenticon={!txData.to.logoUri} + badgeIcon="transaction-contract" + badgeColor="$textSecondaryLight" + title={txData.dataDecoded?.method ?? 'Contract interaction'} + submittedAt={executionInfo.submittedAt} + /> + + <ListTable items={items}> + <ParametersButton txId={txId} /> + </ListTable> + + {'actionCount' in txInfo && ( + <SafeListItem + label="Actions" + onPress={handleViewActions} + rightNode={ + <View flexDirection="row" alignItems="center" gap="$2"> + <Badge themeName="badge_background_inverted" content={txInfo.actionCount as string} circleSize="$6" /> + + <SafeFontIcon name={'chevron-right'} /> + </View> + } + /> + )} + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/GenericView/index.ts b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/GenericView/index.ts new file mode 100644 index 0000000000..23784aa36e --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/GenericView/index.ts @@ -0,0 +1 @@ +export { GenericView } from './GenericView' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/GenericView/utils.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/GenericView/utils.tsx new file mode 100644 index 0000000000..6047c570c0 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/GenericView/utils.tsx @@ -0,0 +1,102 @@ +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Logo } from '@/src/components/Logo' + +import { Badge } from '@/src/components/Badge' +import { ellipsis } from '@/src/utils/formatters' +import { CircleProps, Text, View } from 'tamagui' +import { shortenAddress } from '@safe-global/utils/utils/formatters' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { + MultisigExecutionDetails, + SettingsChangeTransaction, + TransactionData, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { Identicon } from '@/src/components/Identicon' +import { Address } from '@/src/types/address' +import { CopyButton } from '@/src/components/CopyButton' +import { TouchableOpacity } from 'react-native' + +const mintBadgeProps: CircleProps = { borderRadius: '$2', paddingHorizontal: '$2', paddingVertical: '$1' } + +export const formatGenericViewItems = ({ + txInfo, + txData, + chain, + executionInfo, + viewOnExplorer, +}: { + txInfo: SettingsChangeTransaction + txData: TransactionData + chain: Chain + executionInfo: MultisigExecutionDetails + viewOnExplorer: () => void +}) => { + const genericViewName = txData.to.name ? ellipsis(txData.to.name, 18) : shortenAddress(txData.to.value) + + const items = [ + { + label: 'Call', + render: () => ( + <Badge + circleProps={mintBadgeProps} + themeName="badge_background" + fontSize={13} + textContentProps={{ fontFamily: 'DM Mono' }} + circular={false} + content={txData.dataDecoded?.method ?? ''} + /> + ), + }, + { + label: 'Contract', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + {txData.to.logoUri ? ( + <Logo logoUri={txData.to.logoUri} size="$6" /> + ) : ( + <Identicon address={txData.to.value as Address} size={24} /> + )} + <Text fontSize="$4">{genericViewName}</Text> + <CopyButton value={txData.to.value} color={'$textSecondaryLight'} /> + + <TouchableOpacity onPress={viewOnExplorer}> + <SafeFontIcon name="external-link" size={14} color="$textSecondaryLight" /> + </TouchableOpacity> + </View> + ), + }, + ] + + if (txInfo.settingsInfo?.type === 'CHANGE_THRESHOLD') { + items.push({ + label: 'Token', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + {txInfo.settingsInfo && 'threshold' in txInfo.settingsInfo && ( + <Text fontSize="$4"> + {txInfo.settingsInfo?.threshold}/{executionInfo.signers.length} + </Text> + )} + + {txInfo.settingsInfo && 'threshold' in txInfo.settingsInfo && ( + <Text textDecorationLine="line-through" color="$textSecondaryLight" fontSize="$4"> + {executionInfo.confirmationsRequired}/{executionInfo.signers.length} + </Text> + )} + </View> + ), + }) + } + + items.push({ + label: 'Network', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Logo logoUri={chain.chainLogoUri} size="$6" /> + <Text fontSize="$4">{chain.chainName}</Text> + </View> + ), + }) + + return items +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/LifiSwapTransaction/LifiSwapHeader.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/LifiSwapTransaction/LifiSwapHeader.tsx new file mode 100644 index 0000000000..c98be70a47 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/LifiSwapTransaction/LifiSwapHeader.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { SwapTransactionInfo, MultisigExecutionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { formatWithSchema } from '@/src/utils/date' +import { formatValue } from '@/src/utils/formatters' +import { SwapHeader } from '@/src/components/SwapHeader' + +interface LifiSwapHeaderProps { + txInfo: SwapTransactionInfo + executionInfo: MultisigExecutionDetails +} + +export function LifiSwapHeader({ txInfo, executionInfo }: LifiSwapHeaderProps) { + const { fromToken, toToken, fromAmount, toAmount } = txInfo + const date = formatWithSchema(executionInfo.submittedAt, 'MMM d yyyy') + const time = formatWithSchema(executionInfo.submittedAt, 'hh:mm a') + + const sellTokenValue = formatValue(fromAmount, fromToken.decimals) + const buyTokenValue = formatValue(toAmount, toToken.decimals) + + return ( + <SwapHeader + date={date} + time={time} + fromToken={fromToken} + toToken={toToken} + fromAmount={sellTokenValue} + toAmount={buyTokenValue} + fromLabel="Sell" + toLabel="For" + /> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/LifiSwapTransaction/LifiSwapTransaction.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/LifiSwapTransaction/LifiSwapTransaction.tsx new file mode 100644 index 0000000000..099643581c --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/LifiSwapTransaction/LifiSwapTransaction.tsx @@ -0,0 +1,74 @@ +import React, { useMemo } from 'react' +import { YStack, Text } from 'tamagui' +import { ListTable } from '../../ListTable' +import { MultisigExecutionDetails, SwapTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { formatUnits } from 'ethers' +import { EthAddress } from '@/src/components/EthAddress' +import { type ListTableItem } from '../../ListTable' +import { LifiSwapHeader } from './LifiSwapHeader' +import { ParametersButton } from '../../ParametersButton' +import { formatAmount } from '@safe-global/utils/utils/formatNumber' + +interface LifiSwapTransactionProps { + txId: string + executionInfo: MultisigExecutionDetails + txInfo: SwapTransactionInfo +} + +export function LifiSwapTransaction({ txId, executionInfo, txInfo }: LifiSwapTransactionProps) { + const lifiSwapItems = useMemo(() => { + const items: ListTableItem[] = [] + + // Exchange rate + const fromAmountDecimals = formatUnits(txInfo.fromAmount, txInfo.fromToken.decimals) + const toAmountDecimals = formatUnits(txInfo.toAmount, txInfo.toToken.decimals) + const exchangeRate = Number(toAmountDecimals) / Number(fromAmountDecimals) + + items.push({ + label: 'Price', + render: () => ( + <Text> + 1 {txInfo.fromToken.symbol} = {formatAmount(exchangeRate)} {txInfo.toToken.symbol} + </Text> + ), + }) + + // Receiver + items.push({ + label: 'Receiver', + render: () => ( + <EthAddress + address={txInfo.recipient.value as `0x${string}`} + copy + copyProps={{ color: '$textSecondaryLight' }} + /> + ), + }) + + // Fees + const totalFee = formatUnits( + BigInt(txInfo.fees?.integratorFee ?? 0n) + BigInt(txInfo.fees?.lifiFee ?? 0n), + txInfo.fromToken.decimals, + ) + + items.push({ + label: 'Fees', + render: () => ( + <Text> + {Number(totalFee).toFixed(6)} {txInfo.fromToken.symbol} + </Text> + ), + }) + + return items + }, [txInfo]) + + return ( + <YStack gap="$4"> + <LifiSwapHeader txInfo={txInfo} executionInfo={executionInfo} /> + <ListTable items={lifiSwapItems}> + <ParametersButton txId={txId} /> + </ListTable> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/LifiSwapTransaction/index.ts b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/LifiSwapTransaction/index.ts new file mode 100644 index 0000000000..0d37d4c7aa --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/LifiSwapTransaction/index.ts @@ -0,0 +1 @@ +export { LifiSwapTransaction } from './LifiSwapTransaction' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/RemoveSigner/RemoveSigner.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/RemoveSigner/RemoveSigner.tsx new file mode 100644 index 0000000000..ac640ebd2e --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/RemoveSigner/RemoveSigner.tsx @@ -0,0 +1,51 @@ +import React, { useMemo } from 'react' +import { YStack } from 'tamagui' +import { MultisigExecutionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useAppSelector } from '@/src/store/hooks' +import { RootState } from '@/src/store' +import { selectChainById } from '@/src/store/chains' + +import { formatRemoveSignerItems } from './utils' + +import { TransactionHeader } from '../../TransactionHeader' +import { ListTable } from '../../ListTable' +import { getSignerName } from '../AddSigner/utils' +import { ParametersButton } from '../../ParametersButton' +import { NormalizedSettingsChangeTransaction } from '../../ConfirmationView/types' +import { useOpenExplorer } from '@/src/features/ConfirmTx/hooks/useOpenExplorer' + +interface RemoveSignerProps { + txInfo: NormalizedSettingsChangeTransaction + executionInfo: MultisigExecutionDetails + txId: string +} + +export function RemoveSigner({ txInfo, executionInfo, txId }: RemoveSignerProps) { + const activeSafe = useDefinedActiveSafe() + const activeChain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + const viewOnExplorer = useOpenExplorer(txInfo.settingsInfo?.owner?.value) + + const items = useMemo( + () => formatRemoveSignerItems(txInfo, activeChain, viewOnExplorer), + [txInfo, activeChain, viewOnExplorer], + ) + const newRemovedSigners = getSignerName(txInfo) + + return ( + <YStack gap="$4"> + <TransactionHeader + submittedAt={executionInfo.submittedAt} + logo={txInfo.settingsInfo?.owner?.value} + isIdenticon + badgeIcon="transaction-contract" + badgeColor="$textSecondaryLight" + title={newRemovedSigners} + /> + + <ListTable items={items}> + <ParametersButton txId={txId} /> + </ListTable> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/RemoveSigner/index.ts b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/RemoveSigner/index.ts new file mode 100644 index 0000000000..f947dbe399 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/RemoveSigner/index.ts @@ -0,0 +1 @@ +export { RemoveSigner } from './RemoveSigner' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/RemoveSigner/utils.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/RemoveSigner/utils.tsx new file mode 100644 index 0000000000..2736522697 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/RemoveSigner/utils.tsx @@ -0,0 +1,44 @@ +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Logo } from '@/src/components/Logo' +import { Text, View } from 'tamagui' + +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { Identicon } from '@/src/components/Identicon' +import { getSignerName } from '../AddSigner/utils' + +import { NormalizedSettingsChangeTransaction } from '../../ConfirmationView/types' +import { CopyButton } from '@/src/components/CopyButton' +import { TouchableOpacity } from 'react-native' + +export const formatRemoveSignerItems = ( + txInfo: NormalizedSettingsChangeTransaction, + chain: Chain, + viewOnExplorer: () => void, +) => { + const newRemovedSigners = getSignerName(txInfo) + + return [ + { + label: 'Removed signer', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Identicon address={txInfo.settingsInfo?.owner?.value} size={24} /> + <Text fontSize="$4">{newRemovedSigners}</Text> + <CopyButton value={txInfo.settingsInfo?.owner?.value} color={'$textSecondaryLight'} /> + <TouchableOpacity onPress={viewOnExplorer}> + <SafeFontIcon name="external-link" size={14} color="$textSecondaryLight" /> + </TouchableOpacity> + </View> + ), + }, + { + label: 'Network', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Logo logoUri={chain.chainLogoUri} size="$6" /> + <Text fontSize="$4">{chain.chainName}</Text> + </View> + ), + }, + ] +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SendNFT/SendNFT.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SendNFT/SendNFT.tsx new file mode 100644 index 0000000000..a761fc9820 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SendNFT/SendNFT.tsx @@ -0,0 +1,48 @@ +import React, { useMemo } from 'react' +import { YStack } from 'tamagui' +import { TransactionHeader } from '../../TransactionHeader' +import { ListTable } from '../../ListTable' +import { formatSendNFTItems } from './utils' +import { TransferTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { MultisigExecutionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useTokenDetails } from '@/src/hooks/useTokenDetails/useTokenDetails' +import { RootState } from '@/src/store' +import { useAppSelector } from '@/src/store/hooks' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { selectChainById } from '@/src/store/chains' +import { useOpenExplorer } from '@/src/features/ConfirmTx/hooks/useOpenExplorer' +import { ParametersButton } from '../../ParametersButton' + +interface SendNFTProps { + txId: string + txInfo: TransferTransactionInfo + executionInfo: MultisigExecutionDetails +} + +export function SendNFT({ txId, txInfo, executionInfo }: SendNFTProps) { + const activeSafe = useDefinedActiveSafe() + const activeChain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + const viewOnExplorer = useOpenExplorer(txInfo.recipient.value) + + const items = useMemo( + () => formatSendNFTItems(txInfo, activeChain, viewOnExplorer), + [txInfo, activeChain, viewOnExplorer], + ) + const { value, tokenSymbol } = useTokenDetails(txInfo) + + return ( + <YStack gap="$4"> + <TransactionHeader + badgeIcon="transaction-outgoing" + badgeColor="$error" + badgeThemeName="badge_error" + title={`${value} ${tokenSymbol}`} + submittedAt={executionInfo.submittedAt} + /> + + <ListTable items={items}> + <ParametersButton txId={txId} /> + </ListTable> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SendNFT/index.ts b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SendNFT/index.ts new file mode 100644 index 0000000000..0fc6614f67 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SendNFT/index.ts @@ -0,0 +1 @@ +export { SendNFT } from './SendNFT' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SendNFT/utils.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SendNFT/utils.tsx new file mode 100644 index 0000000000..8a04cf6f53 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SendNFT/utils.tsx @@ -0,0 +1,40 @@ +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Logo } from '@/src/components/Logo' +import { TransferTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +import { ellipsis } from '@/src/utils/formatters' +import { Text, View } from 'tamagui' +import { Address } from '@/src/types/address' +import { Identicon } from '@/src/components/Identicon' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { shortenAddress } from '@safe-global/utils/utils/formatters' +import { TouchableOpacity } from 'react-native' + +export const formatSendNFTItems = (txInfo: TransferTransactionInfo, chain: Chain, viewOnExplorer: () => void) => { + return [ + { + label: 'New signer', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Identicon address={txInfo.recipient.value as Address} size={24} /> + <Text fontSize="$4"> + {txInfo.recipient.name ? ellipsis(txInfo.recipient.name, 18) : shortenAddress(txInfo.recipient.value)} + </Text> + <SafeFontIcon name="copy" size={14} color="$textSecondaryLight" /> + <TouchableOpacity onPress={viewOnExplorer}> + <SafeFontIcon name="external-link" size={14} color="$textSecondaryLight" /> + </TouchableOpacity> + </View> + ), + }, + { + label: 'Network', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Logo logoUri={chain.chainLogoUri} size="$6" /> + <Text fontSize="$4">{chain.chainName}</Text> + </View> + ), + }, + ] +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/Deposit.stories.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/Deposit.stories.tsx new file mode 100644 index 0000000000..81ce0a465f --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/Deposit.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { StakingDeposit } from './Deposit' +import { + NativeStakingDepositTransactionInfo, + MultisigExecutionDetails, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +// Mock data for NativeStakingDepositTransactionInfo +const mockStakingDepositInfo: NativeStakingDepositTransactionInfo = { + type: 'NativeStakingDeposit', + humanDescription: 'Deposit ETH for staking', + status: 'ACTIVE', + estimatedEntryTime: 86400000, // 1 day in milliseconds + estimatedExitTime: 30 * 86400000, // 30 days in milliseconds + estimatedWithdrawalTime: 32 * 86400000, // 32 days in milliseconds + fee: 0.0127, // 1.27% fee + monthlyNrr: 4.2, // 4.2% monthly return + annualNrr: 5.04, // 5.04% annual return + value: '32000000000000000000', // 32 ETH in wei + numValidators: 1, + expectedAnnualReward: '1612800000000000000', // ~1.6 ETH + expectedMonthlyReward: '134400000000000000', // ~0.13 ETH + expectedFiatAnnualReward: 4838.4, // ~$4,838 assuming 1 ETH = $3000 + expectedFiatMonthlyReward: 403.2, // ~$403 + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, + validators: ['0x123...abc'], +} + +const mockExecutionInfo: MultisigExecutionDetails = { + type: 'MULTISIG', + nonce: 42, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + name: null, + logoUri: null, + }, + safeTxHash: '0x123abc456def', + submittedAt: Date.now() - 3600000, // 1 hour ago + signers: [ + { + value: '0x1234567890abcdef1234567890abcdef12345678', + name: 'Alice', + logoUri: null, + }, + { + value: '0xabcdef1234567890abcdef1234567890abcdef12', + name: 'Bob', + logoUri: null, + }, + ], + confirmationsRequired: 2, + confirmations: [ + { + signer: { + value: '0x1234567890abcdef1234567890abcdef12345678', + name: 'Alice', + logoUri: null, + }, + signature: null, + submittedAt: Date.now() - 3600000, + }, + ], + rejectors: [], + executor: null, + gasTokenInfo: null, + trusted: true, +} + +const meta: Meta<typeof StakingDeposit> = { + title: 'ConfirmTx/StakingDeposit', + component: StakingDeposit, + argTypes: {}, + args: { + txInfo: mockStakingDepositInfo, + executionInfo: mockExecutionInfo, + txId: 'test-staking-tx-id', + }, +} + +export default meta + +type Story = StoryObj<typeof StakingDeposit> + +export const Default: Story = { + args: { + txInfo: mockStakingDepositInfo, + executionInfo: mockExecutionInfo, + txId: 'test-staking-tx-id', + }, +} + +export const MultipleValidators: Story = { + args: { + txInfo: { + ...mockStakingDepositInfo, + numValidators: 3, + value: '96000000000000000000', // 96 ETH for 3 validators + }, + executionInfo: mockExecutionInfo, + txId: 'test-staking-tx-multi', + }, +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/Deposit.test.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/Deposit.test.tsx new file mode 100644 index 0000000000..1ab6519c00 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/Deposit.test.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { StakingDeposit } from './Deposit' +import { + NativeStakingDepositTransactionInfo, + MultisigExecutionDetails, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +const mockTxInfo: NativeStakingDepositTransactionInfo = { + type: 'NativeStakingDeposit', + humanDescription: 'Deposit tokens for staking', + status: 'ACTIVE', + estimatedEntryTime: 86400000, // 1 day in milliseconds + estimatedExitTime: 30 * 86400000, // 30 days in milliseconds + estimatedWithdrawalTime: 32 * 86400000, // 32 days in milliseconds + fee: 0.05, // 5% fee + monthlyNrr: 4.2, + annualNrr: 50.4, + value: '32000000000000000000', // 32 ETH in wei + numValidators: 1, + expectedAnnualReward: '1612800000000000000', + expectedMonthlyReward: '134400000000000000', + expectedFiatAnnualReward: 4838.4, + expectedFiatMonthlyReward: 403.2, + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, + validators: ['0x123...abc'], +} + +const mockExecutionInfo: MultisigExecutionDetails = { + type: 'MULTISIG', + submittedAt: 1234567890, + nonce: 1, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + }, + safeTxHash: '0x123', + signers: [], + confirmationsRequired: 2, + confirmations: [], + rejectors: [], + trusted: true, +} + +const mockProps = { + txInfo: mockTxInfo, + executionInfo: mockExecutionInfo, + txId: 'test-tx-id', +} + +describe('StakingDeposit', () => { + it('renders correctly with deposit information', () => { + const { getByText } = render(<StakingDeposit {...mockProps} />) + + expect(getByText(/32.*ETH/)).toBeTruthy() + expect(getByText('Rewards rate')).toBeTruthy() + expect(getByText('50.400%')).toBeTruthy() + expect(getByText('Widget fee')).toBeTruthy() + expect(getByText('5.00%')).toBeTruthy() + expect(getByText('Validator')).toBeTruthy() + expect(getByText('1')).toBeTruthy() + expect(getByText('Activation time')).toBeTruthy() + expect(getByText('Rewards')).toBeTruthy() + expect(getByText('Approx. every 5 days after activation')).toBeTruthy() + }) + + it('matches snapshot', () => { + const component = render(<StakingDeposit {...mockProps} />) + expect(component).toMatchSnapshot() + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/Deposit.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/Deposit.tsx new file mode 100644 index 0000000000..862b469264 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/Deposit.tsx @@ -0,0 +1,53 @@ +import React, { useMemo } from 'react' +import { ListTable } from '../../../ListTable' +import { formatStakingDepositItems, formatStakingValidatorItems } from '../utils' +import { YStack, Text, XStack } from 'tamagui' +import { TransactionHeader } from '../../../TransactionHeader' +import { + MultisigExecutionDetails, + NativeStakingDepositTransactionInfo, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TokenAmount } from '@/src/components/TokenAmount' +import { ParametersButton } from '../../../ParametersButton' + +interface StakingDepositProps { + txInfo: NativeStakingDepositTransactionInfo + executionInfo: MultisigExecutionDetails + txId: string +} + +export function StakingDeposit({ txInfo, executionInfo, txId }: StakingDepositProps) { + const items = useMemo(() => formatStakingDepositItems(txInfo), [txInfo]) + const validatorItems = useMemo(() => formatStakingValidatorItems(txInfo), [txInfo]) + + return ( + <YStack gap="$4"> + <TransactionHeader + logo={txInfo.tokenInfo.logoUri ?? undefined} + badgeIcon="transaction-stake" + badgeColor="$textSecondaryLight" + title={ + <XStack gap="$1"> + <TokenAmount + value={txInfo.value} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + /> + </XStack> + } + submittedAt={executionInfo.submittedAt} + /> + + <ListTable items={items}> + <ParametersButton txId={txId} /> + </ListTable> + + <ListTable items={validatorItems}> + <Text fontSize="$3" color="$textSecondaryLight" marginTop="$2"> + Earn ETH rewards with dedicated validators. Rewards must be withdrawn manually, and you can request a + withdrawal at any time. + </Text> + </ListTable> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/__snapshots__/Deposit.test.tsx.snap b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/__snapshots__/Deposit.test.tsx.snap new file mode 100644 index 0000000000..faf688358c --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/__snapshots__/Deposit.test.tsx.snap @@ -0,0 +1,697 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StakingDeposit matches snapshot 1`] = ` +<View> + <View + style={ + { + "flexDirection": "column", + "gap": 16, + } + } + > + <View + style={ + { + "alignItems": "center", + "flexDirection": "column", + "gap": 8, + "marginTop": 16, + "position": "relative", + } + } + > + <View + style={ + { + "width": 40, + } + } + > + <View + style={ + { + "position": "absolute", + "right": -10, + "top": -10, + "zIndex": 1, + } + } + > + <View + style={ + { + "alignItems": "center", + "backgroundColor": "#DDDEE0", + "borderBottomColor": "#EEEFF0", + "borderBottomLeftRadius": 100000, + "borderBottomRightRadius": 100000, + "borderBottomWidth": 1, + "borderLeftColor": "#EEEFF0", + "borderLeftWidth": 1, + "borderRightColor": "#EEEFF0", + "borderRightWidth": 1, + "borderStyle": "solid", + "borderTopColor": "#EEEFF0", + "borderTopLeftRadius": 100000, + "borderTopRightRadius": 100000, + "borderTopWidth": 1, + "flexDirection": "column", + "height": 24, + "justifyContent": "center", + "maxHeight": 24, + "maxWidth": 24, + "minHeight": 24, + "minWidth": 24, + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "width": 24, + } + } + > + <Text + allowFontScaling={false} + selectable={false} + style={ + [ + { + "color": "#A1A3A7", + "fontSize": 12, + }, + undefined, + { + "fontFamily": "SafeIcons", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + </Text> + </View> + </View> + <View + style={ + { + "alignItems": "center", + "borderBottomLeftRadius": 100000, + "borderBottomRightRadius": 100000, + "borderTopLeftRadius": 100000, + "borderTopRightRadius": 100000, + "flexDirection": "column", + "height": 40, + "justifyContent": "center", + "maxHeight": 40, + "maxWidth": 40, + "minHeight": 40, + "minWidth": 40, + "overflow": "hidden", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "position": "relative", + "width": 40, + } + } + > + <View + style={ + { + "bottom": 0, + "flexDirection": "column", + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + "zIndex": 1, + } + } + > + <Image + fullscreen={true} + onError={[Function]} + onLoad={[Function]} + source={ + { + "uri": "https://safe-transaction-assets.safe.global/chains/1/chain_logo.png", + } + } + style={ + { + "backgroundColor": "#121312", + "height": 40, + "width": 40, + } + } + testID="logo-image" + /> + </View> + <View + style={ + { + "backgroundColor": "#EEEFF0", + "bottom": 0, + "flexDirection": "column", + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + "zIndex": 0, + } + } + > + <View + style={ + { + "alignItems": "center", + "backgroundColor": "#EEEFF0", + "borderBottomLeftRadius": 0, + "borderBottomRightRadius": 0, + "borderTopLeftRadius": 0, + "borderTopRightRadius": 0, + "display": "flex", + "height": 40, + "justifyContent": "center", + "width": 40, + } + } + > + <Text + allowFontScaling={false} + selectable={false} + style={ + [ + { + "color": "#A1A3A7", + "fontSize": 16, + }, + undefined, + { + "fontFamily": "SafeIcons", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + testID="logo-fallback-icon" + > +  + </Text> + </View> + </View> + </View> + </View> + <View + style={ + { + "alignItems": "center", + "gap": 8, + } + } + > + <View + style={ + { + "flexDirection": "row", + "gap": 4, + } + } + > + <Text + style={ + { + "color": "#121312", + "fontFamily": "DMSans-Bold", + } + } + suppressHighlighting={true} + > + 32 + + ETH + </Text> + </View> + <Text + style={ + { + "color": "#A1A3A7", + "fontFamily": "DM Sans", + "fontSize": 12, + "lineHeight": 16, + } + } + suppressHighlighting={true} + > + 15 Jan 1970 + , + 06:56 AM + </Text> + </View> + </View> + <View + style={ + { + "backgroundColor": "#FFFFFF", + "borderBottomLeftRadius": 7, + "borderBottomRightRadius": 7, + "borderTopLeftRadius": 7, + "borderTopRightRadius": 7, + "flexDirection": "column", + "gap": 20, + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + } + } + > + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "flexWrap": "wrap", + "gap": 8, + "justifyContent": "space-between", + } + } + > + <Text + style={ + { + "color": "#A1A3A7", + "flex": 1, + "fontFamily": "DM Sans", + "fontSize": 14, + } + } + suppressHighlighting={true} + > + Rewards rate + </Text> + <Text + style={ + { + "color": "#121312", + "flex": 2, + "fontFamily": "DM Sans", + "fontSize": 14, + "textAlign": "right", + } + } + suppressHighlighting={true} + > + 50.400% + </Text> + </View> + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "flexWrap": "wrap", + "gap": 8, + "justifyContent": "space-between", + } + } + > + <Text + style={ + { + "color": "#A1A3A7", + "flex": 1, + "fontFamily": "DM Sans", + "fontSize": 14, + } + } + suppressHighlighting={true} + > + Net annual rewards + </Text> + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "gap": 4, + } + } + > + <Text + style={ + { + "color": "#121312", + "fontFamily": "DM Sans", + } + } + suppressHighlighting={true} + > + 1.6128 + + ETH + </Text> + <Text + style={ + { + "color": "#A1A3A7", + "fontFamily": "DM Sans", + } + } + suppressHighlighting={true} + > + ( + $ 4,838 + ) + </Text> + </View> + </View> + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "flexWrap": "wrap", + "gap": 8, + "justifyContent": "space-between", + } + } + > + <Text + style={ + { + "color": "#A1A3A7", + "flex": 1, + "fontFamily": "DM Sans", + "fontSize": 14, + } + } + suppressHighlighting={true} + > + Net monthly rewards + </Text> + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "gap": 4, + } + } + > + <Text + style={ + { + "color": "#121312", + "fontFamily": "DM Sans", + } + } + suppressHighlighting={true} + > + 0.1344 + + ETH + </Text> + <Text + style={ + { + "color": "#A1A3A7", + "fontFamily": "DM Sans", + } + } + suppressHighlighting={true} + > + ( + $ 403 + ) + </Text> + </View> + </View> + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "flexWrap": "wrap", + "gap": 8, + "justifyContent": "space-between", + } + } + > + <Text + style={ + { + "color": "#A1A3A7", + "flex": 1, + "fontFamily": "DM Sans", + "fontSize": 14, + } + } + suppressHighlighting={true} + > + Widget fee + </Text> + <Text + style={ + { + "color": "#121312", + "flex": 2, + "fontFamily": "DM Sans", + "fontSize": 14, + "textAlign": "right", + } + } + suppressHighlighting={true} + > + 5.00% + </Text> + </View> + <View + style={ + { + "alignItems": "center", + "height": 40, + } + } + > + <View + focusVisibleStyle={{}} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + role="button" + style={ + { + "alignItems": "center", + "backgroundColor": "#DCDEE0", + "borderBottomColor": "transparent", + "borderBottomLeftRadius": 8, + "borderBottomRightRadius": 8, + "borderBottomWidth": 0, + "borderLeftColor": "transparent", + "borderLeftWidth": 0, + "borderRightColor": "transparent", + "borderRightWidth": 0, + "borderStyle": "solid", + "borderTopColor": "transparent", + "borderTopLeftRadius": 8, + "borderTopRightRadius": 8, + "borderTopWidth": 0, + "bottom": 0, + "flexDirection": "row", + "flexWrap": "nowrap", + "fontWeight": "700", + "height": 36, + "justifyContent": "center", + "left": 0, + "paddingLeft": 8, + "paddingRight": 8, + "position": "absolute", + "right": 0, + "top": 0, + } + } + > + <Text + lineBreakMode="clip" + numberOfLines={1} + style={ + { + "color": "#121312", + "flexGrow": 0, + "flexShrink": 1, + "fontFamily": "DMSans-Bold", + "fontSize": 14, + "letterSpacing": 0, + "lineHeight": 15.400000000000002, + "userSelect": "none", + } + } + suppressHighlighting={true} + > + Transaction details + </Text> + </View> + </View> + </View> + <View + style={ + { + "backgroundColor": "#FFFFFF", + "borderBottomLeftRadius": 7, + "borderBottomRightRadius": 7, + "borderTopLeftRadius": 7, + "borderTopRightRadius": 7, + "flexDirection": "column", + "gap": 20, + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + } + } + > + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "flexWrap": "wrap", + "gap": 8, + "justifyContent": "space-between", + } + } + > + <Text + style={ + { + "color": "#A1A3A7", + "flex": 1, + "fontFamily": "DM Sans", + "fontSize": 14, + } + } + suppressHighlighting={true} + > + Validator + </Text> + <Text + style={ + { + "color": "#121312", + "flex": 2, + "fontFamily": "DM Sans", + "fontSize": 14, + "textAlign": "right", + } + } + suppressHighlighting={true} + > + 1 + </Text> + </View> + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "flexWrap": "wrap", + "gap": 8, + "justifyContent": "space-between", + } + } + > + <Text + style={ + { + "color": "#A1A3A7", + "flex": 1, + "fontFamily": "DM Sans", + "fontSize": 14, + } + } + suppressHighlighting={true} + > + Activation time + </Text> + <Text + style={ + { + "color": "#121312", + "flex": 2, + "fontFamily": "DM Sans", + "fontSize": 14, + "textAlign": "right", + } + } + suppressHighlighting={true} + /> + </View> + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "flexWrap": "wrap", + "gap": 8, + "justifyContent": "space-between", + } + } + > + <Text + style={ + { + "color": "#A1A3A7", + "flex": 1, + "fontFamily": "DM Sans", + "fontSize": 14, + } + } + suppressHighlighting={true} + > + Rewards + </Text> + <Text + style={ + { + "color": "#121312", + "flex": 2, + "fontFamily": "DM Sans", + "fontSize": 14, + "textAlign": "right", + } + } + suppressHighlighting={true} + > + Approx. every 5 days after activation + </Text> + </View> + <Text + style={ + { + "color": "#A1A3A7", + "fontFamily": "DM Sans", + "fontSize": 13, + "marginTop": 8, + } + } + suppressHighlighting={true} + > + Earn ETH rewards with dedicated validators. Rewards must be withdrawn manually, and you can request a withdrawal at any time. + </Text> + </View> + </View> +</View> +`; diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/index.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/index.tsx new file mode 100644 index 0000000000..9af0a05924 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Deposit/index.tsx @@ -0,0 +1 @@ +export { StakingDeposit } from './Deposit' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/Exit.stories.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/Exit.stories.tsx new file mode 100644 index 0000000000..2cb5400584 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/Exit.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { StakingExit } from './Exit' +import { + NativeStakingWithdrawTransactionInfo, + MultisigExecutionDetails, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +// Mock data for NativeStakingWithdrawTransactionInfo +const mockExitInfo: NativeStakingWithdrawTransactionInfo = { + type: 'NativeStakingWithdraw', + humanDescription: 'Exit staked tokens', + value: '32000000000000000000', // 32 ETH in wei + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, + validators: ['0x123...abc'], +} + +const mockExecutionInfo: MultisigExecutionDetails = { + type: 'MULTISIG', + nonce: 42, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + name: null, + logoUri: null, + }, + safeTxHash: '0x123abc456def', + submittedAt: Date.now() - 3600000, // 1 hour ago + signers: [], + confirmationsRequired: 2, + confirmations: [], + rejectors: [], + executor: null, + gasTokenInfo: null, + trusted: true, +} + +const meta: Meta<typeof StakingExit> = { + title: 'ConfirmTx/StakingExit', + component: StakingExit, + argTypes: {}, + args: { + txInfo: mockExitInfo, + executionInfo: mockExecutionInfo, + txId: 'test-exit-tx-id', + }, +} + +export default meta + +type Story = StoryObj<typeof StakingExit> + +export const Default: Story = { + args: { + txInfo: mockExitInfo, + executionInfo: mockExecutionInfo, + txId: 'test-exit-tx-id', + }, +} + +export const LargeAmount: Story = { + args: { + txInfo: { + ...mockExitInfo, + value: '100000000000000000000', // 100 ETH + }, + executionInfo: mockExecutionInfo, + txId: 'test-exit-large-tx-id', + }, +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/Exit.test.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/Exit.test.tsx new file mode 100644 index 0000000000..ab1d03848d --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/Exit.test.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { StakingExit } from './Exit' +import { + NativeStakingWithdrawTransactionInfo, + MultisigExecutionDetails, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +const mockExitTxInfo: NativeStakingWithdrawTransactionInfo = { + type: 'NativeStakingWithdraw', + humanDescription: 'Exit staked tokens', + value: '32000000000000000000', // 32 ETH in wei + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, + validators: ['0x123...abc'], +} + +const mockExecutionInfo: MultisigExecutionDetails = { + type: 'MULTISIG', + submittedAt: 1234567890, + nonce: 1, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + }, + safeTxHash: '0x123', + signers: [], + confirmationsRequired: 2, + confirmations: [], + rejectors: [], + trusted: true, +} + +const mockProps = { + txInfo: mockExitTxInfo, + executionInfo: mockExecutionInfo, + txId: 'test-tx-id', +} + +describe('StakingExit', () => { + it('renders correctly with exit information', () => { + const { getByText, getAllByText } = render(<StakingExit {...mockProps} />) + + expect(getAllByText(/32.*ETH/)).toHaveLength(2) // TokenAmount in header and receive row + expect(getByText('Receive')).toBeTruthy() // Receive label + }) + + it('matches snapshot', () => { + const component = render(<StakingExit {...mockProps} />) + expect(component).toMatchSnapshot() + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/Exit.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/Exit.tsx new file mode 100644 index 0000000000..229f020f4b --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/Exit.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { ListTable } from '../../../ListTable' +import { YStack, XStack } from 'tamagui' +import { TransactionHeader } from '../../../TransactionHeader' +import { + MultisigExecutionDetails, + NativeStakingWithdrawTransactionInfo, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TokenAmount } from '@/src/components/TokenAmount' +import { ParametersButton } from '../../../ParametersButton' + +interface StakingExitProps { + txInfo: NativeStakingWithdrawTransactionInfo + executionInfo: MultisigExecutionDetails + txId: string +} + +export function StakingExit({ txInfo, executionInfo, txId }: StakingExitProps) { + const receiveItems = [ + { + label: 'Receive', + render: () => ( + <TokenAmount + value={txInfo.value} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + textProps={{ fontWeight: 600 }} + /> + ), + }, + ] + + return ( + <YStack gap="$4"> + <TransactionHeader + logo={txInfo.tokenInfo.logoUri ?? undefined} + badgeIcon="transaction-stake" + badgeColor="$textSecondaryLight" + title={ + <XStack gap="$1"> + <TokenAmount + value={txInfo.value} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + /> + </XStack> + } + submittedAt={executionInfo.submittedAt} + /> + + <ListTable items={receiveItems}> + <ParametersButton txId={txId} /> + </ListTable> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/__snapshots__/Exit.test.tsx.snap b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/__snapshots__/Exit.test.tsx.snap new file mode 100644 index 0000000000..33f981b78f --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/__snapshots__/Exit.test.tsx.snap @@ -0,0 +1,389 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StakingExit matches snapshot 1`] = ` +<View> + <View + style={ + { + "flexDirection": "column", + "gap": 16, + } + } + > + <View + style={ + { + "alignItems": "center", + "flexDirection": "column", + "gap": 8, + "marginTop": 16, + "position": "relative", + } + } + > + <View + style={ + { + "width": 40, + } + } + > + <View + style={ + { + "position": "absolute", + "right": -10, + "top": -10, + "zIndex": 1, + } + } + > + <View + style={ + { + "alignItems": "center", + "backgroundColor": "#DDDEE0", + "borderBottomColor": "#EEEFF0", + "borderBottomLeftRadius": 100000, + "borderBottomRightRadius": 100000, + "borderBottomWidth": 1, + "borderLeftColor": "#EEEFF0", + "borderLeftWidth": 1, + "borderRightColor": "#EEEFF0", + "borderRightWidth": 1, + "borderStyle": "solid", + "borderTopColor": "#EEEFF0", + "borderTopLeftRadius": 100000, + "borderTopRightRadius": 100000, + "borderTopWidth": 1, + "flexDirection": "column", + "height": 24, + "justifyContent": "center", + "maxHeight": 24, + "maxWidth": 24, + "minHeight": 24, + "minWidth": 24, + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "width": 24, + } + } + > + <Text + allowFontScaling={false} + selectable={false} + style={ + [ + { + "color": "#A1A3A7", + "fontSize": 12, + }, + undefined, + { + "fontFamily": "SafeIcons", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + </Text> + </View> + </View> + <View + style={ + { + "alignItems": "center", + "borderBottomLeftRadius": 100000, + "borderBottomRightRadius": 100000, + "borderTopLeftRadius": 100000, + "borderTopRightRadius": 100000, + "flexDirection": "column", + "height": 40, + "justifyContent": "center", + "maxHeight": 40, + "maxWidth": 40, + "minHeight": 40, + "minWidth": 40, + "overflow": "hidden", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "position": "relative", + "width": 40, + } + } + > + <View + style={ + { + "bottom": 0, + "flexDirection": "column", + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + "zIndex": 1, + } + } + > + <Image + fullscreen={true} + onError={[Function]} + onLoad={[Function]} + source={ + { + "uri": "https://safe-transaction-assets.safe.global/chains/1/chain_logo.png", + } + } + style={ + { + "backgroundColor": "#121312", + "height": 40, + "width": 40, + } + } + testID="logo-image" + /> + </View> + <View + style={ + { + "backgroundColor": "#EEEFF0", + "bottom": 0, + "flexDirection": "column", + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + "zIndex": 0, + } + } + > + <View + style={ + { + "alignItems": "center", + "backgroundColor": "#EEEFF0", + "borderBottomLeftRadius": 0, + "borderBottomRightRadius": 0, + "borderTopLeftRadius": 0, + "borderTopRightRadius": 0, + "display": "flex", + "height": 40, + "justifyContent": "center", + "width": 40, + } + } + > + <Text + allowFontScaling={false} + selectable={false} + style={ + [ + { + "color": "#A1A3A7", + "fontSize": 16, + }, + undefined, + { + "fontFamily": "SafeIcons", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + testID="logo-fallback-icon" + > +  + </Text> + </View> + </View> + </View> + </View> + <View + style={ + { + "alignItems": "center", + "gap": 8, + } + } + > + <View + style={ + { + "flexDirection": "row", + "gap": 4, + } + } + > + <Text + style={ + { + "color": "#121312", + "fontFamily": "DMSans-Bold", + } + } + suppressHighlighting={true} + > + 32 + + ETH + </Text> + </View> + <Text + style={ + { + "color": "#A1A3A7", + "fontFamily": "DM Sans", + "fontSize": 12, + "lineHeight": 16, + } + } + suppressHighlighting={true} + > + 15 Jan 1970 + , + 06:56 AM + </Text> + </View> + </View> + <View + style={ + { + "backgroundColor": "#FFFFFF", + "borderBottomLeftRadius": 7, + "borderBottomRightRadius": 7, + "borderTopLeftRadius": 7, + "borderTopRightRadius": 7, + "flexDirection": "column", + "gap": 20, + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + } + } + > + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "flexWrap": "wrap", + "gap": 8, + "justifyContent": "space-between", + } + } + > + <Text + style={ + { + "color": "#A1A3A7", + "flex": 1, + "fontFamily": "DM Sans", + "fontSize": 14, + } + } + suppressHighlighting={true} + > + Receive + </Text> + <Text + style={ + { + "color": "#121312", + "fontFamily": "DMSans-SemiBold", + } + } + suppressHighlighting={true} + > + 32 + + ETH + </Text> + </View> + <View + style={ + { + "alignItems": "center", + "height": 40, + } + } + > + <View + focusVisibleStyle={{}} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + role="button" + style={ + { + "alignItems": "center", + "backgroundColor": "#DCDEE0", + "borderBottomColor": "transparent", + "borderBottomLeftRadius": 8, + "borderBottomRightRadius": 8, + "borderBottomWidth": 0, + "borderLeftColor": "transparent", + "borderLeftWidth": 0, + "borderRightColor": "transparent", + "borderRightWidth": 0, + "borderStyle": "solid", + "borderTopColor": "transparent", + "borderTopLeftRadius": 8, + "borderTopRightRadius": 8, + "borderTopWidth": 0, + "bottom": 0, + "flexDirection": "row", + "flexWrap": "nowrap", + "fontWeight": "700", + "height": 36, + "justifyContent": "center", + "left": 0, + "paddingLeft": 8, + "paddingRight": 8, + "position": "absolute", + "right": 0, + "top": 0, + } + } + > + <Text + lineBreakMode="clip" + numberOfLines={1} + style={ + { + "color": "#121312", + "flexGrow": 0, + "flexShrink": 1, + "fontFamily": "DMSans-Bold", + "fontSize": 14, + "letterSpacing": 0, + "lineHeight": 15.400000000000002, + "userSelect": "none", + } + } + suppressHighlighting={true} + > + Transaction details + </Text> + </View> + </View> + </View> + </View> +</View> +`; diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/index.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/index.tsx new file mode 100644 index 0000000000..65ea836d30 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/Exit/index.tsx @@ -0,0 +1 @@ +export { StakingExit } from './Exit' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/WithdrawRequest.stories.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/WithdrawRequest.stories.tsx new file mode 100644 index 0000000000..862ec25ffc --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/WithdrawRequest.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { StakingWithdrawRequest } from './WithdrawRequest' +import { + NativeStakingValidatorsExitTransactionInfo, + MultisigExecutionDetails, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +// Mock data for NativeStakingValidatorsExitTransactionInfo +const mockWithdrawRequestInfo: NativeStakingValidatorsExitTransactionInfo = { + type: 'NativeStakingValidatorsExit', + humanDescription: 'Request withdrawal of staked tokens', + status: 'ACTIVE', + estimatedExitTime: 30 * 86400000, // 30 days in milliseconds + estimatedWithdrawalTime: 2 * 86400000, // 2 days in milliseconds + value: '32000000000000000000', // 32 ETH in wei + numValidators: 1, + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, + validators: ['0x123...abc'], +} + +const mockExecutionInfo: MultisigExecutionDetails = { + type: 'MULTISIG', + nonce: 42, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + name: null, + logoUri: null, + }, + safeTxHash: '0x123abc456def', + submittedAt: Date.now() - 3600000, // 1 hour ago + signers: [], + confirmationsRequired: 2, + confirmations: [], + rejectors: [], + executor: null, + gasTokenInfo: null, + trusted: true, +} + +const meta: Meta<typeof StakingWithdrawRequest> = { + title: 'ConfirmTx/StakingWithdrawRequest', + component: StakingWithdrawRequest, + argTypes: {}, + args: { + txInfo: mockWithdrawRequestInfo, + executionInfo: mockExecutionInfo, + txId: 'test-withdraw-request-tx-id', + }, +} + +export default meta + +type Story = StoryObj<typeof StakingWithdrawRequest> + +export const Default: Story = { + args: { + txInfo: mockWithdrawRequestInfo, + executionInfo: mockExecutionInfo, + txId: 'test-withdraw-request-tx-id', + }, +} + +export const MultipleValidators: Story = { + args: { + txInfo: { + ...mockWithdrawRequestInfo, + numValidators: 5, + value: '160000000000000000000', // 160 ETH for 5 validators + }, + executionInfo: mockExecutionInfo, + txId: 'test-withdraw-request-multi-tx-id', + }, +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/WithdrawRequest.test.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/WithdrawRequest.test.tsx new file mode 100644 index 0000000000..c074a49e6f --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/WithdrawRequest.test.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { StakingWithdrawRequest } from './WithdrawRequest' +import { + NativeStakingValidatorsExitTransactionInfo, + MultisigExecutionDetails, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +const mockWithdrawRequestTxInfo: NativeStakingValidatorsExitTransactionInfo = { + type: 'NativeStakingValidatorsExit', + humanDescription: 'Request withdrawal of staked tokens', + status: 'ACTIVE', + estimatedExitTime: 30 * 86400000, // 30 days in milliseconds + estimatedWithdrawalTime: 2 * 86400000, // 2 days in milliseconds + value: '32000000000000000000', // 32 ETH in wei + numValidators: 1, + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, + validators: ['0x123...abc'], +} + +const mockExecutionInfo: MultisigExecutionDetails = { + type: 'MULTISIG', + submittedAt: 1234567890, + nonce: 1, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + }, + safeTxHash: '0x123', + signers: [], + confirmationsRequired: 2, + confirmations: [], + rejectors: [], + trusted: true, +} + +const mockProps = { + txInfo: mockWithdrawRequestTxInfo, + executionInfo: mockExecutionInfo, + txId: 'test-tx-id', +} + +describe('StakingWithdrawRequest', () => { + it('renders correctly with withdraw request information', () => { + const { getByText, getAllByText } = render(<StakingWithdrawRequest {...mockProps} />) + + expect(getAllByText(/32.*ETH/)).toHaveLength(2) // TokenAmount in header and receive row + expect(getByText('Exit')).toBeTruthy() // Exit label + expect(getByText('1 Validator')).toBeTruthy() // Validator count + expect(getAllByText('Receive')).toHaveLength(2) // Receive label (header and table) + expect(getByText('Withdraw in')).toBeTruthy() // Withdraw in label + expect(getByText(/Up to/)).toBeTruthy() // Withdraw timing + expect(getByText(/withdrawal request/)).toBeTruthy() // Warning message + expect(getByText(/Dedicated Staking for ETH/)).toBeTruthy() // Description + }) + + it('renders multiple validators correctly', () => { + const multiValidatorProps = { + ...mockProps, + txInfo: { + ...mockWithdrawRequestTxInfo, + numValidators: 3, + }, + } + + const { getByText } = render(<StakingWithdrawRequest {...multiValidatorProps} />) + + expect(getByText('Exit')).toBeTruthy() + expect(getByText('3 Validators')).toBeTruthy() // Multiple validators with plural form + }) + + it('matches snapshot', () => { + const component = render(<StakingWithdrawRequest {...mockProps} />) + expect(component).toMatchSnapshot() + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/WithdrawRequest.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/WithdrawRequest.tsx new file mode 100644 index 0000000000..011586162f --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/WithdrawRequest.tsx @@ -0,0 +1,57 @@ +import React, { useMemo } from 'react' +import { ListTable } from '../../../ListTable' +import { formatStakingWithdrawRequestItems } from '../utils' +import { YStack, Text, XStack } from 'tamagui' +import { TransactionHeader } from '../../../TransactionHeader' +import { + MultisigExecutionDetails, + NativeStakingValidatorsExitTransactionInfo, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TokenAmount } from '@/src/components/TokenAmount' +import { ParametersButton } from '../../../ParametersButton' +import { Alert2 } from '@/src/components/Alert2' + +interface StakingWithdrawRequestProps { + txInfo: NativeStakingValidatorsExitTransactionInfo + executionInfo: MultisigExecutionDetails + txId: string +} + +export function StakingWithdrawRequest({ txInfo, executionInfo, txId }: StakingWithdrawRequestProps) { + const withdrawRequestItems = useMemo(() => formatStakingWithdrawRequestItems(txInfo), [txInfo]) + + return ( + <YStack gap="$4"> + <TransactionHeader + logo={txInfo.tokenInfo.logoUri ?? undefined} + badgeIcon="transaction-stake" + badgeColor="$textSecondaryLight" + title={ + <XStack gap="$1"> + <Text>Receive</Text> + <TokenAmount + value={txInfo.value} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + /> + </XStack> + } + submittedAt={executionInfo.submittedAt} + /> + + <ListTable items={withdrawRequestItems}> + <ParametersButton txId={txId} /> + <Text fontSize="$3" color="$textSecondaryLight"> + The selected amount and any rewards will be withdrawn from Dedicated Staking for ETH after the validator exit. + </Text> + </ListTable> + + <YStack gap="$3"> + <Alert2 + type="warning" + message="This transaction is a withdrawal request. After it's executed, you'll need to complete a separate withdrawal transaction." + /> + </YStack> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/__snapshots__/WithdrawRequest.test.tsx.snap b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/__snapshots__/WithdrawRequest.test.tsx.snap new file mode 100644 index 0000000000..0139318521 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/__snapshots__/WithdrawRequest.test.tsx.snap @@ -0,0 +1,590 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StakingWithdrawRequest matches snapshot 1`] = ` +<View> + <View + style={ + { + "flexDirection": "column", + "gap": 16, + } + } + > + <View + style={ + { + "alignItems": "center", + "flexDirection": "column", + "gap": 8, + "marginTop": 16, + "position": "relative", + } + } + > + <View + style={ + { + "width": 40, + } + } + > + <View + style={ + { + "position": "absolute", + "right": -10, + "top": -10, + "zIndex": 1, + } + } + > + <View + style={ + { + "alignItems": "center", + "backgroundColor": "#DDDEE0", + "borderBottomColor": "#EEEFF0", + "borderBottomLeftRadius": 100000, + "borderBottomRightRadius": 100000, + "borderBottomWidth": 1, + "borderLeftColor": "#EEEFF0", + "borderLeftWidth": 1, + "borderRightColor": "#EEEFF0", + "borderRightWidth": 1, + "borderStyle": "solid", + "borderTopColor": "#EEEFF0", + "borderTopLeftRadius": 100000, + "borderTopRightRadius": 100000, + "borderTopWidth": 1, + "flexDirection": "column", + "height": 24, + "justifyContent": "center", + "maxHeight": 24, + "maxWidth": 24, + "minHeight": 24, + "minWidth": 24, + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "width": 24, + } + } + > + <Text + allowFontScaling={false} + selectable={false} + style={ + [ + { + "color": "#A1A3A7", + "fontSize": 12, + }, + undefined, + { + "fontFamily": "SafeIcons", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + </Text> + </View> + </View> + <View + style={ + { + "alignItems": "center", + "borderBottomLeftRadius": 100000, + "borderBottomRightRadius": 100000, + "borderTopLeftRadius": 100000, + "borderTopRightRadius": 100000, + "flexDirection": "column", + "height": 40, + "justifyContent": "center", + "maxHeight": 40, + "maxWidth": 40, + "minHeight": 40, + "minWidth": 40, + "overflow": "hidden", + "paddingBottom": 0, + "paddingLeft": 0, + "paddingRight": 0, + "paddingTop": 0, + "position": "relative", + "width": 40, + } + } + > + <View + style={ + { + "bottom": 0, + "flexDirection": "column", + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + "zIndex": 1, + } + } + > + <Image + fullscreen={true} + onError={[Function]} + onLoad={[Function]} + source={ + { + "uri": "https://safe-transaction-assets.safe.global/chains/1/chain_logo.png", + } + } + style={ + { + "backgroundColor": "#121312", + "height": 40, + "width": 40, + } + } + testID="logo-image" + /> + </View> + <View + style={ + { + "backgroundColor": "#EEEFF0", + "bottom": 0, + "flexDirection": "column", + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + "zIndex": 0, + } + } + > + <View + style={ + { + "alignItems": "center", + "backgroundColor": "#EEEFF0", + "borderBottomLeftRadius": 0, + "borderBottomRightRadius": 0, + "borderTopLeftRadius": 0, + "borderTopRightRadius": 0, + "display": "flex", + "height": 40, + "justifyContent": "center", + "width": 40, + } + } + > + <Text + allowFontScaling={false} + selectable={false} + style={ + [ + { + "color": "#A1A3A7", + "fontSize": 16, + }, + undefined, + { + "fontFamily": "SafeIcons", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + testID="logo-fallback-icon" + > +  + </Text> + </View> + </View> + </View> + </View> + <View + style={ + { + "alignItems": "center", + "gap": 8, + } + } + > + <View + style={ + { + "flexDirection": "row", + "gap": 4, + } + } + > + <Text + style={ + { + "color": "#121312", + "fontFamily": "DM Sans", + } + } + suppressHighlighting={true} + > + Receive + </Text> + <Text + style={ + { + "color": "#121312", + "fontFamily": "DMSans-Bold", + } + } + suppressHighlighting={true} + > + 32 + + ETH + </Text> + </View> + <Text + style={ + { + "color": "#A1A3A7", + "fontFamily": "DM Sans", + "fontSize": 12, + "lineHeight": 16, + } + } + suppressHighlighting={true} + > + 15 Jan 1970 + , + 06:56 AM + </Text> + </View> + </View> + <View + style={ + { + "backgroundColor": "#FFFFFF", + "borderBottomLeftRadius": 7, + "borderBottomRightRadius": 7, + "borderTopLeftRadius": 7, + "borderTopRightRadius": 7, + "flexDirection": "column", + "gap": 20, + "paddingBottom": 16, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 16, + } + } + > + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "flexWrap": "wrap", + "gap": 8, + "justifyContent": "space-between", + } + } + > + <Text + style={ + { + "color": "#A1A3A7", + "flex": 1, + "fontFamily": "DM Sans", + "fontSize": 14, + } + } + suppressHighlighting={true} + > + Exit + </Text> + <Text + style={ + { + "color": "#121312", + "flex": 2, + "fontFamily": "DM Sans", + "fontSize": 14, + "textAlign": "right", + } + } + suppressHighlighting={true} + > + 1 Validator + </Text> + </View> + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "flexWrap": "wrap", + "gap": 8, + "justifyContent": "space-between", + } + } + > + <Text + style={ + { + "color": "#A1A3A7", + "flex": 1, + "fontFamily": "DM Sans", + "fontSize": 14, + } + } + suppressHighlighting={true} + > + Receive + </Text> + <Text + style={ + { + "color": "#121312", + "fontFamily": "DMSans-SemiBold", + } + } + suppressHighlighting={true} + > + 32 + + ETH + </Text> + </View> + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "flexWrap": "wrap", + "gap": 8, + "justifyContent": "space-between", + } + } + > + <Text + style={ + { + "color": "#A1A3A7", + "flex": 1, + "fontFamily": "DM Sans", + "fontSize": 14, + } + } + suppressHighlighting={true} + > + Withdraw in + </Text> + <Text + style={ + { + "color": "#121312", + "flex": 2, + "fontFamily": "DM Sans", + "fontSize": 14, + "textAlign": "right", + } + } + suppressHighlighting={true} + > + Up to 1 day + </Text> + </View> + <View + style={ + { + "alignItems": "center", + "height": 40, + } + } + > + <View + focusVisibleStyle={{}} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + role="button" + style={ + { + "alignItems": "center", + "backgroundColor": "#DCDEE0", + "borderBottomColor": "transparent", + "borderBottomLeftRadius": 8, + "borderBottomRightRadius": 8, + "borderBottomWidth": 0, + "borderLeftColor": "transparent", + "borderLeftWidth": 0, + "borderRightColor": "transparent", + "borderRightWidth": 0, + "borderStyle": "solid", + "borderTopColor": "transparent", + "borderTopLeftRadius": 8, + "borderTopRightRadius": 8, + "borderTopWidth": 0, + "bottom": 0, + "flexDirection": "row", + "flexWrap": "nowrap", + "fontWeight": "700", + "height": 36, + "justifyContent": "center", + "left": 0, + "paddingLeft": 8, + "paddingRight": 8, + "position": "absolute", + "right": 0, + "top": 0, + } + } + > + <Text + lineBreakMode="clip" + numberOfLines={1} + style={ + { + "color": "#121312", + "flexGrow": 0, + "flexShrink": 1, + "fontFamily": "DMSans-Bold", + "fontSize": 14, + "letterSpacing": 0, + "lineHeight": 15.400000000000002, + "userSelect": "none", + } + } + suppressHighlighting={true} + > + Transaction details + </Text> + </View> + </View> + <Text + style={ + { + "color": "#A1A3A7", + "fontFamily": "DM Sans", + "fontSize": 13, + } + } + suppressHighlighting={true} + > + The selected amount and any rewards will be withdrawn from Dedicated Staking for ETH after the validator exit. + </Text> + </View> + <View + style={ + { + "flexDirection": "column", + "gap": 12, + } + } + > + <View + style={ + { + "alignItems": "flex-start", + "backgroundColor": "#FFECC2", + "borderBottomLeftRadius": 5, + "borderBottomRightRadius": 5, + "borderTopLeftRadius": 5, + "borderTopRightRadius": 5, + "flexDirection": "row", + "gap": 12, + "paddingBottom": 12, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 12, + } + } + > + <View + style={ + { + "alignItems": "center", + "justifyContent": "center", + } + } + testID="alert-start-icon" + > + <View + style={ + { + "alignItems": "center", + "backgroundColor": "#FF8C00", + "borderBottomLeftRadius": 34, + "borderBottomRightRadius": 34, + "borderTopLeftRadius": 34, + "borderTopRightRadius": 34, + "display": "flex", + "justifyContent": "center", + "paddingBottom": 4, + "paddingLeft": 4, + "paddingRight": 4, + "paddingTop": 4, + } + } + > + <Text + allowFontScaling={false} + selectable={false} + style={ + [ + { + "color": "#FFFFFF", + "fontSize": 16, + }, + undefined, + { + "fontFamily": "SafeIcons", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + testID="warning-icon" + > +  + </Text> + </View> + </View> + <View + style={ + { + "flex": 1, + "gap": 4, + } + } + > + <Text + style={ + { + "color": "#6C2D19", + "fontFamily": "DM Sans", + "fontSize": 13, + } + } + suppressHighlighting={true} + > + This transaction is a withdrawal request. After it's executed, you'll need to complete a separate withdrawal transaction. + </Text> + </View> + </View> + </View> + </View> +</View> +`; diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/index.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/index.tsx new file mode 100644 index 0000000000..202d6d5dc4 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/WithdrawRequest/index.tsx @@ -0,0 +1 @@ +export { StakingWithdrawRequest } from './WithdrawRequest' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/index.ts b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/index.ts new file mode 100644 index 0000000000..80e090e94d --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/index.ts @@ -0,0 +1,3 @@ +export { StakingDeposit } from './Deposit' +export { StakingWithdrawRequest } from './WithdrawRequest' +export { StakingExit } from './Exit' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/utils.test.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/utils.test.tsx new file mode 100644 index 0000000000..fbded177ca --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/utils.test.tsx @@ -0,0 +1,147 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { formatStakingDepositItems, formatStakingValidatorItems, formatStakingWithdrawRequestItems } from './utils' +import { + NativeStakingDepositTransactionInfo, + NativeStakingValidatorsExitTransactionInfo, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +const mockDepositTxInfo: NativeStakingDepositTransactionInfo = { + type: 'NativeStakingDeposit', + humanDescription: 'Deposit tokens for staking', + status: 'ACTIVE', + estimatedEntryTime: 86400000, // 1 day in milliseconds + estimatedExitTime: 30 * 86400000, // 30 days in milliseconds + estimatedWithdrawalTime: 32 * 86400000, // 32 days in milliseconds + fee: 0.05, // 5% fee + monthlyNrr: 4.2, + annualNrr: 50.4, + value: '32000000000000000000', // 32 ETH in wei + numValidators: 1, + expectedAnnualReward: '1612800000000000000', + expectedMonthlyReward: '134400000000000000', + expectedFiatAnnualReward: 4838.4, + expectedFiatMonthlyReward: 403.2, + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, + validators: ['0x123...abc'], +} + +const mockWithdrawRequestTxInfo: NativeStakingValidatorsExitTransactionInfo = { + type: 'NativeStakingValidatorsExit', + humanDescription: 'Request withdrawal of staked tokens', + status: 'ACTIVE', + estimatedExitTime: 30 * 86400000, // 30 days in milliseconds + estimatedWithdrawalTime: 2 * 86400000, // 2 days in milliseconds + value: '32000000000000000000', // 32 ETH in wei + numValidators: 1, + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, + validators: ['0x123...abc'], +} + +describe('Staking Utils', () => { + describe('formatStakingDepositItems', () => { + it('formats deposit information correctly', () => { + const items = formatStakingDepositItems(mockDepositTxInfo) + + expect(items).toHaveLength(4) + + const rewardsRateItem = items[0] as { label: string; value: string } + expect(rewardsRateItem.label).toBe('Rewards rate') + expect(rewardsRateItem.value).toBe('50.400%') + + const widgetFeeItem = items[3] as { label: string; value: string } + expect(widgetFeeItem.label).toBe('Widget fee') + expect(widgetFeeItem.value).toBe('5.00%') + }) + }) + + describe('formatStakingValidatorItems', () => { + it('formats validator information correctly', () => { + const items = formatStakingValidatorItems(mockDepositTxInfo) + + expect(items).toHaveLength(3) + + const validatorItem = items[0] as { label: string; value: string } + expect(validatorItem.label).toBe('Validator') + expect(validatorItem.value).toBe('1') + + const activationTimeItem = items[1] as { label: string } + expect(activationTimeItem.label).toBe('Activation time') + + const rewardsItem = items[2] as { label: string; value: string } + expect(rewardsItem.label).toBe('Rewards') + expect(rewardsItem.value).toBe('Approx. every 5 days after activation') + }) + }) + + describe('formatStakingWithdrawRequestItems', () => { + it('formats withdraw request information correctly', () => { + const items = formatStakingWithdrawRequestItems(mockWithdrawRequestTxInfo) + + expect(items).toHaveLength(3) + + const exitItem = items[0] as { label: string; value: string } + expect(exitItem.label).toBe('Exit') + expect(exitItem.value).toBe('1 Validator') + + const receiveItem = items[1] as { label: string } + expect(receiveItem.label).toBe('Receive') + + const withdrawInItem = items[2] as { label: string; value: string } + expect(withdrawInItem.label).toBe('Withdraw in') + expect(withdrawInItem.value).toMatch(/Up to.*day/) + }) + + it('handles multiple validators correctly', () => { + const multiValidatorInfo = { + ...mockWithdrawRequestTxInfo, + numValidators: 5, + } + + const items = formatStakingWithdrawRequestItems(multiValidatorInfo) + const exitItem = items[0] as { label: string; value: string } + + expect(exitItem.value).toBe('5 Validators') + }) + + it('handles single validator correctly', () => { + const singleValidatorInfo = { + ...mockWithdrawRequestTxInfo, + numValidators: 1, + } + + const items = formatStakingWithdrawRequestItems(singleValidatorInfo) + const exitItem = items[0] as { label: string; value: string } + + expect(exitItem.value).toBe('1 Validator') + }) + + it('renders receive token amount', () => { + const items = formatStakingWithdrawRequestItems(mockWithdrawRequestTxInfo) + const receiveItem = items.find((_item, index) => index === 1) as { label: string; render?: () => React.ReactNode } + + expect(receiveItem).toBeDefined() + expect(receiveItem.label).toBe('Receive') + expect(receiveItem.render).toBeDefined() + + if (receiveItem.render) { + const { getByText } = render(<>{receiveItem.render()}</>) + expect(getByText(/32.*ETH/)).toBeTruthy() + } + }) + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/utils.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/utils.tsx new file mode 100644 index 0000000000..3af7c5fc00 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/Stake/utils.tsx @@ -0,0 +1,110 @@ +import { TokenAmount } from '@/src/components/TokenAmount' + +import { formatCurrency } from '@safe-global/utils/utils/formatNumber' +import { formatDurationFromMilliseconds } from '@safe-global/utils/utils/formatters' +import { Text, View } from 'tamagui' +import { + NativeStakingDepositTransactionInfo, + NativeStakingValidatorsExitTransactionInfo, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ListTableItem } from '../../ListTable' + +const CURRENCY = 'USD' + +export const stakingTypeToLabel = { + NativeStakingDeposit: 'Deposit', + NativeStakingValidatorsExit: 'Withdraw request', + NativeStakingWithdraw: 'Claim', +} as const + +export const formatStakingDepositItems = (txInfo: NativeStakingDepositTransactionInfo): ListTableItem[] => { + // Fee is returned in decimal format, multiply by 100 for percentage + const fee = (txInfo.fee * 100).toFixed(2) + + return [ + { + label: 'Rewards rate', + value: `${txInfo.annualNrr.toFixed(3)}%`, + }, + { + label: 'Net annual rewards', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$1"> + <TokenAmount + value={txInfo.expectedAnnualReward} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + textProps={{ fontWeight: 400 }} + /> + <Text color="$textSecondaryLight">({formatCurrency(txInfo.expectedFiatAnnualReward, CURRENCY)})</Text> + </View> + ), + }, + { + label: 'Net monthly rewards', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$1"> + <TokenAmount + value={txInfo.expectedMonthlyReward} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + textProps={{ fontWeight: 400 }} + /> + <Text color="$textSecondaryLight">({formatCurrency(txInfo.expectedFiatMonthlyReward, CURRENCY)})</Text> + </View> + ), + }, + { + label: 'Widget fee', + value: `${fee}%`, + }, + ] +} + +export const formatStakingValidatorItems = (txInfo: NativeStakingDepositTransactionInfo): ListTableItem[] => { + return [ + { + label: 'Validator', + value: `${txInfo.numValidators}`, + }, + { + label: 'Activation time', + value: formatDurationFromMilliseconds(txInfo.estimatedEntryTime), + }, + { + label: 'Rewards', + value: 'Approx. every 5 days after activation', + }, + ] +} + +export const formatStakingWithdrawRequestItems = ( + txInfo: NativeStakingValidatorsExitTransactionInfo, +): ListTableItem[] => { + const withdrawIn = formatDurationFromMilliseconds(txInfo.estimatedExitTime + txInfo.estimatedWithdrawalTime, [ + 'days', + 'hours', + ]) + + return [ + { + label: 'Exit', + value: `${txInfo.numValidators} Validator${txInfo.numValidators !== 1 ? 's' : ''}`, + }, + { + label: 'Receive', + render: () => ( + <TokenAmount + value={txInfo.value} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + textProps={{ fontWeight: 600 }} + /> + ), + }, + { + label: 'Withdraw in', + value: `Up to ${withdrawIn}`, + }, + ] +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/StatusLabel/index.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/StatusLabel/index.tsx new file mode 100644 index 0000000000..2ab9470673 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/StatusLabel/index.tsx @@ -0,0 +1,78 @@ +import React, { ReactElement } from 'react' +import { View, Text } from 'tamagui' +import { Badge } from '@/src/components/Badge' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { type BadgeThemeTypes } from '@/src/components/Badge/Badge' +import { OrderTransactionInfo as Order } from '@safe-global/store/gateway/types' + +type CustomOrderStatuses = Order['status'] | 'partiallyFilled' +type Props = { + status: CustomOrderStatuses +} + +type StatusProps = { + label: string + themeName: BadgeThemeTypes + icon: React.ReactElement | null +} + +const statusMap: Record<CustomOrderStatuses, StatusProps> = { + presignaturePending: { + label: 'Execution needed', + themeName: 'badge_warning', + icon: <SafeFontIcon name="sign" size={14} color="$color" />, + }, + fulfilled: { + label: 'Filled', + themeName: 'badge_success', + icon: <SafeFontIcon name="check" size={14} color="$color" />, + }, + open: { + label: 'Open', + themeName: 'badge_warning', + icon: <SafeFontIcon name="clock" size={14} color="$color" />, + }, + cancelled: { + label: 'Cancelled', + themeName: 'badge_error', + icon: <SafeFontIcon name="block" size={14} color="$color" />, + }, + expired: { + label: 'Expired', + themeName: 'badge_background', + icon: <SafeFontIcon name="clock" size={14} color="$color" />, + }, + partiallyFilled: { + label: 'Partially filled', + themeName: 'badge_success', + icon: null, + }, + unknown: { + label: 'Unknown', + themeName: 'badge_background', + icon: null, + }, +} + +export const StatusLabel = (props: Props): ReactElement => { + const { status } = props + const { label, themeName, icon } = statusMap[status] + + return ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Badge + circular={false} + themeName={themeName} + textContentProps={{ fontWeight: 500 }} + content={ + <View flexDirection="row" alignItems="center" gap="$2"> + {icon} + <Text color="$color">{label}</Text> + </View> + } + /> + </View> + ) +} + +export default StatusLabel diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/SwapOrder.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/SwapOrder.tsx new file mode 100644 index 0000000000..2fc1d91068 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/SwapOrder.tsx @@ -0,0 +1,100 @@ +import React, { useMemo } from 'react' +import { SwapOrderHeader } from './SwapOrderHeader' +import { View, YStack } from 'tamagui' +import { formatSwapOrderItems, formatTwapOrderItems } from './utils' +import { ListTable } from '../../ListTable' +import { DataDecoded, MultisigExecutionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { OrderTransactionInfo } from '@safe-global/store/gateway/types' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useAppSelector } from '@/src/store/hooks' +import { selectChainById } from '@/src/store/chains' +import { isMultiSendData, isTwapOrderTxInfo } from '@/src/utils/transaction-guards' +import { isSettingTwapFallbackHandler } from '@safe-global/utils/features/swap/helpers/utils' +import { TwapFallbackHandlerWarning } from '@/src/features/ConfirmTx/components/confirmation-views/SwapOrder/TwapFallbackHandlerWarning' +import { Alert2 } from '@/src/components/Alert2' +import { useRecipientItem } from './hooks' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Badge } from '@/src/components/Badge' +import { SafeListItem } from '@/src/components/SafeListItem' +import { useRouter } from 'expo-router' +import { ParametersButton } from '@/src/features/ConfirmTx/components/ParametersButton' + +interface SwapOrderProps { + executionInfo: MultisigExecutionDetails + txInfo: OrderTransactionInfo + decodedData?: DataDecoded | null + txId: string +} + +export function SwapOrder({ executionInfo, txInfo, decodedData, txId }: SwapOrderProps) { + const order = txInfo + const router = useRouter() + const isTwapOrder = isTwapOrderTxInfo(order) + + const activeSafe = useDefinedActiveSafe() + const chain = useAppSelector((state) => selectChainById(state, activeSafe.chainId)) + + const swapItems = useMemo(() => formatSwapOrderItems(txInfo, chain), [txInfo, chain]) + + const twapItems = useMemo(() => { + return isTwapOrder ? formatTwapOrderItems(order) : [] + }, [order, chain]) + + const isChangingFallbackHandler = decodedData && isSettingTwapFallbackHandler(decodedData) + + const recipientItems = useRecipientItem(order) + + const showRecipientWarning = order.receiver && order.owner !== order.receiver + + const handleViewActions = () => { + router.push({ + pathname: '/transaction-actions', + params: { txId }, + }) + } + + return ( + <YStack gap="$4"> + {isChangingFallbackHandler && <TwapFallbackHandlerWarning />} + <SwapOrderHeader executionInfo={executionInfo} txInfo={txInfo} /> + + <ListTable items={swapItems}> + <ParametersButton txId={txId} /> + </ListTable> + {recipientItems.length > 0 && <ListTable items={recipientItems} />} + {isTwapOrder && <ListTable items={twapItems} />} + + {showRecipientWarning && ( + <Alert2 + type="warning" + title="Order recipient address differs from order owner." + message="Double check the address to prevent fund loss." + testID="recipient-warning-alert" + /> + )} + + {decodedData && isMultiSendData(decodedData) && ( + <SafeListItem + label="Actions" + rightNode={ + <View flexDirection="row" alignItems="center" gap="$2"> + {decodedData.parameters?.[0]?.valueDecoded && ( + <Badge + themeName="badge_background_inverted" + content={ + Array.isArray(decodedData.parameters[0].valueDecoded) + ? decodedData.parameters[0].valueDecoded.length.toString() + : '1' + } + /> + )} + + <SafeFontIcon name={'chevron-right'} /> + </View> + } + onPress={handleViewActions} + /> + )} + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/SwapOrderHeader.test.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/SwapOrderHeader.test.tsx new file mode 100644 index 0000000000..55c2eb7e36 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/SwapOrderHeader.test.tsx @@ -0,0 +1,222 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { SwapOrderHeader } from './SwapOrderHeader' +import { OrderTransactionInfo } from '@safe-global/store/gateway/types' +import { MultisigExecutionDetails, TokenInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +// Mock the date utils +jest.mock('@/src/utils/date', () => ({ + formatWithSchema: jest.fn((timestamp: number, format: string) => { + const date = new Date(timestamp) + if (format === 'MMM d yyyy') { + return 'Dec 25 2023' + } + if (format === 'hh:mm a') { + return '10:30 AM' + } + return date.toISOString() + }), +})) + +// Mock the formatters +jest.mock('@/src/utils/formatters', () => ({ + formatValue: jest.fn((_amount: string, _decimals: number) => '100.5'), + ellipsis: jest.fn((text: string, length: number) => (text.length > length ? `${text.slice(0, length)}...` : text)), +})) + +// Mock the TokenIcon component +jest.mock('@/src/components/TokenIcon', () => ({ + TokenIcon: ({ accessibilityLabel }: { logoUri: string; accessibilityLabel: string }) => { + const React = require('react') + const { View, Text } = require('react-native') + return React.createElement( + View, + { testID: `token-icon-${accessibilityLabel}` }, + React.createElement(Text, null, accessibilityLabel), + ) + }, +})) + +// Mock the SafeFontIcon component +jest.mock('@/src/components/SafeFontIcon', () => ({ + SafeFontIcon: ({ name }: { name: string }) => { + const React = require('react') + const { View, Text } = require('react-native') + return React.createElement(View, { testID: `safe-font-icon-${name}` }, React.createElement(Text, null, name)) + }, +})) + +describe('SwapOrderHeader', () => { + const mockSellToken: TokenInfo = { + address: '0x123', + decimals: 18, + logoUri: 'https://example.com/eth.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + } + + const mockBuyToken: TokenInfo = { + address: '0x456', + decimals: 6, + logoUri: 'https://example.com/usdc.png', + name: 'USD Coin', + symbol: 'USDC', + trusted: true, + } + + const mockExecutionInfo: MultisigExecutionDetails = { + type: 'MULTISIG', + submittedAt: 1703505000000, // Dec 25 2023 10:30 AM + nonce: 1, + safeTxGas: '100000', + baseGas: '21000', + gasPrice: '20000000000', + gasToken: '0x0', + refundReceiver: { + value: '0x789', + name: 'Refund Receiver', + logoUri: null, + }, + safeTxHash: '0xabc123', + executor: null, + signers: [], + confirmationsRequired: 2, + confirmations: [], + rejectors: [], + gasTokenInfo: null, + trusted: true, + proposer: null, + proposedByDelegate: null, + } + + const createMockTxInfo = (kind: 'sell' | 'buy'): OrderTransactionInfo => ({ + type: 'SwapOrder' as const, + humanDescription: 'Swap order', + uid: 'test-uid', + status: 'open' as const, + kind, + orderClass: 'market' as const, + validUntil: 1703591400, + sellAmount: '1000000000000000000', // 1 ETH in wei + buyAmount: '1500000000', // 1500 USDC (6 decimals) + executedSellAmount: '0', + executedBuyAmount: '0', + sellToken: mockSellToken, + buyToken: mockBuyToken, + explorerUrl: 'https://explorer.com/order/test-uid', + executedFee: '0', + executedFeeToken: '0x0', + receiver: null, + owner: '0x123', + fullAppData: null, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Sell Order', () => { + it('should render sell order with correct labels', () => { + const sellOrderTxInfo = createMockTxInfo('sell') + const { getByText } = render(<SwapOrderHeader txInfo={sellOrderTxInfo} executionInfo={mockExecutionInfo} />) + + // Check date and time display + expect(getByText('Dec 25 2023 at 10:30 AM')).toBeTruthy() + + // Check sell order specific labels + expect(getByText('Sell')).toBeTruthy() + expect(getByText('For at least')).toBeTruthy() + + // Check token amounts are displayed + expect(getByText('100.5 ETH')).toBeTruthy() + expect(getByText('100.5 USDC')).toBeTruthy() + }) + + it('should render token icons with correct accessibility labels', () => { + const sellOrderTxInfo = createMockTxInfo('sell') + const { getByTestId } = render(<SwapOrderHeader txInfo={sellOrderTxInfo} executionInfo={mockExecutionInfo} />) + + expect(getByTestId('token-icon-ETH')).toBeTruthy() + expect(getByTestId('token-icon-USDC')).toBeTruthy() + }) + + it('should render chevron icon', () => { + const sellOrderTxInfo = createMockTxInfo('sell') + const { getByTestId } = render(<SwapOrderHeader txInfo={sellOrderTxInfo} executionInfo={mockExecutionInfo} />) + + expect(getByTestId('safe-font-icon-chevron-right')).toBeTruthy() + }) + }) + + describe('Buy Order', () => { + it('should render buy order with correct labels', () => { + const buyOrderTxInfo = createMockTxInfo('buy') + const { getByText } = render(<SwapOrderHeader txInfo={buyOrderTxInfo} executionInfo={mockExecutionInfo} />) + + // Check date and time display + expect(getByText('Dec 25 2023 at 10:30 AM')).toBeTruthy() + + // Check buy order specific labels + expect(getByText('For at most')).toBeTruthy() + expect(getByText('Buy exactly')).toBeTruthy() + + // Check token amounts are displayed + expect(getByText('100.5 ETH')).toBeTruthy() + expect(getByText('100.5 USDC')).toBeTruthy() + }) + + it('should not show sell order labels for buy orders', () => { + const buyOrderTxInfo = createMockTxInfo('buy') + const { queryByText } = render(<SwapOrderHeader txInfo={buyOrderTxInfo} executionInfo={mockExecutionInfo} />) + + // Sell order labels should not be present + expect(queryByText('Sell')).toBeNull() + expect(queryByText('For at least')).toBeNull() + }) + }) + + describe('Unknown Order Kind', () => { + it('should handle unknown order kind gracefully', () => { + const unknownOrderTxInfo = createMockTxInfo('sell') + unknownOrderTxInfo.kind = 'unknown' as 'buy' | 'sell' | 'unknown' + + const { getByText } = render(<SwapOrderHeader txInfo={unknownOrderTxInfo} executionInfo={mockExecutionInfo} />) + + // Should default to buy order labels when kind is 'unknown' + expect(getByText('For at most')).toBeTruthy() + expect(getByText('Buy exactly')).toBeTruthy() + }) + }) + + describe('Formatters Integration', () => { + it('should call formatValue with correct parameters', () => { + const sellOrderTxInfo = createMockTxInfo('sell') + const { formatValue } = require('@/src/utils/formatters') + + render(<SwapOrderHeader txInfo={sellOrderTxInfo} executionInfo={mockExecutionInfo} />) + + expect(formatValue).toHaveBeenCalledWith('1000000000000000000', 18) // ETH + expect(formatValue).toHaveBeenCalledWith('1500000000', 6) // USDC + }) + + it('should call ellipsis with correct parameters', () => { + const sellOrderTxInfo = createMockTxInfo('sell') + const { ellipsis } = require('@/src/utils/formatters') + + render(<SwapOrderHeader txInfo={sellOrderTxInfo} executionInfo={mockExecutionInfo} />) + + expect(ellipsis).toHaveBeenCalledWith('100.5', 9) + }) + + it('should call formatWithSchema with correct parameters', () => { + const sellOrderTxInfo = createMockTxInfo('sell') + const { formatWithSchema } = require('@/src/utils/date') + + render(<SwapOrderHeader txInfo={sellOrderTxInfo} executionInfo={mockExecutionInfo} />) + + expect(formatWithSchema).toHaveBeenCalledWith(1703505000000, 'MMM d yyyy') + expect(formatWithSchema).toHaveBeenCalledWith(1703505000000, 'hh:mm a') + }) + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/SwapOrderHeader.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/SwapOrderHeader.tsx new file mode 100644 index 0000000000..a8894513d2 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/SwapOrderHeader.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { OrderTransactionInfo } from '@safe-global/store/gateway/types' +import { MultisigExecutionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { formatWithSchema } from '@/src/utils/date' +import { formatValue } from '@/src/utils/formatters' +import { SwapHeader } from '@/src/components/SwapHeader' + +interface SwapOrderHeaderProps { + txInfo: OrderTransactionInfo + executionInfo: MultisigExecutionDetails +} + +export function SwapOrderHeader({ txInfo, executionInfo }: SwapOrderHeaderProps) { + const { sellToken, buyToken, sellAmount, buyAmount, kind } = txInfo + const date = formatWithSchema(executionInfo.submittedAt, 'MMM d yyyy') + const time = formatWithSchema(executionInfo.submittedAt, 'hh:mm a') + + const sellTokenValue = formatValue(sellAmount, sellToken.decimals) + const buyTokenValue = formatValue(buyAmount, buyToken.decimals) + + const isSellOrder = kind === 'sell' + + return ( + <SwapHeader + date={date} + time={time} + fromToken={sellToken} + toToken={buyToken} + fromAmount={sellTokenValue} + toAmount={buyTokenValue} + fromLabel={isSellOrder ? 'Sell' : 'For at most'} + toLabel={isSellOrder ? 'For at least' : 'Buy exactly'} + /> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/TwapFallbackHandlerWarning.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/TwapFallbackHandlerWarning.tsx new file mode 100644 index 0000000000..bc40277b1f --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/TwapFallbackHandlerWarning.tsx @@ -0,0 +1,15 @@ +import { Alert } from '@/src/components/Alert' + +export const TwapFallbackHandlerWarning = () => { + return ( + <Alert + message={'Enable TWAPs and submit order.'} + iconName={'info'} + info={ + 'To enable TWAP orders you need to set a custom fallback handler. This software is developed by CoW Swap and\n' + + 'Safe will not be responsible for any possible issues with it.' + } + type="warning" + /> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/hooks/index.ts b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/hooks/index.ts new file mode 100644 index 0000000000..1138f2e0e5 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/hooks/index.ts @@ -0,0 +1 @@ +export { useRecipientItem } from './useRecipientItem' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/hooks/useRecipientItem.test.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/hooks/useRecipientItem.test.tsx new file mode 100644 index 0000000000..eb7f53d6c9 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/hooks/useRecipientItem.test.tsx @@ -0,0 +1,204 @@ +import React from 'react' +import { useRecipientItem } from './useRecipientItem' +import { OrderTransactionInfo } from '@safe-global/store/gateway/types' + +// Mock the useOpenExplorer hook +const mockViewOnExplorer = jest.fn() +jest.mock('@/src/features/ConfirmTx/hooks/useOpenExplorer', () => ({ + useOpenExplorer: jest.fn(() => mockViewOnExplorer), +})) + +// Mock React hooks +const mockUseMemo = jest.fn() +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: (fn: () => unknown, deps: unknown[]) => mockUseMemo(fn, deps), +})) + +// Mock the components used in the hook +jest.mock('@/src/components/Identicon', () => ({ + Identicon: 'Identicon', +})) + +jest.mock('@/src/components/EthAddress', () => ({ + EthAddress: 'EthAddress', +})) + +jest.mock('@/src/components/SafeFontIcon', () => ({ + SafeFontIcon: 'SafeFontIcon', +})) + +jest.mock('react-native', () => ({ + TouchableOpacity: 'TouchableOpacity', +})) + +jest.mock('tamagui', () => ({ + View: 'View', +})) + +const createMockOrder = ( + receiver?: string, + owner = '0x1234567890123456789012345678901234567890', +): OrderTransactionInfo => ({ + type: 'SwapOrder', + uid: '0x123456789', + status: 'open', + kind: 'sell', + orderClass: 'market', + validUntil: Date.now() / 1000 + 3600, + sellAmount: '1000000000000000000', + buyAmount: '2000000000000000000', + executedSellAmount: '0', + executedBuyAmount: '0', + sellToken: { + address: '0xtoken1', + name: 'Token1', + symbol: 'TK1', + decimals: 18, + logoUri: 'https://example.com/token1.png', + trusted: true, + }, + buyToken: { + address: '0xtoken2', + name: 'Token2', + symbol: 'TK2', + decimals: 18, + logoUri: 'https://example.com/token2.png', + trusted: true, + }, + explorerUrl: 'https://explorer.example.com', + executedFee: '0', + executedFeeToken: '0xfeetoken', + receiver, + owner, + fullAppData: { + metadata: { + orderClass: { orderClass: 'market' }, + quote: { slippageBips: 50 }, + }, + }, +}) + +describe('useRecipientItem', () => { + beforeEach(() => { + jest.clearAllMocks() + // Mock useMemo to return the result of the callback immediately + mockUseMemo.mockImplementation((fn) => fn()) + }) + + it('should return empty array when receiver is undefined', () => { + const mockOrder = createMockOrder() + + const result = useRecipientItem(mockOrder) + + expect(result).toEqual([]) + }) + + it('should return empty array when receiver equals owner', () => { + const owner = '0x1234567890123456789012345678901234567890' + const mockOrder = createMockOrder(owner, owner) + + const result = useRecipientItem(mockOrder) + + expect(result).toEqual([]) + }) + + it('should return recipient items when receiver differs from owner', () => { + const owner = '0x1234567890123456789012345678901234567890' + const receiver = '0x9876543210987654321098765432109876543210' + const mockOrder = createMockOrder(receiver, owner) + + const result = useRecipientItem(mockOrder) + + expect(result).toHaveLength(1) + expect(result[0]).toHaveProperty('label') + expect(result[0]).toHaveProperty('render') + // Check it's a LabelValueItem (has label property) + const item = result[0] as { label: string; render: () => React.ReactNode } + expect(item.label).toBe('Recipient') + }) + + it('should call useOpenExplorer with correct receiver address', () => { + const owner = '0x1234567890123456789012345678901234567890' + const receiver = '0x9876543210987654321098765432109876543210' + const mockOrder = createMockOrder(receiver, owner) + + const { useOpenExplorer } = require('@/src/features/ConfirmTx/hooks/useOpenExplorer') + + useRecipientItem(mockOrder) + + expect(useOpenExplorer).toHaveBeenCalledWith(receiver) + }) + + it('should call useOpenExplorer with empty string when receiver is undefined', () => { + const mockOrder = createMockOrder() + + const { useOpenExplorer } = require('@/src/features/ConfirmTx/hooks/useOpenExplorer') + + useRecipientItem(mockOrder) + + expect(useOpenExplorer).toHaveBeenCalledWith('') + }) + + it('should handle edge case with null receiver', () => { + const mockOrder = { + ...createMockOrder(), + receiver: null, + } as OrderTransactionInfo & { receiver: null } + + const result = useRecipientItem(mockOrder) + + expect(result).toEqual([]) + }) + + it('should return proper ListTableItem structure with render function', () => { + const owner = '0x1234567890123456789012345678901234567890' + const receiver = '0x9876543210987654321098765432109876543210' + const mockOrder = createMockOrder(receiver, owner) + + const result = useRecipientItem(mockOrder) + + // Test the actual hook result structure + expect(result).toHaveLength(1) + + const item = result[0] + + // Verify it's a LabelValueItem by checking required properties + expect(item).toHaveProperty('label') + expect(item).toHaveProperty('render') + + // Type guard to verify structure + const isLabelValueItem = (obj: unknown): obj is { label: string; render: () => React.ReactNode } => { + return ( + typeof obj === 'object' && + obj !== null && + 'label' in obj && + 'render' in obj && + typeof (obj as { label: unknown }).label === 'string' && + typeof (obj as { render: unknown }).render === 'function' + ) + } + + expect(isLabelValueItem(item)).toBe(true) + + if (isLabelValueItem(item)) { + expect(item.label).toBe('Recipient') + expect(() => item.render()).not.toThrow() + } + }) + + it('should use useMemo for optimization', () => { + const owner = '0x1234567890123456789012345678901234567890' + const receiver = '0x9876543210987654321098765432109876543210' + const mockOrder = createMockOrder(receiver, owner) + + useRecipientItem(mockOrder) + + expect(mockUseMemo).toHaveBeenCalled() + expect(mockUseMemo).toHaveBeenCalledWith(expect.any(Function), [ + mockOrder.receiver, + mockOrder.owner, + expect.any(Function), + ]) + }) +}) diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/hooks/useRecipientItem.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/hooks/useRecipientItem.tsx new file mode 100644 index 0000000000..74e51903bf --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/hooks/useRecipientItem.tsx @@ -0,0 +1,42 @@ +import React, { useMemo } from 'react' +import { View } from 'tamagui' +import { TouchableOpacity } from 'react-native' +import { OrderTransactionInfo } from '@safe-global/store/gateway/types' +import { ListTableItem } from '../../../ListTable' +import { Identicon } from '@/src/components/Identicon' +import { EthAddress } from '@/src/components/EthAddress' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { useOpenExplorer } from '@/src/features/ConfirmTx/hooks/useOpenExplorer' +import { Address } from '@/src/types/address' + +export const useRecipientItem = (order: OrderTransactionInfo): ListTableItem[] => { + const viewRecipientOnExplorer = useOpenExplorer(order.receiver || '') + + const recipientItem = useMemo(() => { + const items: ListTableItem[] = [] + + if (order.receiver && order.owner !== order.receiver) { + items.push({ + label: 'Recipient', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Identicon address={order.receiver as Address} size={24} /> + <EthAddress + address={order.receiver as Address} + copy + textProps={{ fontSize: '$4' }} + copyProps={{ color: '$textSecondaryLight', size: 14 }} + /> + <TouchableOpacity onPress={viewRecipientOnExplorer}> + <SafeFontIcon name="external-link" size={14} color="$textSecondaryLight" /> + </TouchableOpacity> + </View> + ), + }) + } + + return items + }, [order.receiver, order.owner, viewRecipientOnExplorer]) + + return recipientItem +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/hooks/useRecipientItems.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/hooks/useRecipientItems.tsx new file mode 100644 index 0000000000..74e51903bf --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/hooks/useRecipientItems.tsx @@ -0,0 +1,42 @@ +import React, { useMemo } from 'react' +import { View } from 'tamagui' +import { TouchableOpacity } from 'react-native' +import { OrderTransactionInfo } from '@safe-global/store/gateway/types' +import { ListTableItem } from '../../../ListTable' +import { Identicon } from '@/src/components/Identicon' +import { EthAddress } from '@/src/components/EthAddress' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { useOpenExplorer } from '@/src/features/ConfirmTx/hooks/useOpenExplorer' +import { Address } from '@/src/types/address' + +export const useRecipientItem = (order: OrderTransactionInfo): ListTableItem[] => { + const viewRecipientOnExplorer = useOpenExplorer(order.receiver || '') + + const recipientItem = useMemo(() => { + const items: ListTableItem[] = [] + + if (order.receiver && order.owner !== order.receiver) { + items.push({ + label: 'Recipient', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Identicon address={order.receiver as Address} size={24} /> + <EthAddress + address={order.receiver as Address} + copy + textProps={{ fontSize: '$4' }} + copyProps={{ color: '$textSecondaryLight', size: 14 }} + /> + <TouchableOpacity onPress={viewRecipientOnExplorer}> + <SafeFontIcon name="external-link" size={14} color="$textSecondaryLight" /> + </TouchableOpacity> + </View> + ), + }) + } + + return items + }, [order.receiver, order.owner, viewRecipientOnExplorer]) + + return recipientItem +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/index.ts b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/index.ts new file mode 100644 index 0000000000..91363b263a --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/index.ts @@ -0,0 +1 @@ +export { SwapOrder } from './SwapOrder' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/utils.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/utils.tsx new file mode 100644 index 0000000000..38e5839de3 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/SwapOrder/utils.tsx @@ -0,0 +1,147 @@ +import React from 'react' +import { Logo } from '@/src/components/Logo' +import { View, Text } from 'tamagui' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { OrderTransactionInfo as Order, StartTimeValue } from '@safe-global/store/gateway/types' +import { formatWithSchema, getPeriod } from '@safe-global/utils/utils/date' +import { ellipsis, formatValue, getLimitPrice } from '@/src/utils/formatters' +import { TwapOrderTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { formatAmount } from '@safe-global/utils/utils/formatNumber' +import { CopyButton } from '@/src/components/CopyButton' +import { getExecutionPrice, getSlippageInPercent, getOrderClass } from '@safe-global/utils/features/swap/helpers/utils' +import { getOrderFeeBps } from '@safe-global/utils/features/swap/helpers/utils' +import StatusLabel from '@/src/features/ConfirmTx/components/confirmation-views/SwapOrder/StatusLabel' +import { TouchableOpacity, Linking } from 'react-native' +import { type ListTableItem } from '../../ListTable' + +const priceRow = (order: Order) => { + const { status, sellToken, buyToken } = order + const executionPrice = getExecutionPrice(order) + const limitPrice = getLimitPrice(order) + + if (status === 'fulfilled') { + return { + label: 'Execution price', + value: `1 ${buyToken.symbol} = ${formatAmount(executionPrice)} ${sellToken.symbol}`, + } + } + + return { + label: 'Limit price', + value: `1 ${buyToken.symbol} = ${formatAmount(limitPrice)} ${sellToken.symbol}`, + } +} + +const statusRow = (order: Order) => { + const { status } = order + + return { + label: 'Status', + render: () => <StatusLabel status={status} />, + } +} + +export const surplusFee = (order: Pick<Order, 'fullAppData' | 'executedFee' | 'executedFeeToken'>) => { + const bps = getOrderFeeBps(order) + + return { + label: 'Widget fee', + value: `${Number(bps) / 100} %`, + } +} + +export const formatSwapOrderItems = (txInfo: Order, chain: Chain): ListTableItem[] => { + const expiresAt = formatWithSchema(txInfo.validUntil * 1000, 'dd/MM/yyyy, HH:mm') + const orderClass = getOrderClass(txInfo) + const slippage = getSlippageInPercent(txInfo) + + const openCowExplorer = () => { + if ('uid' in txInfo) { + Linking.openURL(txInfo.explorerUrl) + } + } + const items = [ + priceRow(txInfo), + { + label: 'Expiry', + value: expiresAt, + }, + orderClass !== 'limit' + ? { + label: 'Slippage', + value: `${slippage}%`, + } + : null, + 'uid' in txInfo + ? { + label: 'Order ID', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Text fontSize="$4">{ellipsis(txInfo.uid, 6)}</Text> + <CopyButton value={txInfo.uid} color={'$textSecondaryLight'} /> + <TouchableOpacity onPress={openCowExplorer}> + <SafeFontIcon name="external-link" size={14} color="$textSecondaryLight" /> + </TouchableOpacity> + </View> + ), + } + : null, + { + label: 'Network', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Logo logoUri={chain.chainLogoUri} size="$6" /> + <Text fontSize="$4">{chain.chainName}</Text> + </View> + ), + }, + statusRow(txInfo), + surplusFee(txInfo), + ] + return items.filter((item) => item !== null) as ListTableItem[] +} + +export const formatTwapOrderItems = (order: TwapOrderTransactionInfo) => { + const { timeBetweenParts } = order + let startTime = '' + if (order.startTime.startType === StartTimeValue.AT_MINING_TIME) { + startTime = 'Now' + } + if (order.startTime.startType === StartTimeValue.AT_EPOCH) { + startTime = `At block number: ${order.startTime.epoch}` + } + + return [ + { + renderRow: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Text fontSize="$4">Order will be split in</Text> + <Text fontSize="$4" fontWeight={'700'}> + {order.numberOfParts} equal parts + </Text> + </View> + ), + }, + { + label: 'Sell amount', + value: `${formatValue(order.partSellAmount, order.sellToken.decimals)} ${order.sellToken.symbol} per part`, + }, + { + label: 'Buy amount', + value: `${formatValue(order.minPartLimit, order.buyToken.decimals)} ${order.buyToken.symbol} per part`, + }, + { + label: 'Start time', + value: startTime, + }, + { + label: 'Part duration', + value: getPeriod(+timeBetweenParts), + }, + { + label: 'Total duration', + value: getPeriod(+order.timeBetweenParts * +order.numberOfParts), + }, + ] +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/TokenTransfer/TokenTransfer.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/TokenTransfer/TokenTransfer.tsx new file mode 100644 index 0000000000..00aa890145 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/TokenTransfer/TokenTransfer.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { Container } from '@/src/components/Container' +import { View, YStack, Text, Button, H3 } from 'tamagui' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Logo } from '@/src/components/Logo' +import { EthAddress } from '@/src/components/EthAddress' +import { Identicon } from '@/src/components/Identicon' +import { TransactionHeader } from '../../TransactionHeader' +import { + MultisigExecutionDetails, + TransferTransactionInfo, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useTokenDetails } from '@/src/hooks/useTokenDetails' +import { useAppSelector } from '@/src/store/hooks' +import { selectChainById } from '@/src/store/chains' +import { RootState } from '@/src/store' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { Address } from '@/src/types/address' +import { TokenAmount } from '@/src/components/TokenAmount' +import { useOpenExplorer } from '@/src/features/ConfirmTx/hooks/useOpenExplorer' +import { ParametersButton } from '../../ParametersButton' + +interface TokenTransferProps { + txId: string + txInfo: TransferTransactionInfo + executionInfo: MultisigExecutionDetails + executedAt: number +} + +export function TokenTransfer({ txId, txInfo, executionInfo, executedAt }: TokenTransferProps) { + const activeSafe = useDefinedActiveSafe() + const activeChain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + const { value, tokenSymbol, logoUri, decimals } = useTokenDetails(txInfo) + + const recipientAddress = txInfo.recipient.value as Address + + const viewOnExplorer = useOpenExplorer(recipientAddress) + + return ( + <> + <TransactionHeader + logo={logoUri} + badgeIcon="transaction-outgoing" + badgeThemeName="badge_error" + badgeColor="$error" + title={ + <H3 fontWeight={600}> + <TokenAmount + value={value} + decimals={decimals} + tokenSymbol={tokenSymbol} + direction={txInfo.direction} + preciseAmount + /> + </H3> + } + submittedAt={executionInfo?.submittedAt || executedAt} + /> + + <View> + <YStack gap="$4" marginTop="$8"> + <Container padding="$4" gap="$4" borderRadius="$3"> + <View alignItems="center" flexDirection="row" justifyContent="space-between"> + <Text color="$textSecondaryLight">To</Text> + + <View flexDirection="row" alignItems="center" gap="$2"> + <Identicon address={recipientAddress} size={24} /> + <EthAddress + address={recipientAddress} + copy + copyProps={{ color: '$textSecondaryLight', size: 18 }} + textProps={{ fontSize: '$4' }} + /> + <Button + onPress={viewOnExplorer} + marginLeft={-10} + height={18} + pressStyle={{ backgroundColor: 'transparent' }} + > + <SafeFontIcon name="external-link" color="$textSecondaryLight" size={16} /> + </Button> + </View> + </View> + + <View alignItems="center" flexDirection="row" justifyContent="space-between"> + <Text color="$textSecondaryLight">Network</Text> + + <View flexDirection="row" alignItems="center" gap="$2"> + <Logo logoUri={activeChain?.chainLogoUri} size="$6" /> + <Text fontSize="$4">{activeChain?.chainName}</Text> + </View> + </View> + + <ParametersButton txId={txId} /> + </Container> + </YStack> + </View> + </> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/TokenTransfer/index.ts b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/TokenTransfer/index.ts new file mode 100644 index 0000000000..07082620d7 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/TokenTransfer/index.ts @@ -0,0 +1 @@ +export { TokenTransfer } from './TokenTransfer' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultDeposit/VaultDeposit.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultDeposit/VaultDeposit.tsx new file mode 100644 index 0000000000..177af6a24b --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultDeposit/VaultDeposit.tsx @@ -0,0 +1,134 @@ +import React, { useMemo } from 'react' +import { YStack, Text, XStack, View } from 'tamagui' +import { + DataDecoded, + MultisigExecutionDetails, + VaultDepositTransactionInfo, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TransactionHeader } from '../../TransactionHeader' +import { ListTable } from '../../ListTable' +import { TokenAmount } from '@/src/components/TokenAmount' +import { formatPercentage } from '@safe-global/utils/utils/formatters' +import { ParametersButton } from '../../ParametersButton' +import { vaultTypeToLabel, formatVaultDepositItems } from './utils' +import { Container } from '@/src/components/Container' +import { Image } from 'expo-image' +import { isMultiSendData } from '@/src/utils/transaction-guards' +import { SafeListItem } from '@/src/components/SafeListItem' +import { Badge } from '@/src/components/Badge' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { useRouter } from 'expo-router' + +const AdditionalRewards = ({ txInfo }: { txInfo: VaultDepositTransactionInfo }) => { + const reward = txInfo.additionalRewards[0] + if (!reward) { + return null + } + + return ( + <Container padding="$4" gap="$2"> + <Text fontWeight="600" marginBottom="$2"> + Additional reward + </Text> + <ListTable + padding="0" + gap="$4" + items={[ + { + label: 'Token', + value: `${reward.tokenInfo.name} ${reward.tokenInfo.symbol}`, + }, + { + label: 'Earn', + value: formatPercentage(txInfo.additionalRewardsNrr / 100), + }, + { + label: 'Fee', + value: '0%', + }, + ]} + /> + <XStack alignItems="center" gap="$1" marginTop="$2"> + <Text fontSize={12} color="$colorSecondary"> + Powered by + </Text> + <Image source={{ uri: txInfo.vaultInfo.logoUri }} style={{ width: 16, height: 16 }} /> + <Text fontSize={12} color="$colorSecondary"> + Morpho + </Text> + </XStack> + </Container> + ) +} + +interface VaultDepositProps { + txInfo: VaultDepositTransactionInfo + executionInfo: MultisigExecutionDetails + txId: string + decodedData?: DataDecoded | null +} + +export function VaultDeposit({ txInfo, executionInfo, txId, decodedData }: VaultDepositProps) { + const router = useRouter() + const totalNrr = (txInfo.baseNrr + txInfo.additionalRewardsNrr) / 100 + const items = useMemo(() => formatVaultDepositItems(txInfo), [txInfo]) + + const handleViewActions = () => { + router.push({ + pathname: '/transaction-actions', + params: { txId }, + }) + } + + return ( + <YStack gap="$4"> + <TransactionHeader + logo={txInfo.tokenInfo.logoUri ?? undefined} + badgeIcon="transaction-earn" + badgeColor="$textSecondaryLight" + title={ + <XStack gap="$1"> + <Text fontSize="$4">{vaultTypeToLabel[txInfo.type]}</Text> + <TokenAmount + value={txInfo.value} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + /> + </XStack> + } + submittedAt={executionInfo.submittedAt} + /> + + <ListTable items={[{ label: 'Earn (after fees)', value: formatPercentage(totalNrr) }, ...items]} gap="$4"> + <ParametersButton txId={txId} /> + </ListTable> + + <AdditionalRewards txInfo={txInfo} /> + + <Text color="$textSecondaryLight">{txInfo.vaultInfo.description}</Text> + + {decodedData && isMultiSendData(decodedData) && ( + <SafeListItem + label="Actions" + rightNode={ + <View flexDirection="row" alignItems="center" gap="$2"> + {decodedData.parameters?.[0]?.valueDecoded && ( + <Badge + themeName="badge_background_inverted" + content={ + Array.isArray(decodedData.parameters[0].valueDecoded) + ? decodedData.parameters[0].valueDecoded.length.toString() + : '1' + } + /> + )} + + <SafeFontIcon name={'chevron-right'} /> + </View> + } + onPress={handleViewActions} + /> + )} + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultDeposit/index.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultDeposit/index.tsx new file mode 100644 index 0000000000..89c909c50f --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultDeposit/index.tsx @@ -0,0 +1 @@ +export { VaultDeposit } from './VaultDeposit' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultDeposit/utils.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultDeposit/utils.tsx new file mode 100644 index 0000000000..f1bfd21e73 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultDeposit/utils.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { View, Text } from 'tamagui' +import { VaultDepositTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { formatPercentage } from '@safe-global/utils/utils/formatters' +import { TokenAmount } from '@/src/components/TokenAmount' +import { ListTableItem } from '../../ListTable' +import { Image } from 'expo-image' + +export const vaultTypeToLabel = { + VaultDeposit: 'Deposit', + VaultRedeem: 'Withdraw', +} as const + +export const formatVaultDepositItems = (txInfo: VaultDepositTransactionInfo): ListTableItem[] => { + const annualReward = Number(txInfo.expectedAnnualReward).toFixed(0) + const monthlyReward = Number(txInfo.expectedMonthlyReward).toFixed(0) + + return [ + { + label: 'Deposit via', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Image source={{ uri: txInfo.vaultInfo.logoUri }} style={{ width: 24, height: 24 }} /> + <Text fontWeight="700" fontSize="$4"> + {txInfo.vaultInfo.name} + </Text> + </View> + ), + }, + { + label: 'Exp. annual reward', + render: () => ( + <TokenAmount value={annualReward} tokenSymbol={txInfo.tokenInfo.symbol} decimals={txInfo.tokenInfo.decimals} /> + ), + }, + { + label: 'Exp. monthly reward', + render: () => ( + <TokenAmount value={monthlyReward} tokenSymbol={txInfo.tokenInfo.symbol} decimals={txInfo.tokenInfo.decimals} /> + ), + }, + { + label: 'Performance fee', + value: formatPercentage(txInfo.fee, true), + }, + ] +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultRedeem/VaultRedeem.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultRedeem/VaultRedeem.tsx new file mode 100644 index 0000000000..971ee7b85d --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultRedeem/VaultRedeem.tsx @@ -0,0 +1,99 @@ +import React, { useMemo } from 'react' +import { YStack, Text, XStack } from 'tamagui' +import { + MultisigExecutionDetails, + VaultRedeemTransactionInfo, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TransactionHeader } from '../../TransactionHeader' +import { ListTable } from '../../ListTable' +import { TokenAmount } from '@/src/components/TokenAmount' +import { formatPercentage } from '@safe-global/utils/utils/formatters' +import { ParametersButton } from '../../ParametersButton' +import { Container } from '@/src/components/Container' +import { vaultTypeToLabel } from '../VaultDeposit/utils' +import { formatVaultRedeemItems } from './utils' +import { Image } from 'expo-image' + +const AdditionalRewards = ({ txInfo }: { txInfo: VaultRedeemTransactionInfo }) => { + const reward = txInfo.additionalRewards[0] + if (!reward) { + return null + } + + const claimable = Number(reward.claimable) > 0 + if (!claimable) { + return null + } + + return ( + <Container bordered padding="$4" gap="$2"> + <Text fontWeight="600" marginBottom="$2"> + Additional reward + </Text> + <ListTable + padding="0" + gap="$4" + items={[ + { + label: 'Token', + value: `${reward.tokenInfo.name} ${reward.tokenInfo.symbol}`, + }, + { + label: 'Earn', + value: formatPercentage(txInfo.additionalRewardsNrr / 100), + }, + ]} + /> + <XStack alignItems="center" gap="$1" marginTop="$2"> + <Text fontSize={12} color="$colorSecondary"> + Powered by + </Text> + <Image source={{ uri: txInfo.vaultInfo.logoUri }} style={{ width: 16, height: 16 }} /> + <Text fontSize={12} color="$colorSecondary"> + Morpho + </Text> + </XStack> + </Container> + ) +} + +interface VaultRedeemProps { + txInfo: VaultRedeemTransactionInfo + executionInfo: MultisigExecutionDetails + txId: string +} + +export function VaultRedeem({ txInfo, executionInfo, txId }: VaultRedeemProps) { + const items = useMemo(() => formatVaultRedeemItems(txInfo), [txInfo]) + + return ( + <YStack gap="$4"> + <TransactionHeader + logo={txInfo.tokenInfo.logoUri ?? undefined} + badgeIcon="transaction-earn" + badgeColor="$textSecondaryLight" + title={ + <XStack gap="$1"> + <Text color="$textSecondaryLight" fontSize="$4"> + {vaultTypeToLabel[txInfo.type]} + </Text> + <TokenAmount + value={txInfo.value} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + /> + </XStack> + } + submittedAt={executionInfo.submittedAt} + /> + + <ListTable items={items} gap="$4"> + <ParametersButton txId={txId} /> + </ListTable> + + <AdditionalRewards txInfo={txInfo} /> + + <Text color="$textSecondaryLight">{txInfo.vaultInfo.description}</Text> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultRedeem/index.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultRedeem/index.tsx new file mode 100644 index 0000000000..6ff9b5904e --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultRedeem/index.tsx @@ -0,0 +1 @@ +export { VaultRedeem } from './VaultRedeem' diff --git a/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultRedeem/utils.tsx b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultRedeem/utils.tsx new file mode 100644 index 0000000000..022daff031 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/components/confirmation-views/VaultRedeem/utils.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { View, Text } from 'tamagui' +import { VaultRedeemTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { TokenAmount } from '@/src/components/TokenAmount' +import { ListTableItem } from '../../ListTable' +import { Image } from 'expo-image' + +export const formatVaultRedeemItems = (txInfo: VaultRedeemTransactionInfo): ListTableItem[] => { + return [ + { + label: 'Current reward', + render: () => ( + <TokenAmount + value={txInfo.currentReward} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + /> + ), + }, + { + label: 'Withdraw from', + render: () => ( + <View flexDirection="row" alignItems="center" gap="$2"> + <Image source={{ uri: txInfo.vaultInfo.logoUri }} style={{ width: 24, height: 24 }} /> + <Text fontWeight="700" fontSize="$4"> + {txInfo.vaultInfo.name} + </Text> + </View> + ), + }, + ] +} diff --git a/apps/mobile/src/features/ConfirmTx/hooks/useOpenExplorer/index.ts b/apps/mobile/src/features/ConfirmTx/hooks/useOpenExplorer/index.ts new file mode 100644 index 0000000000..8de16dcc08 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/hooks/useOpenExplorer/index.ts @@ -0,0 +1,3 @@ +import useOpenExplorer from './useOpenExplorer' + +export { useOpenExplorer } diff --git a/apps/mobile/src/features/ConfirmTx/hooks/useOpenExplorer/useOpenExplorer.ts b/apps/mobile/src/features/ConfirmTx/hooks/useOpenExplorer/useOpenExplorer.ts new file mode 100644 index 0000000000..52d497dd66 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/hooks/useOpenExplorer/useOpenExplorer.ts @@ -0,0 +1,20 @@ +import { useAppSelector } from '@/src/store/hooks' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { RootState } from '@/src/store' +import { selectChainById } from '@/src/store/chains' +import { getExplorerLink } from '@safe-global/utils/utils/gateway' +import { Linking } from 'react-native' + +function useOpenExplorer(address: string) { + const activeSafe = useDefinedActiveSafe() + const activeChain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + + const viewOnExplorer = () => { + const link = getExplorerLink(address, activeChain.blockExplorerUriTemplate) + Linking.openURL(link.href) + } + + return viewOnExplorer +} + +export default useOpenExplorer diff --git a/apps/mobile/src/features/ConfirmTx/hooks/useTxSigner/index.ts b/apps/mobile/src/features/ConfirmTx/hooks/useTxSigner/index.ts new file mode 100644 index 0000000000..e13f0094d6 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/hooks/useTxSigner/index.ts @@ -0,0 +1 @@ +export { useTxSigner } from './useTxSigner' diff --git a/apps/mobile/src/features/ConfirmTx/hooks/useTxSigner/useTxSigner.ts b/apps/mobile/src/features/ConfirmTx/hooks/useTxSigner/useTxSigner.ts new file mode 100644 index 0000000000..ecc81cf5b7 --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/hooks/useTxSigner/useTxSigner.ts @@ -0,0 +1,56 @@ +import { useLayoutEffect, useMemo } from 'react' +import { MultisigExecutionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { selectActiveSigner, setActiveSigner } from '@/src/store/activeSignerSlice' +import { useAppSelector, useAppDispatch } from '@/src/store/hooks' +import { RootState } from '@/src/store' +import { extractAppSigners } from '../../utils' +import { selectSigners } from '@/src/store/signersSlice' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' + +export const useTxSigner = (detailedExecutionInfo?: MultisigExecutionDetails) => { + const activeSafe = useDefinedActiveSafe() + const activeSigner = useAppSelector((state: RootState) => selectActiveSigner(state, activeSafe.address)) + const signers = useAppSelector(selectSigners) + const dispatch = useAppDispatch() + + const appSigners = extractAppSigners(signers, detailedExecutionInfo) + const activeTxSigner = useMemo( + () => appSigners.find((signer) => signer.value === activeSigner?.value), + [appSigners, activeSigner], + ) + + const hasSigned = useMemo(() => { + return detailedExecutionInfo?.confirmations?.some( + (confirmation) => confirmation.signer.value === activeSigner?.value, + ) + }, [detailedExecutionInfo, activeSigner]) + + const proposedSigner = useMemo(() => { + const signers = appSigners.filter((signer) => { + return !detailedExecutionInfo?.confirmations?.some((confirmation) => confirmation.signer.value === signer?.value) + }) + + return signers?.find((signer) => + detailedExecutionInfo?.signers?.some((executionSigner) => executionSigner.value === signer?.value), + ) + }, [appSigners, activeSigner, detailedExecutionInfo]) + + useLayoutEffect(() => { + if (proposedSigner && activeTxSigner?.value !== proposedSigner.value && hasSigned) { + dispatch(setActiveSigner({ safeAddress: activeSafe.address, signer: proposedSigner })) + return + } + }, [proposedSigner, activeTxSigner, hasSigned]) + + useLayoutEffect(() => { + // Changes the active signer if there are app signers and the active signer is not in the app signers + // because it can be a signer of that safe but in a different chain + if (appSigners.length > 0 && !activeTxSigner) { + dispatch(setActiveSigner({ safeAddress: activeSafe.address, signer: appSigners[0] })) + } + }, [activeTxSigner, appSigners, proposedSigner]) + + const canSign = Boolean(proposedSigner) && !hasSigned + + return { activeSigner, hasSigned, canSign } +} diff --git a/apps/mobile/src/features/ConfirmTx/index.ts b/apps/mobile/src/features/ConfirmTx/index.ts new file mode 100644 index 0000000000..7bd37f6a4e --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/index.ts @@ -0,0 +1 @@ +export { default as ConfirmTxContainer } from './ConfirmTx.container' diff --git a/apps/mobile/src/features/ConfirmTx/utils.ts b/apps/mobile/src/features/ConfirmTx/utils.ts new file mode 100644 index 0000000000..e52a762b9e --- /dev/null +++ b/apps/mobile/src/features/ConfirmTx/utils.ts @@ -0,0 +1,16 @@ +import { SignerInfo } from '@/src/types/address' +import { MultisigExecutionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +export const extractAppSigners = ( + signers: Record<string, SignerInfo>, + detailedExecutionInfo?: MultisigExecutionDetails, +): SignerInfo[] => { + if (!detailedExecutionInfo || !('signers' in detailedExecutionInfo)) { + return [] + } + + const { signers: signersList } = detailedExecutionInfo + + // TODO: remove this casting once we fix the cgw type problem + return signersList.filter((signer) => signers[signer.value]) as unknown as SignerInfo[] +} diff --git a/apps/mobile/src/features/ConfirmationsSheet/ConfirmationsSheet.container.tsx b/apps/mobile/src/features/ConfirmationsSheet/ConfirmationsSheet.container.tsx new file mode 100644 index 0000000000..d574a3c0a9 --- /dev/null +++ b/apps/mobile/src/features/ConfirmationsSheet/ConfirmationsSheet.container.tsx @@ -0,0 +1,98 @@ +import { SafeBottomSheet } from '@/src/components/SafeBottomSheet' +import React, { useCallback, useMemo } from 'react' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { RouteProp, useRoute } from '@react-navigation/native' +import { SignersCard } from '@/src/components/transactions-list/Card/SignersCard' +import { Badge } from '@/src/components/Badge' +import { Text, View } from 'tamagui' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Address } from '@/src/types/address' +import { + AddressInfo, + MultisigExecutionDetails, + useTransactionsGetTransactionByIdV1Query, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { selectSigners } from '@/src/store/signersSlice' +import { useAppSelector } from '@/src/store/hooks' +import { ContactDisplayNameContainer } from '../AddressBook' + +export const ConfirmationsSheetContainer = () => { + const activeSafe = useDefinedActiveSafe() + const importedSigners = useAppSelector(selectSigners) + const txId = useRoute<RouteProp<{ params: { txId: string } }>>().params.txId + const { data, isLoading } = useTransactionsGetTransactionByIdV1Query({ + chainId: activeSafe.chainId, + id: txId, + }) + + const { confirmations, signers, proposer } = data?.detailedExecutionInfo as MultisigExecutionDetails + const confirmationsMapper = useMemo(() => { + const mapper = confirmations.reduce((acc, confirmation) => { + acc.set(confirmation.signer.value as Address, true) + + return acc + }, new Map<Address, boolean>()) + + return mapper + }, [confirmations]) + + const sortedSigners = useMemo(() => { + return Array.from(signers.values()).sort((a, b) => a.value.toLowerCase().localeCompare(b.value.toLowerCase())) + }, [signers]) + + const getSignerTag = useMemo(() => { + return (signerAddress: Address): string | undefined => { + if (importedSigners[signerAddress]?.value) { + return 'You' + } + + if (proposer?.value === signerAddress) { + return 'Creator' + } + + return undefined + } + }, [proposer, importedSigners]) + + const renderItem = useCallback( + ({ item }: { item: AddressInfo }) => { + const hasSigned = confirmationsMapper.has(item.value as Address) + + return ( + <View width="100%"> + <SignersCard + name={<ContactDisplayNameContainer address={item.value as Address} />} + getSignerTag={getSignerTag} + address={item.value as Address} + rightNode={ + <Badge + circular={false} + content={ + <View alignItems="center" flexDirection="row" gap="$1"> + {hasSigned && <SafeFontIcon size={12} name="check" />} + + <Text fontWeight={600} color={'$color'}> + {hasSigned ? 'Signed' : 'Pending'} + </Text> + </View> + } + themeName={hasSigned ? 'badge_success_variant1' : 'badge_warning'} + /> + } + /> + </View> + ) + }, + [confirmationsMapper], + ) + + return ( + <SafeBottomSheet + title="Confirmations" + loading={isLoading} + items={sortedSigners} + keyExtractor={({ item }) => item.value} + renderItem={renderItem} + /> + ) +} diff --git a/apps/mobile/src/features/ConfirmationsSheet/index.ts b/apps/mobile/src/features/ConfirmationsSheet/index.ts new file mode 100644 index 0000000000..e575ac50b5 --- /dev/null +++ b/apps/mobile/src/features/ConfirmationsSheet/index.ts @@ -0,0 +1 @@ +export { ConfirmationsSheetContainer } from './ConfirmationsSheet.container' diff --git a/apps/mobile/src/features/ConflictTxSheet/ConflictTxSheet.container.tsx b/apps/mobile/src/features/ConflictTxSheet/ConflictTxSheet.container.tsx new file mode 100644 index 0000000000..72cf43a276 --- /dev/null +++ b/apps/mobile/src/features/ConflictTxSheet/ConflictTxSheet.container.tsx @@ -0,0 +1,46 @@ +import { Badge } from '@/src/components/Badge' +import { SafeBottomSheet } from '@/src/components/SafeBottomSheet' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Link } from 'expo-router' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' +import { H3, H6, Text, View } from 'tamagui' + +export const ConflictTxSheetContainer = () => { + return ( + <SafeBottomSheet> + <View flex={1} justifyContent="center" alignItems="center"> + <Badge + themeName="badge_error" + circleSize={40} + content={<SafeFontIcon size={20} color="$error" name="alert" />} + /> + + <H3 fontWeight={600} marginTop="$6" marginBottom="$4"> + Conflicting transactions + </H3> + + <H6 textAlign="center" fontWeight={300}> + Marked transactions have the same nonce (order in the queue). Executing one of them will automatically replace + the other(s). + </H6> + + <Link href={HelpCenterArticle.CONFLICTING_TRANSACTIONS} asChild> + <View + marginTop="$2" + flexDirection="row" + justifyContent="center" + alignItems="center" + gap="$2" + paddingVertical="$3" + paddingHorizontal="$10" + > + <SafeFontIcon color="$textSecondaryLight" name="info" size={16} /> + <Text fontWeight={600} color="$textSecondaryLight"> + Why did it happen? + </Text> + </View> + </Link> + </View> + </SafeBottomSheet> + ) +} diff --git a/apps/mobile/src/features/ConflictTxSheet/index.ts b/apps/mobile/src/features/ConflictTxSheet/index.ts new file mode 100644 index 0000000000..9156ef0779 --- /dev/null +++ b/apps/mobile/src/features/ConflictTxSheet/index.ts @@ -0,0 +1 @@ +export { ConflictTxSheetContainer } from './ConflictTxSheet.container' diff --git a/apps/mobile/src/features/DataImport/DataTransfer.container.tsx b/apps/mobile/src/features/DataImport/DataTransfer.container.tsx new file mode 100644 index 0000000000..43c706ab2d --- /dev/null +++ b/apps/mobile/src/features/DataImport/DataTransfer.container.tsx @@ -0,0 +1,33 @@ +import React, { useCallback } from 'react' +import { useRouter } from 'expo-router' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useColorScheme } from 'react-native' +import { DataTransferView } from './components/DataTransferView' + +export const DataTransfer = () => { + const router = useRouter() + const insets = useSafeAreaInsets() + const colorScheme = useColorScheme() + + const onPressTransferData = useCallback(() => { + // Navigate to help import flow + router.navigate('/import-data/help-import') + }, [router]) + + const onPressStartFresh = useCallback(() => { + // Go back to previous screen and then navigate to import accounts + router.back() + setTimeout(() => { + router.navigate('/(import-accounts)') + }, 100) + }, [router]) + + return ( + <DataTransferView + colorScheme={colorScheme} + bottomInset={insets.bottom} + onPressTransferData={onPressTransferData} + onPressStartFresh={onPressStartFresh} + /> + ) +} diff --git a/apps/mobile/src/features/DataImport/EnterPassword.container.tsx b/apps/mobile/src/features/DataImport/EnterPassword.container.tsx new file mode 100644 index 0000000000..a2f6bfa153 --- /dev/null +++ b/apps/mobile/src/features/DataImport/EnterPassword.container.tsx @@ -0,0 +1,35 @@ +import React, { useCallback } from 'react' +import { useRouter } from 'expo-router' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useDataImportContext } from './context/DataImportProvider' +import { EnterPasswordView } from './components/EnterPasswordView' + +export const EnterPassword = () => { + const router = useRouter() + const insets = useSafeAreaInsets() + const { handlePasswordChange, handleImport, password, isLoading, fileName } = useDataImportContext() + + const handleDecrypt = useCallback(async () => { + const result = await handleImport() + if (result) { + // Navigate to review data screen to show what will be imported + router.push('/import-data/review-data') + } else { + // Navigate to error screen when import fails + router.push('/import-data/import-error') + } + handlePasswordChange('') + }, [handleImport, router]) + + return ( + <EnterPasswordView + topInset={insets.top} + bottomInset={insets.bottom} + password={password} + isLoading={isLoading} + fileName={fileName ?? undefined} + onPasswordChange={handlePasswordChange} + onDecrypt={handleDecrypt} + /> + ) +} diff --git a/apps/mobile/src/features/DataImport/FileSelection.container.tsx b/apps/mobile/src/features/DataImport/FileSelection.container.tsx new file mode 100644 index 0000000000..027b8561a1 --- /dev/null +++ b/apps/mobile/src/features/DataImport/FileSelection.container.tsx @@ -0,0 +1,34 @@ +import React, { useCallback } from 'react' +import { useRouter } from 'expo-router' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useColorScheme } from 'react-native' +import { useDataImportContext } from './context/DataImportProvider' +import { FileSelectionView } from './components/FileSelectionView' + +export const FileSelection = () => { + const router = useRouter() + const insets = useSafeAreaInsets() + const colorScheme = useColorScheme() + const { pickFile } = useDataImportContext() + + const handleFileSelect = useCallback(async () => { + const fileSelected = await pickFile() + // Only navigate if a file was actually selected + if (fileSelected) { + router.push('/import-data/enter-password') + } + }, [pickFile, router]) + + const handleImagePress = useCallback(() => { + handleFileSelect() + }, [handleFileSelect]) + + return ( + <FileSelectionView + colorScheme={colorScheme} + bottomInset={insets.bottom} + onFileSelect={handleFileSelect} + onImagePress={handleImagePress} + /> + ) +} diff --git a/apps/mobile/src/features/DataImport/HelpImport.container.tsx b/apps/mobile/src/features/DataImport/HelpImport.container.tsx new file mode 100644 index 0000000000..3ff2eac4bb --- /dev/null +++ b/apps/mobile/src/features/DataImport/HelpImport.container.tsx @@ -0,0 +1,28 @@ +import React, { useCallback } from 'react' +import { useRouter } from 'expo-router' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { Linking } from 'react-native' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' +import { HelpImportView } from './components/HelpImportView' + +export const HelpImport = () => { + const router = useRouter() + const insets = useSafeAreaInsets() + + const onPressProceedToImport = useCallback(() => { + // Navigate to file selection screen + router.push('/import-data/file-selection') + }, [router]) + + const onPressNeedHelp = useCallback(() => { + Linking.openURL(HelpCenterArticle.BULK_IMPORT_OLD_DATA) + }, []) + + return ( + <HelpImportView + bottomInset={insets.bottom} + onPressProceedToImport={onPressProceedToImport} + onPressNeedHelp={onPressNeedHelp} + /> + ) +} diff --git a/apps/mobile/src/features/DataImport/ImportError.container.tsx b/apps/mobile/src/features/DataImport/ImportError.container.tsx new file mode 100644 index 0000000000..4b0da4bd79 --- /dev/null +++ b/apps/mobile/src/features/DataImport/ImportError.container.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useTheme } from 'tamagui' +import { router } from 'expo-router' +import { ImportErrorView } from './components/ImportErrorView' + +export default function ImportError() { + const theme = useTheme() + const colors: [string, string] = [theme.errorDark.get(), 'transparent'] + const insets = useSafeAreaInsets() + + return <ImportErrorView colors={colors} bottomInset={insets.bottom} onTryAgain={router.back} /> +} diff --git a/apps/mobile/src/features/DataImport/ImportProgressScreen.container.tsx b/apps/mobile/src/features/DataImport/ImportProgressScreen.container.tsx new file mode 100644 index 0000000000..f2f5613a8c --- /dev/null +++ b/apps/mobile/src/features/DataImport/ImportProgressScreen.container.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useState, useRef } from 'react' +import { useRouter } from 'expo-router' +import { useDataImportContext } from './context/DataImportProvider' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { selectCurrency } from '@/src/store/settingsSlice' +import useDelegate from '@/src/hooks/useDelegate' +import Logger from '@/src/utils/logger' +import { + fetchAndStoreSafeOverviews, + storeSafeContacts, + storeContacts, + LegacyDataStructure, + storeKeysWithValidation, + ImportProgressCallback, +} from './helpers/transforms' +import { ImportProgressScreenView } from './components/ImportProgressScreenView' + +export const ImportProgressScreen = () => { + const router = useRouter() + const { importedData, updateNotImportedKeys } = useDataImportContext() + const dispatch = useAppDispatch() + const currency = useAppSelector(selectCurrency) + const { createDelegate } = useDelegate() + + const [progress, setProgress] = useState(0) + const [progressMessage, setProgressMessage] = useState('Initializing...') + const hasImportStarted = useRef(false) + + useEffect(() => { + if (!importedData?.data) { + router.back() + return + } + + // Prevent multiple imports + if (hasImportStarted.current) { + Logger.info('Import already in progress, skipping...') + return + } + + const performImport = async () => { + try { + // Set the flag to prevent multiple imports + hasImportStarted.current = true + + const data = importedData.data as LegacyDataStructure + const totalSteps = 4 + let currentStep = 0 + + // Step 1: Fetch SafeOverview data and store it properly + currentStep++ + setProgress(5) + setProgressMessage('Fetching safe information...') + Logger.info('Starting SafeOverview data fetch and storage...') + + const safeInfos = + data.safes?.map((safe) => ({ + address: safe.address, + chainId: safe.chain, + })) || [] + + // Create progress callback for safe overview fetching + const safeOverviewProgressCallback: ImportProgressCallback = (subProgress, message) => { + const stepProgress = ((currentStep - 1) / totalSteps) * 100 + const currentStepProgress = subProgress * 0.25 // 25% of total progress for this step + setProgress(stepProgress + currentStepProgress) + setProgressMessage(message) + } + + const allOwners = await fetchAndStoreSafeOverviews(safeInfos, currency, dispatch, safeOverviewProgressCallback) + + // Step 2: Store safe contacts (quick operation) + currentStep++ + setProgress(30) + setProgressMessage('Storing safe contacts...') + Logger.info('Storing safe contacts...') + storeSafeContacts(data, dispatch) + + // Step 3: Import and validate signers/private keys with delegate creation + currentStep++ + setProgress(35) + setProgressMessage('Processing signers and creating delegates...') + Logger.info('Starting key import with validation and delegate creation...') + + // Create progress callback for key validation + const keyValidationProgressCallback: ImportProgressCallback = (subProgress, message) => { + const stepProgress = ((currentStep - 1) / totalSteps) * 100 + const currentStepProgress = subProgress * 0.4 // 40% of total progress for this step + setProgress(stepProgress + currentStepProgress) + setProgressMessage(message) + } + + await storeKeysWithValidation( + data, + allOwners, + dispatch, + updateNotImportedKeys, + createDelegate, + keyValidationProgressCallback, + ) + + // Step 4: Import address book/contacts + currentStep++ + setProgress(80) + setProgressMessage('Importing address book...') + Logger.info('Starting contacts import...') + storeContacts(data, dispatch) + + // Complete + setProgress(100) + setProgressMessage('Import completed successfully!') + + // Wait a bit to show completion + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // Navigate to success screen + router.push('/import-data/import-success') + } catch (error) { + Logger.error('Import failed:', error) + setProgressMessage('Import failed. Please try again.') + // Reset the flag on error so user can retry + hasImportStarted.current = false + // Wait a bit to show error message + await new Promise((resolve) => setTimeout(resolve, 2000)) + // Navigate back to review screen on error + router.back() + } + } + + performImport() + }, [importedData, dispatch, router, currency, createDelegate, updateNotImportedKeys]) + + return <ImportProgressScreenView progress={progress} message={progressMessage} /> +} diff --git a/apps/mobile/src/features/DataImport/ImportSuccessScreen.container.tsx b/apps/mobile/src/features/DataImport/ImportSuccessScreen.container.tsx new file mode 100644 index 0000000000..77708f1bde --- /dev/null +++ b/apps/mobile/src/features/DataImport/ImportSuccessScreen.container.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { useRouter } from 'expo-router' +import { getTokenValue } from 'tamagui' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { selectAllSafes, SafesSlice } from '@/src/store/safesSlice' +import { setActiveSafe } from '@/src/store/activeSafeSlice' +import { SafeInfo } from '@/src/types/address' +import { ImportSuccessScreenView } from './components/ImportSuccessScreenView' +import { useDataImportContext } from './context/DataImportProvider' + +export const ImportSuccessScreen = () => { + const router = useRouter() + const insets = useSafeAreaInsets() + const dispatch = useAppDispatch() + const allSafes = useAppSelector(selectAllSafes) as SafesSlice + const { notImportedKeys } = useDataImportContext() + const colors: [string, string] = [getTokenValue('$color.successBackgroundLight'), 'transparent'] + + const handleContinue = () => { + const safeAddresses = Object.keys(allSafes) + + if (safeAddresses.length > 0) { + const firstSafeAddress = safeAddresses[0] as `0x${string}` + const firstSafe = allSafes[firstSafeAddress] + const chainIds = Object.keys(firstSafe) + + if (chainIds.length > 0) { + const activeChainId = chainIds[0] + const activeSafeInfo: SafeInfo = { + address: firstSafeAddress, + chainId: activeChainId, + } + + dispatch(setActiveSafe(activeSafeInfo)) + + // Navigates to first screen in stack + router.dismissAll() + // closes first screen in stack + router.back() + // Navigate to the main assets screen + router.replace('/(tabs)') + return + } + } + + // Fallback: just navigate to main screen + router.replace('/(tabs)') + } + + return ( + <ImportSuccessScreenView + bottomInset={insets.bottom} + gradientColors={colors} + onContinue={handleContinue} + notImportedKeys={notImportedKeys} + /> + ) +} diff --git a/apps/mobile/src/features/DataImport/ReviewData.container.tsx b/apps/mobile/src/features/DataImport/ReviewData.container.tsx new file mode 100644 index 0000000000..3dbd50b198 --- /dev/null +++ b/apps/mobile/src/features/DataImport/ReviewData.container.tsx @@ -0,0 +1,54 @@ +import React, { useCallback, useMemo } from 'react' +import { useRouter } from 'expo-router' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useDataImportContext } from './context/DataImportProvider' +import { ReviewDataView } from './components/ReviewDataView' +import { LegacyDataStructure } from './helpers/transforms' + +export const ReviewData = () => { + const router = useRouter() + const insets = useSafeAreaInsets() + const { importedData } = useDataImportContext() + + const importSummary = useMemo(() => { + if (!importedData?.data) { + return { safeAccountsCount: 0, signersCount: 0, addressBookCount: 0 } + } + + const data = importedData.data as LegacyDataStructure + + // Count Safe Accounts from addedSafes + const safeAccountsCount = data.safes ? data.safes.length : 0 + + // Count signers from addedSafes owners + const allSigners = new Set<string>() + if (data.keys) { + data.keys.forEach((key) => { + allSigners.add(key.address) + }) + } + + // Count address book entries + const addressBookCount = data.contacts ? Object.keys(data.contacts).length : 0 + + return { + safeAccountsCount, + signersCount: allSigners.size, + addressBookCount, + } + }, [importedData]) + + const handleContinue = useCallback(() => { + // Navigate to import progress screen to start the actual import + router.push('/import-data/import-progress') + }, [router]) + + return ( + <ReviewDataView + bottomInset={insets.bottom} + importSummary={importSummary} + isImportDataAvailable={!!importedData} + onContinue={handleContinue} + /> + ) +} diff --git a/apps/mobile/src/features/DataImport/__tests__/EnterPassword.test.tsx b/apps/mobile/src/features/DataImport/__tests__/EnterPassword.test.tsx new file mode 100644 index 0000000000..8cbdf89e34 --- /dev/null +++ b/apps/mobile/src/features/DataImport/__tests__/EnterPassword.test.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { act, fireEvent, render } from '@/src/tests/test-utils' +import { EnterPassword } from '../EnterPassword.container' +import { useDataImportContext } from '../context/DataImportProvider' +import { useRouter } from 'expo-router' + +jest.mock('../context/DataImportProvider', () => ({ + useDataImportContext: jest.fn(), +})) + +jest.mock('expo-router', () => ({ + useRouter: jest.fn(), +})) + +describe('EnterPassword', () => { + const pushMock = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useRouter).mockReturnValue({ push: pushMock } as unknown as ReturnType<typeof useRouter>) + }) + + it('shows the selected file name', () => { + jest.mocked(useDataImportContext).mockReturnValue({ + handlePasswordChange: jest.fn(), + handleImport: jest.fn(), + password: '', + isLoading: false, + fileName: 'export.json', + } as unknown as ReturnType<typeof useDataImportContext>) + + const { getByTestId } = render(<EnterPassword />) + + expect(getByTestId('file-name').props.children).toContain('export.json') + }) + + it('navigates to error screen when import fails', async () => { + const importMock = jest.fn().mockResolvedValue(null) + jest.mocked(useDataImportContext).mockReturnValue({ + handlePasswordChange: jest.fn(), + handleImport: importMock, + password: 'pw', + isLoading: false, + fileName: 'export.json', + } as unknown as ReturnType<typeof useDataImportContext>) + + const { getByTestId } = render(<EnterPassword />) + + await act(async () => { + fireEvent.press(getByTestId('decrypt-button')) + }) + + expect(importMock).toHaveBeenCalled() + expect(pushMock).toHaveBeenCalledWith('/import-data/import-error') + }) +}) diff --git a/apps/mobile/src/features/DataImport/__tests__/FileSelection.test.tsx b/apps/mobile/src/features/DataImport/__tests__/FileSelection.test.tsx new file mode 100644 index 0000000000..1948319365 --- /dev/null +++ b/apps/mobile/src/features/DataImport/__tests__/FileSelection.test.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { act, fireEvent, render } from '@/src/tests/test-utils' +import { FileSelection } from '../FileSelection.container' +import { useDataImportContext } from '../context/DataImportProvider' +import { useRouter } from 'expo-router' + +jest.mock('../context/DataImportProvider', () => ({ + useDataImportContext: jest.fn(), +})) + +jest.mock('expo-router', () => ({ + useRouter: jest.fn(), +})) + +describe('FileSelection', () => { + const pushMock = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useRouter).mockReturnValue({ push: pushMock } as unknown as ReturnType<typeof useRouter>) + }) + + it('navigates when a file is selected', async () => { + jest.mocked(useDataImportContext).mockReturnValue({ + pickFile: jest.fn().mockResolvedValue(true), + } as unknown as ReturnType<typeof useDataImportContext>) + + const { getByTestId } = render(<FileSelection />) + + await act(async () => { + fireEvent.press(getByTestId('select-file-to-import-button')) + }) + + expect(pushMock).toHaveBeenCalledWith('/import-data/enter-password') + }) + + it('does not navigate when no file is selected', async () => { + jest.mocked(useDataImportContext).mockReturnValue({ + pickFile: jest.fn().mockResolvedValue(false), + } as unknown as ReturnType<typeof useDataImportContext>) + + const { getByTestId } = render(<FileSelection />) + + await act(async () => { + fireEvent.press(getByTestId('select-file-to-import-button')) + }) + + expect(pushMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/src/features/DataImport/__tests__/ImportProgressScreen.test.tsx b/apps/mobile/src/features/DataImport/__tests__/ImportProgressScreen.test.tsx new file mode 100644 index 0000000000..88feff04a7 --- /dev/null +++ b/apps/mobile/src/features/DataImport/__tests__/ImportProgressScreen.test.tsx @@ -0,0 +1,111 @@ +import { useRouter } from 'expo-router' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { storePrivateKey } from '@/src/hooks/useSign/useSign' +import { useDataImportContext } from '../context/DataImportProvider' +import * as transforms from '../helpers/transforms' + +jest.mock('expo-router', () => ({ + useRouter: jest.fn(), +})) + +jest.mock('@/src/store/hooks', () => ({ + useAppDispatch: jest.fn(), + useAppSelector: jest.fn(), +})) + +jest.mock('@/src/hooks/useSign/useSign', () => ({ + storePrivateKey: jest.fn(), +})) + +jest.mock('@/src/hooks/useDelegate', () => ({ + __esModule: true, + default: jest.fn(() => ({ + createDelegate: jest.fn().mockResolvedValue({ success: true, delegateAddress: '0xDelegate' }), + })), +})) + +jest.mock('../context/DataImportProvider', () => ({ + useDataImportContext: jest.fn(), +})) + +jest.mock('../helpers/transforms', () => ({ + ...jest.requireActual('../helpers/transforms'), + fetchSafeOwnersInBatches: jest.fn(), + storeKeysWithValidation: jest.fn(), + storeSafes: jest.fn(), + storeContacts: jest.fn(), +})) + +describe('ImportProgressScreen', () => { + const pushMock = jest.fn() + const backMock = jest.fn() + const dispatchMock = jest.fn() + const mockCreateDelegate = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + + jest + .mocked(useRouter) + .mockReturnValue({ push: pushMock, back: backMock } as unknown as ReturnType<typeof useRouter>) + jest.mocked(useAppDispatch).mockReturnValue(dispatchMock) + jest.mocked(useAppSelector).mockReturnValue('USD') + jest.mocked(storePrivateKey).mockResolvedValue(undefined) + mockCreateDelegate.mockResolvedValue({ success: true, delegateAddress: '0xDelegate' }) + + // Mock useDelegate hook + require('@/src/hooks/useDelegate').default.mockReturnValue({ + createDelegate: mockCreateDelegate, + }) + + // Mock transforms functions + jest.mocked(transforms.fetchSafeOwnersInBatches).mockResolvedValue(new Set(['0x2'])) + jest.mocked(transforms.storeKeysWithValidation).mockResolvedValue(undefined) + jest.mocked(transforms.storeContacts).mockImplementation(jest.fn()) + }) + + it('calls storeKeysWithValidation with createDelegate parameter', async () => { + const updateNotImportedKeys = jest.fn() + + jest.mocked(useDataImportContext).mockReturnValue({ + importedData: { + data: { + safes: [{ address: '0x1', chain: '1', name: 'Safe' }], + keys: [{ address: '0x2', name: 'Key', key: 'AAAA' }], + contacts: [{ address: '0x3', name: 'Contact', chain: '1' }], + }, + }, + updateNotImportedKeys, + } as unknown as ReturnType<typeof useDataImportContext>) + + // Test that the hook integration works by checking the mocks + expect(mockCreateDelegate).toBeDefined() + expect(transforms.storeKeysWithValidation).toBeDefined() + + // Simulate calling the storeKeysWithValidation with the createDelegate parameter + await transforms.storeKeysWithValidation( + { keys: [{ address: '0x2', name: 'Key', key: 'AAAA' }] }, + new Set(['0x2']), + dispatchMock, + updateNotImportedKeys, + mockCreateDelegate, + ) + + // Verify that the function was called with the correct parameters + expect(transforms.storeKeysWithValidation).toHaveBeenCalledWith( + { keys: [{ address: '0x2', name: 'Key', key: 'AAAA' }] }, + new Set(['0x2']), + dispatchMock, + updateNotImportedKeys, + mockCreateDelegate, + ) + }) + + it('useDelegate hook returns createDelegate function', () => { + const useDelegate = require('@/src/hooks/useDelegate').default + const result = useDelegate() + + expect(result.createDelegate).toBeDefined() + expect(typeof result.createDelegate).toBe('function') + }) +}) diff --git a/apps/mobile/src/features/DataImport/__tests__/ReviewData.test.tsx b/apps/mobile/src/features/DataImport/__tests__/ReviewData.test.tsx new file mode 100644 index 0000000000..20ddae491f --- /dev/null +++ b/apps/mobile/src/features/DataImport/__tests__/ReviewData.test.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import { fireEvent, render, within } from '@/src/tests/test-utils' +import { ReviewData } from '../ReviewData.container' +import { useDataImportContext } from '../context/DataImportProvider' +import { useRouter } from 'expo-router' + +jest.mock('../context/DataImportProvider', () => ({ + useDataImportContext: jest.fn(), +})) + +jest.mock('expo-router', () => ({ + useRouter: jest.fn(), +})) + +describe('ReviewData', () => { + const pushMock = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useRouter).mockReturnValue({ push: pushMock } as unknown as ReturnType<typeof useRouter>) + }) + + it('summarizes imported counts correctly', () => { + jest.mocked(useDataImportContext).mockReturnValue({ + importedData: { + data: { + safes: [ + { address: '0x1', chain: '1', name: 'Safe 1' }, + { address: '0x2', chain: '1', name: 'Safe 2' }, + ], + contacts: [{ address: '0x3', name: 'Contact', chain: '1' }], + keys: [ + { address: '0x4', name: 'Key 1', key: 'AAA' }, + { address: '0x4', name: 'Key 1', key: 'BBB' }, + ], + }, + }, + } as unknown as ReturnType<typeof useDataImportContext>) + + const { getByTestId } = render(<ReviewData />) + + expect(within(getByTestId('safe-accounts-summary')).getByText('2')).toBeTruthy() + expect(within(getByTestId('signers-summary')).getByText('1')).toBeTruthy() + expect(within(getByTestId('address-book-summary')).getByText('1')).toBeTruthy() + }) + + it('navigates to progress screen on continue', () => { + jest.mocked(useDataImportContext).mockReturnValue({ + importedData: { data: {} }, + } as unknown as ReturnType<typeof useDataImportContext>) + + const { getByTestId } = render(<ReviewData />) + + fireEvent.press(getByTestId('continue-button')) + + expect(pushMock).toHaveBeenCalledWith('/import-data/import-progress') + }) +}) diff --git a/apps/mobile/src/features/DataImport/__tests__/useLegacyImport.test.ts b/apps/mobile/src/features/DataImport/__tests__/useLegacyImport.test.ts new file mode 100644 index 0000000000..65ab342893 --- /dev/null +++ b/apps/mobile/src/features/DataImport/__tests__/useLegacyImport.test.ts @@ -0,0 +1,205 @@ +import { act, renderHook } from '@/src/tests/test-utils' +import { useLegacyImport } from '../hooks/useLegacyImport' +import * as DocumentPicker from 'expo-document-picker' +import * as FileSystem from 'expo-file-system' +import { + decodeLegacyData, + LegacyDataPasswordError, + LegacyDataFormatError, + LegacyDataCorruptedError, +} from '@/src/utils/legacyData' + +jest.mock('expo-document-picker') +jest.mock('expo-file-system') +jest.mock('@/src/utils/legacyData', () => ({ + decodeLegacyData: jest.fn(), + LegacyDataPasswordError: class LegacyDataPasswordError extends Error { + constructor() { + super('Invalid password for legacy data') + this.name = 'LegacyDataPasswordError' + } + }, + LegacyDataFormatError: class LegacyDataFormatError extends Error { + constructor() { + super('Invalid legacy data format') + this.name = 'LegacyDataFormatError' + } + }, + LegacyDataCorruptedError: class LegacyDataCorruptedError extends Error { + constructor() { + super('Legacy data appears to be corrupted') + this.name = 'LegacyDataCorruptedError' + } + }, +})) + +describe('useLegacyImport', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('imports a valid file', async () => { + jest.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [{ uri: 'uri', name: 'file.json' }], + } as unknown as DocumentPicker.DocumentPickerResult) + + const fileContent = JSON.stringify({ version: '1.0', data: { hello: 'world' } }) + jest.mocked(FileSystem.readAsStringAsync).mockResolvedValue(fileContent) + jest.mocked(decodeLegacyData).mockReturnValue({ + version: '1.0', + data: { hello: 'world' }, + }) + + const { result } = renderHook(() => useLegacyImport()) + + await act(async () => { + await result.current.pickFile() + }) + + act(() => { + result.current.handlePasswordChange('pw') + }) + + let data + await act(async () => { + data = await result.current.handleImport() + }) + + expect(data).toEqual({ version: '1.0', data: { hello: 'world' } }) + expect(result.current.importedData).toEqual({ version: '1.0', data: { hello: 'world' } }) + expect(result.current.error).toBeUndefined() + }) + + it('sets error on invalid JSON', async () => { + jest.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [{ uri: 'uri', name: 'file.json' }], + } as unknown as DocumentPicker.DocumentPickerResult) + jest.mocked(FileSystem.readAsStringAsync).mockResolvedValue('not json') + + const { result } = renderHook(() => useLegacyImport()) + + await act(async () => { + await result.current.pickFile() + }) + + act(() => { + result.current.handlePasswordChange('pw') + }) + + await act(async () => { + await result.current.handleImport() + }) + + expect(result.current.error).toBe('Invalid file format. Please select a valid export file.') + }) + + it('sets error on wrong password', async () => { + jest.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [{ uri: 'uri', name: 'file.json' }], + } as unknown as DocumentPicker.DocumentPickerResult) + jest.mocked(FileSystem.readAsStringAsync).mockResolvedValue('{}') + jest.mocked(decodeLegacyData).mockImplementation(() => { + throw new LegacyDataPasswordError() + }) + + const { result } = renderHook(() => useLegacyImport()) + + await act(async () => { + await result.current.pickFile() + }) + + act(() => { + result.current.handlePasswordChange('pw') + }) + + await act(async () => { + await result.current.handleImport() + }) + + expect(result.current.error).toBe('Incorrect password. Please try again.') + }) + + it('sets error on format error', async () => { + jest.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [{ uri: 'uri', name: 'file.json' }], + } as unknown as DocumentPicker.DocumentPickerResult) + jest.mocked(FileSystem.readAsStringAsync).mockResolvedValue('{}') + jest.mocked(decodeLegacyData).mockImplementation(() => { + throw new LegacyDataFormatError() + }) + + const { result } = renderHook(() => useLegacyImport()) + + await act(async () => { + await result.current.pickFile() + }) + + act(() => { + result.current.handlePasswordChange('pw') + }) + + await act(async () => { + await result.current.handleImport() + }) + + expect(result.current.error).toBe('Invalid file format. Please select a valid export file.') + }) + + it('sets error on corrupted data error', async () => { + jest.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [{ uri: 'uri', name: 'file.json' }], + } as unknown as DocumentPicker.DocumentPickerResult) + jest.mocked(FileSystem.readAsStringAsync).mockResolvedValue('{}') + jest.mocked(decodeLegacyData).mockImplementation(() => { + throw new LegacyDataCorruptedError() + }) + + const { result } = renderHook(() => useLegacyImport()) + + await act(async () => { + await result.current.pickFile() + }) + + act(() => { + result.current.handlePasswordChange('pw') + }) + + await act(async () => { + await result.current.handleImport() + }) + + expect(result.current.error).toBe('Invalid file format. Please select a valid export file.') + }) + + it('sets generic error for unknown errors', async () => { + jest.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [{ uri: 'uri', name: 'file.json' }], + } as unknown as DocumentPicker.DocumentPickerResult) + jest.mocked(FileSystem.readAsStringAsync).mockResolvedValue('{}') + jest.mocked(decodeLegacyData).mockImplementation(() => { + throw new Error('Unknown error') + }) + + const { result } = renderHook(() => useLegacyImport()) + + await act(async () => { + await result.current.pickFile() + }) + + act(() => { + result.current.handlePasswordChange('pw') + }) + + await act(async () => { + await result.current.handleImport() + }) + + expect(result.current.error).toBe('Failed to import data. Please check your file and password.') + }) +}) diff --git a/apps/mobile/src/features/DataImport/components/DataTransferView.tsx b/apps/mobile/src/features/DataImport/components/DataTransferView.tsx new file mode 100644 index 0000000000..64e738811a --- /dev/null +++ b/apps/mobile/src/features/DataImport/components/DataTransferView.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { Text, YStack, Image, styled, H2, H5, getTokenValue } from 'tamagui' +import { SafeButton } from '@/src/components/SafeButton' +import TransferOldAppDark from '@/assets/images/transfer-old-app-dark.png' +import TransferOldAppLight from '@/assets/images/transfer-old-app-light.png' +import { ColorSchemeName } from 'react-native' +import { GradientText } from '@/src/components/GradientText' + +const StyledText = styled(Text, { + fontSize: '$4', + textAlign: 'center', +}) + +interface DataTransferViewProps { + colorScheme: ColorSchemeName + bottomInset: number + onPressTransferData: () => void + onPressStartFresh: () => void +} + +export const DataTransferView = ({ + colorScheme, + bottomInset, + onPressTransferData, + onPressStartFresh, +}: DataTransferViewProps) => { + return ( + <YStack flex={1} paddingTop={'$4'} testID="data-transfer-screen"> + {/* Content */} + <YStack flex={1} paddingHorizontal="$4" justifyContent="space-between" marginBottom={'$4'}> + <YStack gap="$4" alignItems="center"> + {colorScheme === 'dark' ? ( + <GradientText + colors={[getTokenValue('$color.infoMainDark'), getTokenValue('$color.primaryMainDark')]} + fontWeight={'600'} + color="$green9" + fontSize="$5" + textAlign="center" + gradientStart={{ x: 0, y: 0 }} + gradientEnd={{ x: 1, y: 0 }} + > + Still have the old app? + </GradientText> + ) : ( + <H5 fontWeight={'600'} color="$colorSecondary"> + Still have the old app? + </H5> + )} + + <H2 fontWeight={'600'} textAlign="center"> + Import old app data + </H2> + + <StyledText>Move your Safe accounts, signers, and address book in minutes.</StyledText> + </YStack> + + {/* Phone Mockup */} + <Image source={colorScheme === 'dark' ? TransferOldAppDark : TransferOldAppLight} /> + </YStack> + + {/* Bottom Buttons */} + <YStack gap="$3" paddingHorizontal="$4" paddingBottom={bottomInset} paddingTop="$4"> + <SafeButton primary testID="transfer-data-button" onPress={onPressTransferData}> + Import data + </SafeButton> + + <SafeButton text testID="start-fresh-button" onPress={onPressStartFresh}> + Start fresh + </SafeButton> + </YStack> + </YStack> + ) +} diff --git a/apps/mobile/src/features/DataImport/components/EnterPasswordView.tsx b/apps/mobile/src/features/DataImport/components/EnterPasswordView.tsx new file mode 100644 index 0000000000..d59e9f9d8e --- /dev/null +++ b/apps/mobile/src/features/DataImport/components/EnterPasswordView.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import { Text, YStack, H2, XStack, ScrollView } from 'tamagui' +import { SafeButton } from '@/src/components/SafeButton' +import { KeyboardAvoidingView } from 'react-native' +import { Alert } from '@/src/components/Alert' +import { SafeInput } from '@/src/components/SafeInput' + +interface EnterPasswordViewProps { + topInset: number + bottomInset: number + password: string + isLoading: boolean + fileName?: string + onPasswordChange: (password: string) => void + onDecrypt: () => void +} + +export const EnterPasswordView = ({ + topInset, + bottomInset, + password, + isLoading, + fileName, + onPasswordChange, + onDecrypt, +}: EnterPasswordViewProps) => { + return ( + <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }} keyboardVerticalOffset={bottomInset + topInset}> + <ScrollView contentContainerStyle={{ flex: 1 }} keyboardShouldPersistTaps="handled"> + <YStack flex={1} testID="enter-password-screen"> + <YStack flex={1} paddingHorizontal="$4" justifyContent="space-between" marginTop={'$4'}> + <YStack gap="$6"> + <H2 fontWeight={'600'} textAlign="center" marginHorizontal={'$4'}> + Enter password + </H2> + + <XStack justifyContent="center"> + <Alert type="warning" message="Use the password you set to encrypt the file." orientation="left" /> + </XStack> + + <YStack gap="$4"> + <SafeInput + placeholder="Enter the file password" + keyboardType="visible-password" + value={password} + onChangeText={onPasswordChange} + autoFocus + secureTextEntry + testID="password-input" + /> + + <YStack> + {fileName && ( + <Text color="$colorSecondary" fontSize="$3" testID="file-name"> + File: {fileName} + </Text> + )} + </YStack> + </YStack> + </YStack> + + <YStack gap="$4" paddingBottom={bottomInset}> + <SafeButton + primary + testID="decrypt-button" + onPress={onDecrypt} + disabled={!password.length || isLoading} + opacity={!password.length || isLoading ? 0.5 : 1} + > + {isLoading ? 'Decrypting...' : 'Continue'} + </SafeButton> + </YStack> + </YStack> + </YStack> + </ScrollView> + </KeyboardAvoidingView> + ) +} diff --git a/apps/mobile/src/features/DataImport/components/FileSelectionView.tsx b/apps/mobile/src/features/DataImport/components/FileSelectionView.tsx new file mode 100644 index 0000000000..fd0d14789c --- /dev/null +++ b/apps/mobile/src/features/DataImport/components/FileSelectionView.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { Text, YStack, Image, styled, H2 } from 'tamagui' +import { SafeButton } from '@/src/components/SafeButton' +import ImportDataSelectFilesDark from '@/assets/images/import-data-select-files-dark.png' +import ImportDataSelectFilesLight from '@/assets/images/import-data-select-files-light.png' +import { ColorSchemeName, TouchableOpacity } from 'react-native' + +const StyledText = styled(Text, { + fontSize: '$4', + textAlign: 'center', + color: '$colorSecondary', +}) + +interface FileSelectionViewProps { + colorScheme: ColorSchemeName + bottomInset: number + onFileSelect: () => void + onImagePress: () => void +} + +export const FileSelectionView = ({ colorScheme, bottomInset, onFileSelect, onImagePress }: FileSelectionViewProps) => { + return ( + <YStack flex={1} testID="file-selection-screen" paddingBottom={bottomInset}> + <YStack flex={1} paddingHorizontal="$4" justifyContent="space-between" marginTop={'$4'}> + <YStack gap="$4" flex={1}> + <H2 fontWeight={'600'} textAlign="center" marginHorizontal={'$4'}> + Import your file + </H2> + + <StyledText>Find the exported file from your old app to continue.</StyledText> + + <YStack flex={1} justifyContent="center" alignItems="center"> + <TouchableOpacity onPress={onImagePress} activeOpacity={0.8}> + <Image + source={colorScheme === 'dark' ? ImportDataSelectFilesDark : ImportDataSelectFilesLight} + alignSelf="center" + marginVertical="$4" + /> + </TouchableOpacity> + </YStack> + </YStack> + + <YStack gap="$4"> + <SafeButton primary testID="select-file-to-import-button" onPress={onFileSelect}> + Select from files + </SafeButton> + </YStack> + </YStack> + </YStack> + ) +} diff --git a/apps/mobile/src/features/DataImport/components/HelpImportView.tsx b/apps/mobile/src/features/DataImport/components/HelpImportView.tsx new file mode 100644 index 0000000000..269cfdeed0 --- /dev/null +++ b/apps/mobile/src/features/DataImport/components/HelpImportView.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import { Text, YStack, XStack, styled, H2 } from 'tamagui' +import { SafeButton } from '@/src/components/SafeButton' +import { TouchableOpacity } from 'react-native' +import { Badge } from '@/src/components/Badge' + +const StepText = styled(Text, { + fontSize: '$4', + lineHeight: '$5', + color: '$color', + flex: 1, +}) + +const HighlightedText = styled(Text, { + color: '$primary', + fontWeight: '600', +}) + +const StepBadge = ({ step }: { step: string }) => { + return <Badge themeName="badge_background" content={step} textContentProps={{ fontWeight: 600 }} /> +} + +interface HelpImportViewProps { + bottomInset: number + onPressProceedToImport: () => void + onPressNeedHelp: () => void +} + +export const HelpImportView = ({ bottomInset, onPressProceedToImport, onPressNeedHelp }: HelpImportViewProps) => { + return ( + <YStack flex={1} testID="help-import-screen"> + <YStack flex={1} paddingHorizontal="$4" justifyContent="space-between" marginTop={'$4'}> + <YStack gap="$6"> + <H2 fontWeight={'600'} textAlign="center" marginHorizontal={'$4'}> + How to move your data + </H2> + + <YStack gap="$4"> + <XStack gap="$3" alignItems="center"> + <StepBadge step="1" /> + <StepText> + Open your old Safe{'{'}Wallet{'}'} app. + </StepText> + </XStack> + + <XStack gap="$3" alignItems="center"> + <StepBadge step="2" /> + <StepText> + Go to <HighlightedText>Settings</HighlightedText> → <HighlightedText>Export Data</HighlightedText>. + </StepText> + </XStack> + + <XStack gap="$3" alignItems="center"> + <StepBadge step="3" /> + <StepText>Follow the steps to save the file.</StepText> + </XStack> + + <XStack gap="$3" alignItems="center"> + <StepBadge step="4" /> + <StepText>Return here to import it.</StepText> + </XStack> + </YStack> + </YStack> + + <YStack gap="$4" paddingBottom={bottomInset}> + <SafeButton primary testID="proceed-to-import-button" onPress={onPressProceedToImport}> + Proceed to import + </SafeButton> + + <TouchableOpacity onPress={onPressNeedHelp} testID="need-help-button"> + <Text textAlign="center" fontSize="$4"> + Need help? <HighlightedText>Visit Help Center</HighlightedText> + </Text> + </TouchableOpacity> + </YStack> + </YStack> + </YStack> + ) +} diff --git a/apps/mobile/src/features/DataImport/components/ImportErrorView.tsx b/apps/mobile/src/features/DataImport/components/ImportErrorView.tsx new file mode 100644 index 0000000000..019fc8d17a --- /dev/null +++ b/apps/mobile/src/features/DataImport/components/ImportErrorView.tsx @@ -0,0 +1,66 @@ +import { StyleSheet } from 'react-native' +import { LinearGradient } from 'expo-linear-gradient' +import React from 'react' +import { ScrollView, Text, View, YStack } from 'tamagui' +import { Badge } from '@/src/components/Badge' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { LargeHeaderTitle } from '@/src/components/Title' +import { SafeButton } from '@/src/components/SafeButton' + +interface ImportErrorViewProps { + colors: [string, string] + bottomInset: number + onTryAgain: () => void +} + +export const ImportErrorView = ({ colors, bottomInset, onTryAgain }: ImportErrorViewProps) => { + return ( + <YStack flex={1} testID="import-error-screen" paddingBottom={bottomInset}> + <LinearGradient colors={colors} style={styles.background} /> + <View flex={1} justifyContent="space-between"> + <View flex={1}> + <ScrollView contentContainerStyle={{ flexGrow: 1 }}> + <View + flex={1} + flexGrow={1} + alignItems="center" + marginTop="$10" + justifyContent="center" + paddingHorizontal="$3" + > + <Badge + themeName="badge_error" + circleSize={64} + content={<SafeFontIcon size={32} color="$error" name="close-filled" />} + /> + + <View margin="$4" width="100%" alignItems="center" gap="$4"> + <LargeHeaderTitle textAlign="center" size="$8" lineHeight={32} maxWidth={200} fontWeight={600}> + Import failed + </LargeHeaderTitle> + + <Text textAlign="center" fontSize="$4" width="80%"> + The file could not be processed. Please check the file details and try again. + </Text> + </View> + </View> + </ScrollView> + </View> + + <View paddingHorizontal="$4" gap="$4"> + <SafeButton onPress={onTryAgain}>Try again</SafeButton> + </View> + </View> + </YStack> + ) +} + +const styles = StyleSheet.create({ + background: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + height: 300, + }, +}) diff --git a/apps/mobile/src/features/DataImport/components/ImportProgressScreenView.tsx b/apps/mobile/src/features/DataImport/components/ImportProgressScreenView.tsx new file mode 100644 index 0000000000..45928a3f6a --- /dev/null +++ b/apps/mobile/src/features/DataImport/components/ImportProgressScreenView.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { Text, YStack, H2, ScrollView, View } from 'tamagui' +import { Bar } from 'react-native-progress' + +interface ImportProgressScreenViewProps { + progress: number + message?: string +} + +export const ImportProgressScreenView = ({ progress, message }: ImportProgressScreenViewProps) => { + return ( + <ScrollView contentContainerStyle={{ flex: 1 }}> + <YStack flex={1} testID="import-progress-screen"> + {/* Content */} + <YStack flex={1} paddingHorizontal="$4" justifyContent="center" alignItems="center"> + <YStack gap="$6" alignItems="center" maxWidth={300}> + {/* Title */} + <H2 fontWeight={'600'} textAlign="center"> + Importing data... + </H2> + + {/* Subtitle */} + <Text fontSize="$4" textAlign="center" color="$colorSecondary"> + Hang on, it may take a few seconds + </Text> + + {/* Progress Message */} + {message && ( + <Text fontSize="$3" textAlign="center" color="$colorSecondary" marginTop="$4"> + {message} + </Text> + )} + + {/* Progress Bar Container */} + <View width="100%" height={8} borderRadius="$2" overflow="hidden" marginTop="$8"> + <Bar progress={progress / 100} borderWidth={0} color="#5FDDFF" useNativeDriver={true} /> + </View> + + {/* Progress Percentage */} + <Text fontSize="$5" fontWeight="600" color="$color"> + {Math.round(progress)}% + </Text> + </YStack> + </YStack> + </YStack> + </ScrollView> + ) +} diff --git a/apps/mobile/src/features/DataImport/components/ImportSuccessScreenView.tsx b/apps/mobile/src/features/DataImport/components/ImportSuccessScreenView.tsx new file mode 100644 index 0000000000..a7ac0d680e --- /dev/null +++ b/apps/mobile/src/features/DataImport/components/ImportSuccessScreenView.tsx @@ -0,0 +1,113 @@ +import React from 'react' +import { Text, YStack, H2, ScrollView, View, XStack } from 'tamagui' +import { SafeButton } from '@/src/components/SafeButton' +import { StyleSheet } from 'react-native' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Badge } from '@/src/components/Badge' +import { LinearGradient } from 'expo-linear-gradient' +import { NotImportedKey } from '../helpers/transforms' +import { Identicon } from '@/src/components/Identicon' +import { Container } from '@/src/components/Container' +import { InfoSheet } from '@/src/components/InfoSheet' + +interface ImportSuccessScreenViewProps { + bottomInset: number + gradientColors: [string, string] + onContinue: () => void + notImportedKeys: NotImportedKey[] +} + +export const ImportSuccessScreenView = ({ + bottomInset, + gradientColors, + onContinue, + notImportedKeys, +}: ImportSuccessScreenViewProps) => { + return ( + <View flex={1} paddingBottom={bottomInset} testID="import-success-screen"> + <LinearGradient colors={gradientColors} style={styles.background} /> + <View flex={1} justifyContent="space-between"> + <View flex={1}> + <ScrollView contentContainerStyle={{ flexGrow: 1 }}> + <View flex={1} flexGrow={1} alignItems="center" justifyContent="center" paddingHorizontal="$3"> + <Badge + themeName="badge_success_variant1" + circleSize={64} + content={<SafeFontIcon size={32} name="check-filled" />} + /> + + <YStack margin="$4" width="100%" alignItems="center" gap="$4"> + {/* Title */} + <H2 fontWeight={'600'} textAlign="center"> + Import complete! + </H2> + + {/* Subtitle */} + <Text fontSize="$4" textAlign="center" marginHorizontal={'$4'} color="$colorSecondary"> + {notImportedKeys.length > 0 + ? "Your data has been successfully imported. However, some signers are not associated with your Safe accounts and won't be added" + : 'Your accounts, signers, and contacts are ready to use.'} + </Text> + + {/* Not Imported Keys Section */} + {notImportedKeys.length > 0 && ( + <YStack width="100%" gap="$3" marginTop="$4" paddingHorizontal="$2"> + <InfoSheet info="Those keys were not associated with any Safe account from your import."> + <XStack alignItems="center" justifyContent="center" gap="$2"> + <SafeFontIcon size={16} name={'info'} color="$colorSecondary" /> + <Text fontWeight="500" color="$colorSecondary"> + Why did it happen? + </Text> + </XStack> + </InfoSheet> + + <Container gap="$2" backgroundColor="$background" padding="$3" borderRadius="$3"> + <Text fontWeight="500" marginBottom="$2"> + Not imported: + </Text> + {notImportedKeys.map((key, index) => ( + <XStack key={index} alignItems="center" gap="$3" paddingVertical="$1"> + <Identicon address={key.address as `0x${string}`} size={32} /> + <YStack flex={1}> + <Text fontSize="$3" fontWeight="500"> + {key.name} + </Text> + <Text fontSize="$2" color="$colorSecondary"> + {key.address.slice(0, 6)}...{key.address.slice(-4)} + </Text> + </YStack> + </XStack> + ))} + </Container> + </YStack> + )} + </YStack> + </View> + </ScrollView> + </View> + + <View paddingHorizontal="$4"> + <Text fontSize="$2" color="$colorSecondary" marginBottom="$2" textAlign="center"> + This does not affect your imported accounts or the security of your data. + </Text> + <SafeButton primary testID="continue-button" onPress={onContinue}> + Continue + </SafeButton> + </View> + </View> + </View> + ) +} + +const styles = StyleSheet.create({ + background: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + height: 300, + }, + notImportedHeader: { + alignSelf: 'center', + }, +}) diff --git a/apps/mobile/src/features/DataImport/components/ReviewDataView.tsx b/apps/mobile/src/features/DataImport/components/ReviewDataView.tsx new file mode 100644 index 0000000000..5d039830df --- /dev/null +++ b/apps/mobile/src/features/DataImport/components/ReviewDataView.tsx @@ -0,0 +1,158 @@ +import React from 'react' +import { Text, YStack, H2, XStack, ScrollView } from 'tamagui' +import { SafeButton } from '@/src/components/SafeButton' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Container } from '@/src/components/Container' +import { Badge } from '@/src/components/Badge' + +interface ImportSummary { + safeAccountsCount: number + signersCount: number + addressBookCount: number +} + +interface ReviewDataViewProps { + bottomInset: number + importSummary: ImportSummary + isImportDataAvailable: boolean + onContinue: () => void +} + +export const ReviewDataView = ({ + bottomInset, + importSummary, + isImportDataAvailable, + onContinue, +}: ReviewDataViewProps) => { + return ( + <ScrollView contentContainerStyle={{ flex: 1 }}> + <YStack flex={1} testID="review-data-screen"> + <YStack flex={1} paddingHorizontal="$4" justifyContent="space-between" marginTop={'$4'}> + <YStack gap="$3"> + <H2 fontWeight={'600'} textAlign="center" marginHorizontal={'$4'}> + Review data + </H2> + + <Text fontSize="$4" textAlign="center" marginHorizontal={'$4'}> + Check that everything looks correct before importing. + </Text> + + <Container gap="$3" marginTop="$4" padding="$4" backgroundColor="$background" borderRadius="$4"> + <Text color="$colorSecondary" fontSize="$3" fontWeight="500"> + Importing: + </Text> + + <XStack + justifyContent="space-between" + alignItems="center" + paddingVertical="$1" + testID="safe-accounts-summary" + > + <XStack alignItems="center" gap="$3"> + <Badge + themeName="badge_background" + circleSize={32} + content={<SafeFontIcon name="wallet" size={16} color="$color" />} + /> + <YStack> + <Text fontSize="$4" fontWeight="500"> + Safe Accounts + </Text> + <Text fontSize="$2" color="$colorSecondary"> + Including read-only + </Text> + </YStack> + </XStack> + <Text fontSize="$5" fontWeight="600"> + <Badge + themeName="badge_background" + content={<Text fontWeight={600}>{importSummary.safeAccountsCount}</Text>} + circular + /> + </Text> + </XStack> + + <XStack justifyContent="space-between" alignItems="center" paddingVertical="$1" testID="signers-summary"> + <XStack alignItems="center" gap="$3"> + <Badge + themeName="badge_background" + circleSize={32} + content={<SafeFontIcon name="key" size={16} color="$color" />} + /> + <YStack> + <Text fontSize="$4" fontWeight="500"> + Signers + </Text> + <Text fontSize="$2" color="$colorSecondary"> + Generated and imported + </Text> + </YStack> + </XStack> + <Text fontSize="$5" fontWeight="600"> + <Badge + themeName="badge_background" + content={<Text fontWeight={600}>{importSummary.signersCount}</Text>} + circular + /> + </Text> + </XStack> + + <XStack + justifyContent="space-between" + alignItems="center" + paddingVertical="$1" + testID="address-book-summary" + > + <XStack alignItems="center" gap="$3"> + <Badge + themeName="badge_background" + circleSize={32} + content={<SafeFontIcon name="address-book" size={16} color="$color" />} + /> + <YStack> + <Text fontSize="$4" fontWeight="500"> + Address Book + </Text> + <Text fontSize="$2" color="$colorSecondary"> + All added contacts + </Text> + </YStack> + </XStack> + <Text fontSize="$5" fontWeight="600"> + <Badge + themeName="badge_background" + content={<Text fontWeight={600}>{importSummary.addressBookCount}</Text>} + circular + /> + </Text> + </XStack> + </Container> + </YStack> + + <YStack gap="$4" paddingBottom={bottomInset}> + <Text + color="$colorSecondary" + fontSize="$3" + textAlign="center" + marginHorizontal={'$4'} + testID="privacy-notice" + > + Your data stays private and secure during the transfer. + </Text> + + {/* Continue button */} + <SafeButton + primary + testID="continue-button" + onPress={onContinue} + disabled={!isImportDataAvailable} + opacity={!isImportDataAvailable ? 0.5 : 1} + > + Continue + </SafeButton> + </YStack> + </YStack> + </YStack> + </ScrollView> + ) +} diff --git a/apps/mobile/src/features/DataImport/context/DataImportProvider.tsx b/apps/mobile/src/features/DataImport/context/DataImportProvider.tsx new file mode 100644 index 0000000000..29063c741b --- /dev/null +++ b/apps/mobile/src/features/DataImport/context/DataImportProvider.tsx @@ -0,0 +1,24 @@ +import React, { createContext, useContext, ReactNode } from 'react' +import { useLegacyImport } from '../hooks/useLegacyImport' + +type DataImportContextType = ReturnType<typeof useLegacyImport> + +const DataImportContext = createContext<DataImportContextType | null>(null) + +export const useDataImportContext = () => { + const context = useContext(DataImportContext) + if (!context) { + throw new Error('useDataImportContext must be used within a DataImportProvider') + } + return context +} + +interface DataImportProviderProps { + children: ReactNode +} + +export const DataImportProvider: React.FC<DataImportProviderProps> = ({ children }) => { + const importState = useLegacyImport() + + return <DataImportContext.Provider value={importState}>{children}</DataImportContext.Provider> +} diff --git a/apps/mobile/src/features/DataImport/helpers/transforms.test.ts b/apps/mobile/src/features/DataImport/helpers/transforms.test.ts new file mode 100644 index 0000000000..74a6e2aa49 --- /dev/null +++ b/apps/mobile/src/features/DataImport/helpers/transforms.test.ts @@ -0,0 +1,289 @@ +import { + transformSafeData, + transformKeyData, + transformContactsData, + fetchAndStoreSafeOverviews, + storeSafeContacts, + storeContacts, + storeKeysWithValidation, + LegacyDataStructure, + ImportProgressCallback, +} from './transforms' +import { addContact, addContacts } from '@/src/store/addressBookSlice' +import { addSignerWithEffects } from '@/src/store/signersSlice' +import { storePrivateKey } from '@/src/hooks/useSign/useSign' +import { additionalSafesRtkApi } from '@safe-global/store/gateway/safes' + +jest.mock('@/src/hooks/useSign/useSign', () => ({ + storePrivateKey: jest.fn(), +})) + +jest.mock('@/src/store/signersSlice', () => ({ + addSignerWithEffects: jest.fn(), +})) + +jest.mock('@safe-global/store/gateway/safes', () => ({ + additionalSafesRtkApi: { + endpoints: { + safesGetOverviewForMany: { + initiate: jest.fn(), + }, + }, + }, +})) + +describe('Data import helpers', () => { + const mockCreateDelegate = jest.fn() + const mockDispatch = jest.fn() + const mockProgressCallback: ImportProgressCallback = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + mockCreateDelegate.mockResolvedValue({ success: true, delegateAddress: '0xDelegate' }) + + // Ensure all async mocks return resolved promises + ;(storePrivateKey as jest.Mock).mockResolvedValue(undefined) + ;(addSignerWithEffects as jest.Mock).mockReturnValue({ type: 'addSignerWithEffects' }) + + // Mock the RTK query + const mockQueryResult = { + unwrap: jest.fn().mockResolvedValue([ + { + address: { value: '0x1', name: 'Test Safe' }, + chainId: '1', + threshold: 2, + owners: [ + { value: '0x2', name: null }, + { value: '0x3', name: null }, + ], + fiatTotal: '1000.00', + queued: 3, + awaitingConfirmation: null, + }, + ]), + } + const mockInitiateAction = { type: 'safesGetOverviewForMany' } + ;(additionalSafesRtkApi.endpoints.safesGetOverviewForMany.initiate as jest.Mock).mockReturnValue(mockInitiateAction) + + // Mock dispatch to return the query result only for the RTK query action + mockDispatch.mockImplementation((action) => { + if (action === mockInitiateAction) { + return mockQueryResult + } + return { type: action.type } // Return action-like object for other dispatches + }) + }) + + afterEach(() => { + // Only run timer methods if fake timers are active + if (jest.isMockFunction(setTimeout)) { + jest.runOnlyPendingTimers() + } + jest.useRealTimers() + }) + + describe('Pure transformation functions', () => { + it('transforms safe data correctly', () => { + const safeData = { + address: '0x1', + chain: '1', + name: 'Test Safe', + threshold: 2, + owners: ['0x2', '0x3'], + } + + const result = transformSafeData(safeData) + + expect(result).toEqual({ + address: { value: '0x1', name: 'Test Safe' }, + chainId: '1', + threshold: 2, + owners: [ + { value: '0x2', name: null }, + { value: '0x3', name: null }, + ], + fiatTotal: '0', + queued: 0, + awaitingConfirmation: null, + }) + }) + + it('transforms key data correctly', () => { + const key = Buffer.from('abcd', 'hex').toString('base64') + const keyData = { + address: '0x1', + name: 'Owner', + key, + } + + const result = transformKeyData(keyData) + + expect(result).toEqual({ + address: '0x1', + privateKey: '0xabcd', + signerInfo: { + value: '0x1', + name: 'Owner', + }, + }) + }) + + it('transforms contact data correctly', () => { + const contactsData = [ + { + address: '0x1', + name: 'Contact 1', + chain: '1', + }, + { + address: '0x2', + name: 'Contact 2', + chain: '137', + }, + ] + + const result = transformContactsData(contactsData) + + expect(result).toEqual([ + { + value: '0x1', + name: 'Contact 1', + chainIds: ['1'], + }, + { + value: '0x2', + name: 'Contact 2', + chainIds: ['137'], + }, + ]) + }) + }) + + describe('Store functions', () => { + it('fetchAndStoreSafeOverviews fetches and returns owners', async () => { + const safes = [ + { address: '0x1', chainId: '1' }, + { address: '0x2', chainId: '137' }, + ] + + const result = await fetchAndStoreSafeOverviews(safes, 'USD', mockDispatch, mockProgressCallback) + + expect(additionalSafesRtkApi.endpoints.safesGetOverviewForMany.initiate).toHaveBeenCalledWith({ + safes: ['1:0x1', '137:0x2'], + currency: 'USD', + trusted: true, + excludeSpam: true, + }) + + expect(result).toEqual(new Set(['0x2', '0x3'])) + expect(mockProgressCallback).toHaveBeenCalled() + }) + + it('storeSafeContacts dispatches addContact for each safe', () => { + const data: LegacyDataStructure = { + safes: [ + { + address: '0x1', + chain: '1', + name: 'Test Safe', + threshold: 2, + owners: ['0x2'], + }, + { + address: '0x2', + chain: '137', + name: 'Test Safe 2', + threshold: 1, + owners: ['0x3'], + }, + ], + } + + storeSafeContacts(data, mockDispatch) + + expect(mockDispatch).toHaveBeenCalledWith(addContact({ value: '0x1', name: 'Test Safe', chainIds: [] })) + expect(mockDispatch).toHaveBeenCalledWith(addContact({ value: '0x2', name: 'Test Safe 2', chainIds: [] })) + }) + + it('storeContacts dispatches addContacts', () => { + const data: LegacyDataStructure = { + contacts: [ + { + address: '0x1', + name: 'Contact 1', + chain: '1', + }, + { + address: '0x2', + name: 'Contact 2', + chain: '137', + }, + ], + } + + storeContacts(data, mockDispatch) + + expect(mockDispatch).toHaveBeenCalledWith( + addContacts([ + { value: '0x1', name: 'Contact 1', chainIds: ['1'] }, + { value: '0x2', name: 'Contact 2', chainIds: ['137'] }, + ]), + ) + }) + }) + + describe('storeKeysWithValidation', () => { + it('imports keys that are owners and creates delegates', async () => { + const data: LegacyDataStructure = { + keys: [ + { + address: '0x2', + name: 'Owner 1', + key: Buffer.from('abcd', 'hex').toString('base64'), + }, + { + address: '0x4', + name: 'Non-Owner', + key: Buffer.from('efgh', 'hex').toString('base64'), + }, + ], + } + + const allOwners = new Set(['0x2', '0x3']) + const mockUpdateNotImportedKeys = jest.fn() + + // Use runAllTimersAsync to handle the async delay properly + const storeKeysPromise = storeKeysWithValidation( + data, + allOwners, + mockDispatch, + mockUpdateNotImportedKeys, + mockCreateDelegate, + mockProgressCallback, + ) + + await jest.runAllTimersAsync() + await storeKeysPromise + + // Should import the owner key + expect(storePrivateKey).toHaveBeenCalledWith('0x2', '0xabcd') + expect(mockDispatch).toHaveBeenCalledWith(addSignerWithEffects({ value: '0x2', name: 'Owner 1' })) + expect(mockCreateDelegate).toHaveBeenCalledWith('0xabcd', null) + + // Should not import the non-owner key + expect(storePrivateKey).not.toHaveBeenCalledWith('0x4', '0xefgh') + + // Should update not imported keys + expect(mockUpdateNotImportedKeys).toHaveBeenCalledWith([ + { + address: '0x4', + name: 'Non-Owner', + reason: 'Not an owner of any imported safe', + }, + ]) + + expect(mockProgressCallback).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/mobile/src/features/DataImport/helpers/transforms.ts b/apps/mobile/src/features/DataImport/helpers/transforms.ts new file mode 100644 index 0000000000..08b999a526 --- /dev/null +++ b/apps/mobile/src/features/DataImport/helpers/transforms.ts @@ -0,0 +1,357 @@ +export interface LegacyDataStructure { + safes?: { + address: string + chain: string + name: string + threshold?: number + owners?: string[] + }[] + contacts?: { + address: string + name: string + chain: string + }[] + keys?: { + address: string + name: string + key: string + }[] +} + +import { AppDispatch } from '@/src/store' +import { addSafe as _addSafe } from '@/src/store/safesSlice' +import { addSignerWithEffects as _addSignerWithEffects } from '@/src/store/signersSlice' +import { addContact as _addContact, addContacts, Contact } from '@/src/store/addressBookSlice' +import { storePrivateKey as _storePrivateKey } from '@/src/hooks/useSign/useSign' +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { additionalSafesRtkApi } from '@safe-global/store/gateway/safes' +import { addSignerWithEffects } from '@/src/store/signersSlice' +import { storePrivateKey } from '@/src/hooks/useSign/useSign' +import Logger from '@/src/utils/logger' + +export interface NotImportedKey { + address: string + name: string + reason: string +} + +export const transformSafeData = (safe: NonNullable<LegacyDataStructure['safes']>[0]): SafeOverview => { + return { + address: { + value: safe.address, + name: safe.name || null, + }, + chainId: safe.chain, + threshold: safe.threshold || 1, + owners: (safe.owners || []).map((owner: string) => ({ + value: owner, + name: null, + })), + fiatTotal: '0', + queued: 0, + awaitingConfirmation: null, + } +} + +export const transformKeyData = ( + key: NonNullable<LegacyDataStructure['keys']>[0], +): { address: string; privateKey: string; signerInfo: AddressInfo } => { + const hexPrivateKey = `0x${Buffer.from(key.key, 'base64').toString('hex')}` + + const signerInfo: AddressInfo = { + value: key.address, + name: key.name || null, + } + + return { + address: key.address, + privateKey: hexPrivateKey, + signerInfo, + } +} + +export const transformContactsData = (contacts: NonNullable<LegacyDataStructure['contacts']>): Contact[] => { + // Group contacts by address to handle same address on multiple chains + const contactsMap = new Map<string, Contact>() + + for (const contact of contacts) { + const address = contact.address.toLowerCase() // Normalize address for consistency + + if (contactsMap.has(address)) { + // Address already exists, add the chainId if not already present + const existingContact = contactsMap.get(address) + if (existingContact && !existingContact.chainIds.includes(contact.chain)) { + existingContact.chainIds.push(contact.chain) + } + } else { + // New address, create new contact + contactsMap.set(address, { + value: contact.address, // Keep original casing + name: contact.name, + chainIds: [contact.chain], + }) + } + } + + return Array.from(contactsMap.values()) +} + +interface SafeInfo { + address: string + chainId: string +} + +// Function to create delay for throttling +const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)) + +// Function to chunk array into smaller arrays +const chunkArray = <T>(array: T[], size: number): T[][] => { + const chunks: T[][] = [] + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)) + } + return chunks +} + +export type ImportProgressCallback = (progress: number, message: string) => void + +// Constants for import delays +const KEY_IMPORT_DELAY = 1000 // 1 second delay between key imports to throttle delegate creation + +/** + * Adds safe addresses to the Redux state with minimal data, then fetches complete SafeOverview data. + * This two-step process is required because the safesSlice extraReducer only updates existing safes. + */ +export const fetchAndStoreSafeOverviews = async ( + safes: SafeInfo[], + currency = 'USD', + dispatch: AppDispatch, + progressCallback?: ImportProgressCallback, +): Promise<Set<string>> => { + if (safes.length === 0) { + return new Set() + } + + // Step 1: Add safe addresses to state with minimal data so extraReducer can update them + Logger.info(`Pre-populating ${safes.length} safe addresses in Redux state`) + progressCallback?.(0, 'Preparing safes for data fetch...') + + for (const safe of safes) { + // Add safe with minimal data - the extraReducer will update this with full data + const minimalSafeOverview: SafeOverview = { + address: { value: safe.address, name: null }, + chainId: safe.chainId, + threshold: 1, + owners: [], + fiatTotal: '0', + queued: 0, + awaitingConfirmation: null, + } + + dispatch( + _addSafe({ + address: safe.address as `0x${string}`, + info: { [safe.chainId]: minimalSafeOverview }, + }), + ) + } + + // Step 2: Fetch complete SafeOverview data - this will trigger the extraReducer to update the state + const allOwners = new Set<string>() + const BATCH_SIZE = 10 + const THROTTLE_DELAY = 300 // 300ms between requests + + // Create safe IDs in the format expected by the API + const safeIds = safes.map((safe) => `${safe.chainId}:${safe.address}`) + const chunks = chunkArray(safeIds, BATCH_SIZE) + + Logger.info(`Fetching complete SafeOverview data for ${safes.length} safes in ${chunks.length} batches`) + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i] + const batchProgress = Math.round((i / chunks.length) * 100) + + Logger.info(`Processing batch ${i + 1}/${chunks.length} with ${chunk.length} safes`) + progressCallback?.(batchProgress, `Fetching safe data (batch ${i + 1}/${chunks.length})`) + + try { + // Make the API call for this batch - this will trigger the extraReducer to update the state + const response = await dispatch( + additionalSafesRtkApi.endpoints.safesGetOverviewForMany.initiate({ + safes: chunk, + currency, + trusted: true, + excludeSpam: true, + }), + ).unwrap() + + // Extract owners from the response + for (const safeOverview of response) { + if (safeOverview.owners) { + safeOverview.owners.forEach((owner: AddressInfo) => { + allOwners.add(owner.value.toLowerCase()) + }) + } + } + + // Add throttling delay between requests (except for the last batch) + if (i < chunks.length - 1) { + await delay(THROTTLE_DELAY) + } + } catch (error) { + Logger.error(`Failed to fetch safe information for batch ${i + 1}`, { + error: error instanceof Error ? error.message : 'Unknown error', + chunk, + }) + // Continue with next batch even if one fails + } + } + + progressCallback?.(100, `Fetched complete data for ${safes.length} safes`) + Logger.info(`Extracted ${allOwners.size} unique owners from ${safes.length} safes`) + Logger.info(`Complete SafeOverview data has been stored in Redux store via RTK query extraReducer`) + return allOwners +} + +/** + * Stores safe addresses as contacts in the address book. + * This function only handles contact creation and does NOT create SafeOverview data, + * as that should be handled by fetchAndStoreSafeOverviews. + */ +export const storeSafeContacts = (data: LegacyDataStructure, dispatch: AppDispatch): void => { + if (!data.safes) { + return + } + + Logger.info(`Storing ${data.safes.length} safe addresses as contacts`) + + for (const safe of data.safes) { + dispatch( + _addContact({ + value: safe.address, + name: safe.name, + chainIds: [], + }), + ) + } + + Logger.info(`Stored ${data.safes.length} safe contacts`) +} + +export const storeContacts = (data: LegacyDataStructure, dispatch: AppDispatch): void => { + if (!data.contacts) { + return + } + + const contactsToAdd = transformContactsData(data.contacts) + + dispatch(addContacts(contactsToAdd)) + Logger.info(`Imported ${contactsToAdd.length} contacts from ${data.contacts.length} contact entries`) +} + +export const storeKeysWithValidation = async ( + data: LegacyDataStructure, + allOwners: Set<string>, + dispatch: AppDispatch, + updateNotImportedKeys: (keys: NotImportedKey[]) => void, + createDelegate: ( + ownerPrivateKey: string, + safe?: string | null, + ) => Promise<{ + success: boolean + delegateAddress?: string + error?: string + }>, + progressCallback?: ImportProgressCallback, +): Promise<void> => { + if (!data.keys) { + return + } + + const notImportedKeys: NotImportedKey[] = [] + let importedCount = 0 + + Logger.info(`Validating ${data.keys.length} keys against ${allOwners.size} safe owners`) + + for (let i = 0; i < data.keys.length; i++) { + const key = data.keys[i] + const keyAddress = key.address.toLowerCase() + const keyProgress = Math.round((i / data.keys.length) * 100) + + progressCallback?.(keyProgress, `Processing key ${i + 1}/${data.keys.length}`) + + if (!allOwners.has(keyAddress)) { + // Key is not an owner of any safe, don't import it + notImportedKeys.push({ + address: key.address, + name: key.name || 'Unknown', + reason: 'Not an owner of any imported safe', + }) + + Logger.info(`Key ${key.address} not imported - not an owner of any safe`) + continue + } + + // Key is an owner, proceed with import + try { + const { address, privateKey, signerInfo } = transformKeyData(key) + + await storePrivateKey(address, privateKey) + dispatch(addSignerWithEffects(signerInfo)) + + // Create delegate for this owner + try { + progressCallback?.(keyProgress, `Creating delegate for ${key.name || key.address}`) + + // Pass null as safe address to create a delegate for the chain, not for a specific safe + const delegateResult = await createDelegate(privateKey, null) + + if (!delegateResult.success) { + Logger.error('Failed to create delegate during data import', { + address: key.address, + error: delegateResult.error, + }) + } else { + Logger.info(`Delegate created successfully for key ${key.address}`) + } + } catch (delegateError) { + // Log the error but continue with the import - delegate creation failure shouldn't prevent key import + Logger.error('Error creating delegate during data import', { + address: key.address, + error: delegateError instanceof Error ? delegateError.message : 'Unknown error', + }) + } + + importedCount++ + Logger.info(`Key ${key.address} successfully imported`) + + // Add delay between key imports to throttle delegate creation requests + // Skip delay for the last key to avoid unnecessary waiting + if (i < data.keys.length - 1) { + Logger.info(`Waiting ${KEY_IMPORT_DELAY}ms before processing next key...`) + await delay(KEY_IMPORT_DELAY) + } + } catch (error) { + Logger.error('Failed to import validated key', { + error: error instanceof Error ? error.message : 'Unknown error', + address: key.address, + }) + + notImportedKeys.push({ + address: key.address, + name: key.name || 'Unknown', + reason: 'Import failed due to technical error', + }) + } + } + + // Update the context with not imported keys + updateNotImportedKeys(notImportedKeys) + + progressCallback?.(100, `Completed: ${importedCount} keys imported`) + Logger.info(`Import validation complete: ${importedCount} keys imported, ${notImportedKeys.length} keys not imported`) +} + +// Legacy function - keeping for backward compatibility +export const fetchSafeOwnersInBatches = fetchAndStoreSafeOverviews diff --git a/apps/mobile/src/features/DataImport/hooks/useLegacyImport.ts b/apps/mobile/src/features/DataImport/hooks/useLegacyImport.ts new file mode 100644 index 0000000000..fd8aa00319 --- /dev/null +++ b/apps/mobile/src/features/DataImport/hooks/useLegacyImport.ts @@ -0,0 +1,123 @@ +import { useState, useCallback } from 'react' +import * as DocumentPicker from 'expo-document-picker' +import * as FileSystem from 'expo-file-system' +import Logger from '@/src/utils/logger' +import { + decodeLegacyData, + SecuredDataFile, + SerializedDataFile, + LegacyDataPasswordError, + LegacyDataFormatError, + LegacyDataCorruptedError, +} from '@/src/utils/legacyData' +import { NotImportedKey } from '../helpers/transforms' + +export function useLegacyImport() { + const [fileName, setFileName] = useState<string | null>(null) + const [fileUri, setFileUri] = useState<string | null>(null) + const [password, setPassword] = useState('') + const [error, setError] = useState<string>() + const [isLoading, setIsLoading] = useState(false) + const [importedData, setImportedData] = useState<SerializedDataFile | null>(null) + const [notImportedKeys, setNotImportedKeys] = useState<NotImportedKey[]>([]) + + const pickFile = async (): Promise<boolean> => { + try { + setError(undefined) + const res = await DocumentPicker.getDocumentAsync({ + type: '*/*', + copyToCacheDirectory: true, + }) + + // Check if the result is success type and has assets + if (res.canceled === false && res.assets && res.assets.length > 0) { + const asset = res.assets[0] + setFileName(asset.name) + setFileUri(asset.uri) + return true + } + return false + } catch (e) { + Logger.error('Failed to pick file', e) + setError('Failed to select file') + return false + } + } + + const handlePasswordChange = (text: string) => { + setPassword(text) + setError(undefined) // Clear error when user starts typing + } + + const handleImport = async () => { + if (!fileUri) { + setError('No file selected') + return + } + + if (!password.trim()) { + setError('Password is required') + return + } + + try { + setIsLoading(true) + setError(undefined) + + const content = await FileSystem.readAsStringAsync(fileUri) + const secured: SecuredDataFile = JSON.parse(content) + Logger.trace('Legacy secured data loaded') + + const decoded = decodeLegacyData(secured, password) + Logger.trace('Legacy data successfully decoded') + + setImportedData(decoded) + return decoded + } catch (e) { + Logger.error('Failed to import legacy data', { + errorType: e instanceof Error ? e.constructor.name : 'Unknown', + }) + + if (e instanceof LegacyDataPasswordError) { + setError('Incorrect password. Please try again.') + } else if (e instanceof LegacyDataFormatError || e instanceof LegacyDataCorruptedError) { + setError('Invalid file format. Please select a valid export file.') + } else if (e instanceof Error && e.message.includes('JSON')) { + setError('Invalid file format. Please select a valid export file.') + } else { + setError('Failed to import data. Please check your file and password.') + } + } finally { + setIsLoading(false) + } + } + + const updateNotImportedKeys = useCallback((keys: NotImportedKey[]) => { + setNotImportedKeys(keys) + }, []) + + const reset = () => { + setFileName(null) + setFileUri(null) + setPassword('') + setError(undefined) + setIsLoading(false) + setImportedData(null) + setNotImportedKeys([]) + } + + return { + pickFile, + handlePasswordChange, + handleImport, + updateNotImportedKeys, + reset, + fileName, + password, + error, + isLoading, + hasFile: !!fileUri, + importedData, + notImportedKeys, + } +} diff --git a/apps/mobile/src/features/DataImport/index.tsx b/apps/mobile/src/features/DataImport/index.tsx new file mode 100644 index 0000000000..5bfa1bccd3 --- /dev/null +++ b/apps/mobile/src/features/DataImport/index.tsx @@ -0,0 +1,7 @@ +export { DataTransfer } from './DataTransfer.container' +export { EnterPassword } from './EnterPassword.container' +export { FileSelection } from './FileSelection.container' +export { HelpImport } from './HelpImport.container' +export { ReviewData } from './ReviewData.container' + +export { DataImportProvider, useDataImportContext } from './context/DataImportProvider' diff --git a/apps/mobile/src/features/Developer/Developer.container.tsx b/apps/mobile/src/features/Developer/Developer.container.tsx new file mode 100644 index 0000000000..5d206ec4ca --- /dev/null +++ b/apps/mobile/src/features/Developer/Developer.container.tsx @@ -0,0 +1,34 @@ +import { Developer } from '@/src/features/Developer/components/Developer' +import * as Device from 'expo-device' +import * as Application from 'expo-application' +import { GATEWAY_URL } from '@/src/config/constants' +import { useAppSelector } from '@/src/store/hooks' +import { selectFCMToken } from '@/src/store/notificationsSlice' +import { type Info } from '@/src/features/Developer/types' + +export const DeveloperContainer = () => { + const fcmToken = useAppSelector(selectFCMToken) + + const info: Info = { + device: { + brand: Device.brand || '', + deviceName: Device.deviceName || '', + manufacturer: Device.manufacturer || '', + modelId: Device.modelId || '', + modelName: Device.modelName || '', + osBuildId: Device.osBuildId || '', + osInternalBuildId: Device.osInternalBuildId || '', + osName: Device.osName || '', + osVersion: Device.osVersion || '', + }, + application: { + applicationName: Application.applicationName || '', + applicationId: Application.applicationId || '', + applicationVersion: Application.nativeApplicationVersion || '', + applicationBuildNumber: Application.nativeBuildVersion || '', + gatewayUrl: GATEWAY_URL, + fcmToken: fcmToken || '', + }, + } + return <Developer info={info} /> +} diff --git a/apps/mobile/src/features/Developer/components/Developer.tsx b/apps/mobile/src/features/Developer/components/Developer.tsx new file mode 100644 index 0000000000..c3e7df2d66 --- /dev/null +++ b/apps/mobile/src/features/Developer/components/Developer.tsx @@ -0,0 +1,53 @@ +import { View, Text, ScrollView, H2 } from 'tamagui' +import { CopyButton } from '@/src/components/CopyButton' +import { type Info } from '@/src/features/Developer/types' +import { getCrashlytics } from '@react-native-firebase/crashlytics' +import { SafeButton } from '@/src/components/SafeButton' + +type DeveloperProps = { + info: Info +} + +type InfoProps = { + info: Record<string, string> +} +const Info = ({ info }: InfoProps) => { + return ( + <View> + {Object.keys(info).map((key) => { + const value = info[key] + return ( + <View key={key} marginBottom={'$2'}> + <Text fontWeight={600}>{key}: </Text> + <View padding={'$2'} borderRadius={'$6'} flex={1} flexDirection={'row'} justifyContent={'space-between'}> + <Text flex={1}>{value}</Text> + <View> + <CopyButton value={value} color={'$primary'} /> + </View> + </View> + </View> + ) + })} + </View> + ) +} +export const Developer = ({ info }: DeveloperProps) => { + return ( + <View flex={1}> + <ScrollView paddingHorizontal={'$4'}> + <View> + <H2>App info</H2> + <Info info={info.application} /> + </View> + <View marginTop={'$2'}> + <H2>Device Info</H2> + <Info info={info.device} /> + </View> + <View marginTop={'$4'}> + <Text>The button below will crash the app on purpose. This is for testing purposes only.</Text> + <SafeButton onPress={() => getCrashlytics().crash()}>Crash App</SafeButton> + </View> + </ScrollView> + </View> + ) +} diff --git a/apps/mobile/src/features/Developer/index.tsx b/apps/mobile/src/features/Developer/index.tsx new file mode 100644 index 0000000000..3ce02ca304 --- /dev/null +++ b/apps/mobile/src/features/Developer/index.tsx @@ -0,0 +1 @@ +export { DeveloperContainer } from './Developer.container' diff --git a/apps/mobile/src/features/Developer/types.ts b/apps/mobile/src/features/Developer/types.ts new file mode 100644 index 0000000000..e1a565e349 --- /dev/null +++ b/apps/mobile/src/features/Developer/types.ts @@ -0,0 +1,21 @@ +export type Info = { + device: { + brand: string + deviceName: string + manufacturer: string + modelId: string + modelName: string + osBuildId: string + osInternalBuildId: string + osName: string + osVersion: string + } + application: { + applicationName: string + applicationId: string + applicationVersion: string + applicationBuildNumber: string + gatewayUrl: string + fcmToken: string + } +} diff --git a/apps/mobile/src/features/GetStarted/GetStarted.tsx b/apps/mobile/src/features/GetStarted/GetStarted.tsx new file mode 100644 index 0000000000..519d9f193a --- /dev/null +++ b/apps/mobile/src/features/GetStarted/GetStarted.tsx @@ -0,0 +1,100 @@ +import React, { useCallback } from 'react' +import { Link, useRouter } from 'expo-router' +import { View, Text, YStack, styled } from 'tamagui' +import { SafeButton } from '@/src/components/SafeButton' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { BlurView } from 'expo-blur' +import { getCrashlytics } from '@react-native-firebase/crashlytics' +import { setAnalyticsCollectionEnabled } from '@/src/services/analytics' +import { isAndroid } from '@/src/config/constants' + +const StyledText = styled(Text, { + fontSize: '$3', + color: '$colorSecondary', +}) + +export const GetStarted = () => { + const router = useRouter() + const insets = useSafeAreaInsets() + + const enableCrashlytics = async () => { + await getCrashlytics().setCrashlyticsCollectionEnabled(true) + await setAnalyticsCollectionEnabled(true) + } + + const onPressAddAccount = useCallback(async () => { + await enableCrashlytics() + router.navigate('/(import-accounts)') + }, []) + + const onPressImportAccount = useCallback(async () => { + await enableCrashlytics() + router.navigate('/import-data') + }, [router]) + + return ( + <YStack justifyContent={'flex-end'} flex={1} testID={'get-started-screen'}> + <BlurView intensity={100} style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}> + <View + flex={1} + onPress={() => { + router.back() + }} + ></View> + </BlurView> + <YStack + gap={'$3'} + paddingHorizontal={'$4'} + backgroundColor={'$background'} + paddingBottom={insets.bottom} + paddingTop={'$5'} + borderTopLeftRadius={'$9'} + borderTopRightRadius={'$9'} + > + <Text + fontSize={'$6'} + fontWeight={'600'} + textAlign={'center'} + marginBottom={'$2'} + paddingHorizontal={'$10'} + lineHeight={'$9'} + > + How would you like to continue? + </Text> + + <SafeButton + outlined + icon={<SafeFontIcon name={'plus-outlined'} />} + testID={'add-account-button'} + onPress={onPressAddAccount} + > + Add account + </SafeButton> + {!isAndroid && ( + <SafeButton outlined icon={<SafeFontIcon name={'upload'} />} onPress={onPressImportAccount}> + Migrate old app + </SafeButton> + )} + <View + paddingHorizontal={'$10'} + marginTop={'$2'} + flexDirection="row" + alignItems="center" + flexWrap="wrap" + justifyContent="center" + > + <StyledText>By continuing, you agree to our </StyledText> + <Link href={'https://app.safe.global/terms'} target={'_blank'} asChild> + <StyledText textDecorationLine={'underline'}>User Terms</StyledText> + </Link> + <StyledText> and </StyledText> + <Link href={'https://app.safe.global/privacy'} target={'_blank'} asChild> + <StyledText textDecorationLine={'underline'}>Privacy Policy</StyledText> + </Link> + <StyledText>.</StyledText> + </View> + </YStack> + </YStack> + ) +} diff --git a/apps/mobile/src/features/GetStarted/index.tsx b/apps/mobile/src/features/GetStarted/index.tsx new file mode 100644 index 0000000000..a5a07a7f13 --- /dev/null +++ b/apps/mobile/src/features/GetStarted/index.tsx @@ -0,0 +1 @@ +export { GetStarted } from './GetStarted' diff --git a/apps/mobile/src/features/ImportPrivateKey/ImportPrivateKey.container.tsx b/apps/mobile/src/features/ImportPrivateKey/ImportPrivateKey.container.tsx new file mode 100644 index 0000000000..4a688b9b5b --- /dev/null +++ b/apps/mobile/src/features/ImportPrivateKey/ImportPrivateKey.container.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { KeyboardAvoidingView, StyleSheet } from 'react-native' +import { Button, View, YStack, ScrollView } from 'tamagui' +import { useScrollableHeader } from '@/src/navigation/useScrollableHeader' +import { NavBarTitle } from '@/src/components/Title' +import { SectionTitle } from '@/src/components/Title' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { SafeButton } from '@/src/components/SafeButton' + +import { SafeInput } from '@/src/components/SafeInput' +import { useImportPrivateKey } from './hooks/useImportPrivateKey' +import { useSafeAreaInsets } from 'react-native-safe-area-context' + +const CUSTOM_VERTICAL_OFFSET = 70 + +export function ImportPrivateKey() { + const { top } = useSafeAreaInsets() + const { handlePrivateKeyChange, handleImport, onPrivateKeyPaste, wallet, privateKey, error } = useImportPrivateKey() + const { handleScroll } = useScrollableHeader({ + children: <NavBarTitle paddingRight={5}>Import a private key</NavBarTitle>, + }) + + return ( + <KeyboardAvoidingView behavior="padding" style={styles.flex1} keyboardVerticalOffset={top + CUSTOM_VERTICAL_OFFSET}> + <ScrollView onScroll={handleScroll} flex={1}> + <View marginTop="$2"> + <SectionTitle + paddingHorizontal={0} + title="Import a private key" + description="Enter your private key below. Make sure to do so in a safe and private place." + /> + </View> + + <YStack gap="$3" marginTop="$6" paddingVertical="$1"> + <View> + <SafeInput + height={114} + value={privateKey} + onChangeText={handlePrivateKeyChange} + placeholder="Paste here or type..." + secureTextEntry={true} + success={!!wallet} + textAlign="center" + error={error} + /> + </View> + + <View alignItems="center"> + <Button + height="$10" + paddingHorizontal="$2" + borderRadius="$3" + backgroundColor="$borderLight" + icon={<SafeFontIcon name="paste" />} + fontWeight="500" + size="$5" + onPress={onPrivateKeyPaste} + > + Paste + </Button> + </View> + </YStack> + </ScrollView> + + <SafeButton onPress={handleImport} testID={'import-signer-button'}> + Import signer + </SafeButton> + </KeyboardAvoidingView> + ) +} + +const styles = StyleSheet.create({ + flex1: { + flex: 1, + }, +}) diff --git a/apps/mobile/src/features/ImportPrivateKey/ImportPrivateKey.test.tsx b/apps/mobile/src/features/ImportPrivateKey/ImportPrivateKey.test.tsx new file mode 100644 index 0000000000..3a4f8a96bd --- /dev/null +++ b/apps/mobile/src/features/ImportPrivateKey/ImportPrivateKey.test.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { render, screen, fireEvent, act, waitFor } from '@/src/tests/test-utils' +import { ImportPrivateKey } from './ImportPrivateKey.container' +import { inputTheme } from '@/src/components/SafeInput/theme' + +describe('ImportPrivateKey', () => { + it('renders the import private key screen', () => { + render(<ImportPrivateKey />) + + expect(screen.getByText('Import a private key')).toBeTruthy() + expect( + screen.getByText('Enter your private key below. Make sure to do so in a safe and private place.'), + ).toBeTruthy() + }) + + it('enables import button when private key is entered', async () => { + render(<ImportPrivateKey />) + + const input = screen.getByPlaceholderText('Paste here or type...') + const button = screen.getByText('Import signer') + + await act(() => fireEvent.press(button)) + + await waitFor(() => { + expect(screen.getByTestId('safe-input').props.style.borderTopColor).toBe( + inputTheme.light_input_error.borderColor.val, + ) + expect(screen.getByTestId('safe-input').props.style.borderBottomColor).toBe( + inputTheme.light_input_error.borderColor.val, + ) + expect(screen.getByTestId('safe-input').props.style.borderLeftColor).toBe( + inputTheme.light_input_error.borderColor.val, + ) + expect(screen.getByTestId('safe-input').props.style.borderRightColor).toBe( + inputTheme.light_input_error.borderColor.val, + ) + }) + + act(() => fireEvent.changeText(input, 'test-private-key')) + + await waitFor(() => { + expect(screen.getByDisplayValue('test-private-key')).toBeTruthy() + }) + }) +}) diff --git a/apps/mobile/src/features/ImportPrivateKey/components/ImportError/ImportError.tsx b/apps/mobile/src/features/ImportPrivateKey/components/ImportError/ImportError.tsx new file mode 100644 index 0000000000..0c2e0423ea --- /dev/null +++ b/apps/mobile/src/features/ImportPrivateKey/components/ImportError/ImportError.tsx @@ -0,0 +1,56 @@ +import { Badge } from '@/src/components/Badge/Badge' +import { Identicon } from '@/src/components/Identicon' +import { SafeButton } from '@/src/components/SafeButton/SafeButton' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { LargeHeaderTitle } from '@/src/components/Title/LargeHeaderTitle' +import { Link, useLocalSearchParams } from 'expo-router' +import React from 'react' +import { ScrollView } from 'react-native' +import { Text, View } from 'tamagui' + +export function ImportError() { + const { address } = useLocalSearchParams<{ address: `0x${string}` }>() + + return ( + <View flex={1} justifyContent="space-between"> + <View flex={1}> + <ScrollView contentContainerStyle={{ flexGrow: 1 }}> + <View flex={1} flexGrow={1} alignItems="center" justifyContent="center" paddingHorizontal="$3"> + <View flexDirection="row" alignItems="center" gap="$3"> + <Identicon address={address} size={64} /> + + <Text fontSize="$4" color="$error"> + . . . . + </Text> + + <Badge + themeName="badge_error" + circleSize={64} + content={<SafeFontIcon size={32} color="$error" name="close-filled" />} + /> + </View> + + <View margin="$10" width="100%" alignItems="center" gap="$4"> + <LargeHeaderTitle textAlign="center">Private key couldn't be imported</LargeHeaderTitle> + + <Text textAlign="center" fontSize="$4"> + This private key does not belong to any signer of this Safe Account. Double-check the address and try to + import again. + </Text> + + <Text textAlign="center" fontSize="$4"> + Don’t worry, your private key was not stored! + </Text> + </View> + </View> + </ScrollView> + </View> + + <View paddingHorizontal="$3" gap="$6"> + <Link href={'../'} asChild> + <SafeButton>Import again</SafeButton> + </Link> + </View> + </View> + ) +} diff --git a/apps/mobile/src/features/ImportPrivateKey/components/ImportError/index.ts b/apps/mobile/src/features/ImportPrivateKey/components/ImportError/index.ts new file mode 100644 index 0000000000..6356c6970e --- /dev/null +++ b/apps/mobile/src/features/ImportPrivateKey/components/ImportError/index.ts @@ -0,0 +1 @@ +export { ImportError } from './ImportError' diff --git a/apps/mobile/src/features/ImportPrivateKey/components/ImportSuccess/ImportSuccess.tsx b/apps/mobile/src/features/ImportPrivateKey/components/ImportSuccess/ImportSuccess.tsx new file mode 100644 index 0000000000..d6311de468 --- /dev/null +++ b/apps/mobile/src/features/ImportPrivateKey/components/ImportSuccess/ImportSuccess.tsx @@ -0,0 +1,85 @@ +import React from 'react' + +import { Badge } from '@/src/components/Badge' +import { SafeButton } from '@/src/components/SafeButton' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { LargeHeaderTitle } from '@/src/components/Title' +import { SignersCard } from '@/src/components/transactions-list/Card/SignersCard' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { ScrollView } from 'react-native' +import { Button, Text, View } from 'tamagui' +import { ToastViewport } from '@tamagui/toast' +import { useCopyAndDispatchToast } from '@/src/hooks/useCopyAndDispatchToast' +import Logger from '@/src/utils/logger' + +export function ImportSuccess() { + const { address, name } = useLocalSearchParams<{ address: `0x${string}`; name: string }>() + const router = useRouter() + const copy = useCopyAndDispatchToast() + + const handleContinuePress = async () => { + try { + router.dismissAll() + router.back() + } catch (error) { + Logger.error('Navigation error:', error) + } + } + + return ( + <View flex={1} justifyContent="space-between" testID={'import-success'}> + <View flex={1}> + <ScrollView contentContainerStyle={{ flexGrow: 1 }}> + <View flex={1} flexGrow={1} alignItems="center" justifyContent="center" paddingHorizontal="$3"> + <Badge + circleProps={{ backgroundColor: '$success' }} + themeName="badge_success" + circleSize={64} + content={<SafeFontIcon size={32} color="$primary" name="check-filled" />} + /> + + <View margin="$10" width="100%" alignItems="center" gap="$4"> + <LargeHeaderTitle textAlign="center">Your signer is ready!</LargeHeaderTitle> + + <Text textAlign="center" fontSize="$4"> + You can now use it to interact with your Safe Account — sign and execute transactions seamlessly. + </Text> + </View> + + <SignersCard + transparent={false} + rightNode={ + <View flex={1} alignItems="flex-end"> + <Button + maxWidth={120} + height="$10" + paddingHorizontal="$2" + borderRadius="$3" + backgroundColor="$borderLight" + fontWeight="500" + size="$5" + onPress={() => { + copy(address) + }} + icon={<SafeFontIcon name="copy" />} + > + Copy + </Button> + </View> + } + name={name} + address={address} + /> + </View> + </ScrollView> + <ToastViewport multipleToasts={false} left={0} right={0} /> + </View> + + <View paddingHorizontal="$3"> + <SafeButton onPress={handleContinuePress} testID={'import-success-continue'}> + Continue + </SafeButton> + </View> + </View> + ) +} diff --git a/apps/mobile/src/features/ImportPrivateKey/components/ImportSuccess/index.ts b/apps/mobile/src/features/ImportPrivateKey/components/ImportSuccess/index.ts new file mode 100644 index 0000000000..58adf94681 --- /dev/null +++ b/apps/mobile/src/features/ImportPrivateKey/components/ImportSuccess/index.ts @@ -0,0 +1 @@ +export { ImportSuccess } from './ImportSuccess' diff --git a/apps/mobile/src/features/ImportPrivateKey/components/LoadingImport/LoadingImport.container.tsx b/apps/mobile/src/features/ImportPrivateKey/components/LoadingImport/LoadingImport.container.tsx new file mode 100644 index 0000000000..e41eed8290 --- /dev/null +++ b/apps/mobile/src/features/ImportPrivateKey/components/LoadingImport/LoadingImport.container.tsx @@ -0,0 +1,135 @@ +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { + SafesGetSafeV1ApiResponse, + SafesGetSafeOverviewV1ApiResponse, + useLazySafesGetSafeV1Query, +} from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { useCallback, useEffect } from 'react' +import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router' +import { addSignerWithEffects } from '@/src/store/signersSlice' +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { useLazySafesGetOverviewForManyQuery } from '@safe-global/store/gateway/safes' +import { selectAllChainsIds } from '@/src/store/chains' +import { makeSafeId } from '@/src/utils/formatters' +import { extractSignersFromSafes } from '@/src/features/ImportReadOnly/helpers/safes' +import { FetchBaseQueryError } from '@reduxjs/toolkit/query' +import { SerializedError } from '@reduxjs/toolkit' +import { LoadingScreen } from '@/src/components/LoadingScreen' +import { selectCurrency } from '@/src/store/settingsSlice' + +const getData = ( + manySafes: SafesGetSafeOverviewV1ApiResponse | undefined, + singleSafe: SafesGetSafeV1ApiResponse | undefined, + isImporting: string | undefined, +) => { + if (isImporting) { + return manySafes || [] + } + + return singleSafe ? [singleSafe] : [] +} + +const getError = ( + singleSafeError: FetchBaseQueryError | SerializedError | undefined, + manySafesError: FetchBaseQueryError | SerializedError | undefined, + isImporting: string | undefined, +) => { + if (isImporting) { + return manySafesError + } + return singleSafeError +} + +export function LoadingImport() { + const glob = useGlobalSearchParams<{ safeAddress?: string; chainId?: string; import_safe?: string }>() + const { address } = useLocalSearchParams() + const chainIds = useAppSelector(selectAllChainsIds) + const dispatch = useAppDispatch() + const router = useRouter() + // we use this screen on the "getting started" and there we don't have an active safe + const activeSafe = useAppSelector(selectActiveSafe) + const currency = useAppSelector(selectCurrency) + let safeAddress = glob.safeAddress + let chainId = glob.chainId + if (activeSafe) { + if (!safeAddress) { + safeAddress = activeSafe.address + } + + if (!chainId) { + chainId = activeSafe.chainId + } + } + + const [singleSafeTrigger, { data: singleSafeData, error: singleSafeError }] = useLazySafesGetSafeV1Query({}) + const [manySafesTrigger, { data: manySafesData, error: manySafesError }] = useLazySafesGetOverviewForManyQuery() + + const data = getData(manySafesData, singleSafeData, glob.import_safe) + const error = getError(singleSafeError, manySafesError, glob.import_safe) + + const redirectToError = useCallback(() => { + router.replace({ + pathname: '/import-signers/private-key-error', + params: { + address, + }, + }) + }, [router]) + + useEffect(() => { + if (glob.import_safe) { + manySafesTrigger( + { + safes: chainIds.map((chainId: string) => makeSafeId(chainId, safeAddress as string)), + currency, + trusted: true, + excludeSpam: true, + }, + true, + ) + } else { + if (safeAddress && chainId) { + singleSafeTrigger( + { + safeAddress, + chainId, + }, + true, + ) + } + } + }, [glob.import_safe, safeAddress, chainId, manySafesTrigger, singleSafeTrigger]) + + useEffect(() => { + if (!address || error) { + redirectToError() + } + }, [address, error, redirectToError]) + + useEffect(() => { + if (!data?.length) { + return + } + + const owner = Object.values(extractSignersFromSafes(data)).find((owner) => owner.value === address) + + if (owner) { + dispatch(addSignerWithEffects(owner)) + + router.replace({ + pathname: '/import-signers/private-key-success', + params: { + name: owner.name, + address: owner.value, + safeAddress: safeAddress, + chainId: chainId, + import_safe: glob.import_safe, + }, + }) + } else { + redirectToError() + } + }, [data, redirectToError]) + + return <LoadingScreen title="Creating your signer..." description="Verifying address..." /> +} diff --git a/apps/mobile/src/features/ImportPrivateKey/components/LoadingImport/index.ts b/apps/mobile/src/features/ImportPrivateKey/components/LoadingImport/index.ts new file mode 100644 index 0000000000..f1be5356a6 --- /dev/null +++ b/apps/mobile/src/features/ImportPrivateKey/components/LoadingImport/index.ts @@ -0,0 +1 @@ +export { LoadingImport } from './LoadingImport.container' diff --git a/apps/mobile/src/features/ImportPrivateKey/hooks/useImportPrivateKey.ts b/apps/mobile/src/features/ImportPrivateKey/hooks/useImportPrivateKey.ts new file mode 100644 index 0000000000..4df3bfd810 --- /dev/null +++ b/apps/mobile/src/features/ImportPrivateKey/hooks/useImportPrivateKey.ts @@ -0,0 +1,73 @@ +import { ethers } from 'ethers' +import { useState } from 'react' +import Clipboard from '@react-native-clipboard/clipboard' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { storePrivateKey } from '@/src/hooks/useSign/useSign' +import useDelegate from '@/src/hooks/useDelegate' +import Logger from '@/src/utils/logger' + +const ERROR_MESSAGE = 'Invalid private key.' +export const useImportPrivateKey = () => { + const [privateKey, setPrivateKey] = useState('') + const [wallet, setWallet] = useState<ethers.Wallet>() + const local = useLocalSearchParams<{ safeAddress: string; chainId: string; import_safe: string }>() + const [error, setError] = useState<string>() + const router = useRouter() + const { createDelegate } = useDelegate() + + const handlePrivateKeyChange = (text: string) => { + setPrivateKey(text) + try { + const wallet = new ethers.Wallet(text) + setWallet(wallet) + setError(undefined) + } catch { + setError(ERROR_MESSAGE) + } + } + + const handleImport = async () => { + if (!wallet) { + return setError(ERROR_MESSAGE) + } + + try { + // Store the private key + await storePrivateKey(wallet.address, privateKey) + + // Create a delegate for this owner + try { + // We don't want to fail the private key import if delegate creation fails + // by passing null as the safe address, we are creating a delegate for the chain and not for the safe + const delegateResult = await createDelegate(privateKey, null) + + if (!delegateResult.success) { + Logger.error('Failed to create delegate during private key import', delegateResult.error) + } + } catch (delegateError) { + // Log the error but continue with the import + Logger.error('Error creating delegate during private key import', delegateError) + } + + // Continue with normal flow + router.push({ + pathname: '/import-signers/loading', + params: { + address: wallet.address, + safeAddress: local.safeAddress, + chainId: local.chainId, + import_safe: local.import_safe, + }, + }) + } catch (err) { + setError((err as Error).message) + } + } + + const onPrivateKeyPaste = async () => { + const text = await Clipboard.getString() + handlePrivateKeyChange(text.trim()) + } + + return { handlePrivateKeyChange, handleImport, onPrivateKeyPaste, privateKey, wallet, error } +} diff --git a/apps/mobile/src/features/ImportPrivateKey/index.ts b/apps/mobile/src/features/ImportPrivateKey/index.ts new file mode 100644 index 0000000000..4878dd59d0 --- /dev/null +++ b/apps/mobile/src/features/ImportPrivateKey/index.ts @@ -0,0 +1,3 @@ +import { ImportPrivateKey } from './ImportPrivateKey.container' + +export { ImportPrivateKey } diff --git a/apps/mobile/src/features/ImportReadOnly/AddSignersForm.container.tsx b/apps/mobile/src/features/ImportReadOnly/AddSignersForm.container.tsx new file mode 100644 index 0000000000..98bbd5326e --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/AddSignersForm.container.tsx @@ -0,0 +1,80 @@ +import { router, useLocalSearchParams } from 'expo-router' +import React, { useMemo } from 'react' +import { makeSafeId } from '@/src/utils/formatters' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { selectAllChainsIds } from '@/src/store/chains' +import { useSafesGetOverviewForManyQuery } from '@safe-global/store/gateway/safes' +import { addSafe } from '@/src/store/safesSlice' +import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice' +import { Address } from '@/src/types/address' +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { groupSigners } from '@/src/features/Signers/hooks/useSignersGroupService' +import { selectSigners } from '@/src/store/signersSlice' +import { SignerSection } from '@/src/features/Signers/components/SignersList/SignersList' +import { extractSignersFromSafes } from '@/src/features/ImportReadOnly/helpers/safes' +import { AddSignersFormView } from '@/src/features/ImportReadOnly/components/AddSignersFormView' +import { upsertContact } from '@/src/store/addressBookSlice' +import { selectCurrency } from '@/src/store/settingsSlice' + +export const AddSignersFormContainer = () => { + const params = useLocalSearchParams<{ safeAddress: string; safeName: string }>() + const dispatch = useAppDispatch() + const chainIds = useAppSelector(selectAllChainsIds) + const appSigners = useAppSelector(selectSigners) + const activeSafe = useAppSelector(selectActiveSafe) + const currency = useAppSelector(selectCurrency) + const { currentData, isFetching } = useSafesGetOverviewForManyQuery({ + safes: chainIds.map((chainId: string) => makeSafeId(chainId, params.safeAddress)), + currency, + trusted: true, + excludeSpam: true, + }) + + const signers = extractSignersFromSafes(currentData || []) + const signersGroupedBySection = useMemo(() => groupSigners(Object.values(signers), appSigners), [signers, appSigners]) + + const signersSections = Object.keys(signersGroupedBySection) + .map((group) => { + return signersGroupedBySection[group].data.length ? signersGroupedBySection[group] : null + }) + .filter(Boolean) as SignerSection[] + + const handlePress = () => { + if (!currentData) { + return + } + const hasActiveSafe = !!activeSafe + dispatch(upsertContact({ value: params.safeAddress, name: params.safeName, chainIds: [] })) + const info = currentData.reduce<Record<string, SafeOverview>>((acc, safe) => { + acc[safe.chainId] = safe + return acc + }, {}) + dispatch(addSafe({ address: currentData[0].address.value as Address, info })) + dispatch( + setActiveSafe({ + address: currentData[0].address.value as Address, + chainId: currentData[0].chainId, + }), + ) + + // Navigates to first screen in stack + router.dismissAll() + // closes first screen in stack + router.back() + if (!hasActiveSafe) { + router.replace('/(tabs)') + } else { + // closes the "my accounts" screen modal + router.back() + } + } + + return ( + <AddSignersFormView + isFetching={isFetching} + signersGroupedBySection={signersGroupedBySection} + signersSections={signersSections} + onPress={handlePress} + /> + ) +} diff --git a/apps/mobile/src/features/ImportReadOnly/ImportAccountForm.container.tsx b/apps/mobile/src/features/ImportReadOnly/ImportAccountForm.container.tsx new file mode 100644 index 0000000000..40ed601992 --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/ImportAccountForm.container.tsx @@ -0,0 +1,43 @@ +import { useLocalSearchParams, useRouter } from 'expo-router' +import React, { useCallback } from 'react' +import { parsePrefixedAddress } from '@safe-global/utils/utils/addresses' +import { ImportAccountFormView } from '@/src/features/ImportReadOnly/components/ImportAccountFormView' +import { FormProvider, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { formSchema } from './schema' +import { FormValues } from './types' + +export const ImportAccountFormContainer = () => { + const router = useRouter() + const params = useLocalSearchParams<{ safeAddress: string }>() + const methods = useForm<FormValues>({ + resolver: zodResolver(formSchema), + mode: 'onChange', + defaultValues: { + name: '', + safeAddress: params.safeAddress || '', + }, + }) + + const addressState = methods.getFieldState('safeAddress') + + const handleContinue = useCallback(() => { + const inputAddress = methods.getValues('safeAddress') + const chainId = methods.getValues('importedSafeResult.data.0.chainId') + const safeName = methods.getValues('name') + const { address } = parsePrefixedAddress(inputAddress) + + router.push( + `/(import-accounts)/signers?safeAddress=${address}&chainId=${chainId}&import_safe=true&safeName=${safeName}`, + ) + }, [router, methods.getValues]) + + return ( + <FormProvider {...methods}> + <ImportAccountFormView + isEnteredAddressValid={addressState.isTouched && !addressState.invalid} + onContinue={handleContinue} + /> + </FormProvider> + ) +} diff --git a/apps/mobile/src/features/ImportReadOnly/NetworkBadge.container.tsx b/apps/mobile/src/features/ImportReadOnly/NetworkBadge.container.tsx new file mode 100644 index 0000000000..1256ed105e --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/NetworkBadge.container.tsx @@ -0,0 +1,16 @@ +import { useAppSelector } from '@/src/store/hooks' +import { selectChainById } from '@/src/store/chains' +import { NetworkBadge } from '@/src/components/NetworkBadge' + +type Props = { + chainId: string + size?: 'small' | 'medium' | 'large' +} +export const NetworkBadgeContainer = ({ chainId }: Props) => { + const chain = useAppSelector((state) => selectChainById(state, chainId)) + if (!chain) { + return null + } + + return <NetworkBadge network={chain} /> +} diff --git a/apps/mobile/src/features/ImportReadOnly/ScanQrAccount.container.tsx b/apps/mobile/src/features/ImportReadOnly/ScanQrAccount.container.tsx new file mode 100644 index 0000000000..1e1113fc65 --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/ScanQrAccount.container.tsx @@ -0,0 +1,32 @@ +import { Camera, useCameraPermission } from 'react-native-vision-camera' +import React, { useCallback } from 'react' +import { useRouter } from 'expo-router' + +import { QrCameraView } from '@/src/features/ImportReadOnly/components/ScanQrAccountView' +import { useScan } from '@/src/features/ImportReadOnly/hooks/useScan' + +export const ScanQrAccountContainer = () => { + const router = useRouter() + const permission = Camera.getCameraPermissionStatus() + const { hasPermission } = useCameraPermission() + const { onScan, isCameraActive, setIsCameraActive } = useScan() + + const onEnterManuallyPress = useCallback(async () => { + router.push(`/(import-accounts)/form`) + }, [router]) + + const handleActivateCamera = useCallback(() => { + setIsCameraActive(true) + }, []) + + return ( + <QrCameraView + permission={permission} + hasPermission={hasPermission} + isCameraActive={isCameraActive} + onScan={onScan} + onActivateCamera={handleActivateCamera} + onEnterManuallyPress={onEnterManuallyPress} + /> + ) +} diff --git a/apps/mobile/src/features/ImportReadOnly/components/AddSignersFormView.tsx b/apps/mobile/src/features/ImportReadOnly/components/AddSignersFormView.tsx new file mode 100644 index 0000000000..cbd8a0f131 --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/components/AddSignersFormView.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { SafeButton } from '@/src/components/SafeButton' +import { SignersList } from '@/src/features/Signers/components/SignersList' +import { type SignerSection } from '@/src/features/Signers/components/SignersList/SignersList' +import { ToastViewport } from '@tamagui/toast' +import { View } from 'tamagui' +import { useSafeAreaInsets } from 'react-native-safe-area-context' + +type AddSignersFormViewProps = { + isFetching: boolean + signersGroupedBySection: Record<string, SignerSection> + signersSections: SignerSection[] + onPress: () => void +} + +export const AddSignersFormView = ({ + isFetching, + signersGroupedBySection, + signersSections, + onPress, +}: AddSignersFormViewProps) => { + const { bottom } = useSafeAreaInsets() + return ( + <> + <SignersList + navbarTitle={'Import your signers to unlock account'} + isFetching={isFetching} + hasLocalSigners={!!signersGroupedBySection.imported?.data.length} + signersGroup={signersSections} + /> + <View paddingHorizontal={'$4'} paddingTop={'$2'} paddingBottom={bottom || 60}> + <SafeButton onPress={onPress} testID={'continue-button'}> + Continue + </SafeButton> + </View> + <ToastViewport multipleToasts={false} left={0} right={0} /> + </> + ) +} diff --git a/apps/mobile/src/features/ImportReadOnly/components/AvailableNetworks.tsx b/apps/mobile/src/features/ImportReadOnly/components/AvailableNetworks.tsx new file mode 100644 index 0000000000..97003e929c --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/components/AvailableNetworks.tsx @@ -0,0 +1,15 @@ +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { Text, XStack, YStack } from 'tamagui' +import { NetworkBadgeContainer } from '@/src/features/ImportReadOnly/NetworkBadge.container' +import React from 'react' + +export const AvailableNetworks = ({ networks }: { networks: SafeOverview[] }) => { + return ( + <YStack marginTop={'$5'} gap={'$1'}> + <Text fontWeight={'600'}>Available on networks:</Text> + <XStack marginTop={'$3'} flexWrap={'wrap'} columnGap={'$1'} rowGap={'$2'}> + {networks?.map((safe) => <NetworkBadgeContainer key={safe.chainId} chainId={safe.chainId} />)} + </XStack> + </YStack> + ) +} diff --git a/apps/mobile/src/features/ImportReadOnly/components/ImportAccountFormView.tsx b/apps/mobile/src/features/ImportReadOnly/components/ImportAccountFormView.tsx new file mode 100644 index 0000000000..258a8f83a4 --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/components/ImportAccountFormView.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import { KeyboardAvoidingView } from 'react-native' +import { LargeHeaderTitle } from '@/src/components/Title/LargeHeaderTitle' +import { SafeInput } from '@/src/components/SafeInput/SafeInput' +import { SafeButton } from '@/src/components/SafeButton' +import { VerificationStatus } from '@/src/features/ImportReadOnly/components/VerificationStatus' +import { View, Text, ScrollView, YStack, getTokenValue } from 'tamagui' +import { useScrollableHeader } from '@/src/navigation/useScrollableHeader' +import { NavBarTitle } from '@/src/components/Title' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { Controller, useFormContext } from 'react-hook-form' +import type { FormValues } from '@/src/features/ImportReadOnly/types' +import SafeAccountInput from '@/src/components/SafeAccountInput' + +type ImportAccountFormViewProps = { + isEnteredAddressValid: boolean + onContinue: () => void +} + +export const ImportAccountFormView: React.FC<ImportAccountFormViewProps> = ({ isEnteredAddressValid, onContinue }) => { + const { + control, + formState: { errors, isValid, dirtyFields }, + watch, + } = useFormContext<FormValues>() + const { top, bottom } = useSafeAreaInsets() + const result = watch('importedSafeResult') + + const { handleScroll } = useScrollableHeader({ + children: <NavBarTitle paddingRight={5}>Import Safe account</NavBarTitle>, + }) + + return ( + <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }} keyboardVerticalOffset={bottom + top}> + <YStack flex={1}> + <ScrollView + paddingBottom={'$4'} + onScroll={handleScroll} + flex={1} + contentContainerStyle={{ paddingBottom: '$4', paddingHorizontal: '$4' }} + > + <LargeHeaderTitle marginBottom={'$4'}>Import Safe account</LargeHeaderTitle> + <Text>Paste the address of an account you want to import.</Text> + <View marginTop={'$4'}> + <Controller + control={control} + name="name" + render={({ field: { onChange, value } }) => { + return ( + <SafeInput + value={value} + onChangeText={onChange} + multiline={true} + autoFocus={true} + placeholder="Enter safe name here" + error={errors.name?.message} + success={dirtyFields.name && !errors.name} + /> + ) + }} + /> + </View> + + <View marginTop={'$4'}> + <SafeAccountInput /> + </View> + + {!errors.safeAddress && ( + <VerificationStatus + isLoading={result?.isFetching} + data={result?.data} + isEnteredAddressValid={isEnteredAddressValid} + /> + )} + </ScrollView> + + <View paddingHorizontal={'$4'} paddingTop={'$2'} paddingBottom={bottom || getTokenValue('$4')}> + <SafeButton + primary + onPress={onContinue} + disabled={!isValid || result?.isFetching || !result?.data?.length} + testID={'continue-button'} + > + Continue + </SafeButton> + </View> + </YStack> + </KeyboardAvoidingView> + ) +} diff --git a/apps/mobile/src/features/ImportReadOnly/components/ScanQrAccountView.tsx b/apps/mobile/src/features/ImportReadOnly/components/ScanQrAccountView.tsx new file mode 100644 index 0000000000..5bf65c08ae --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/components/ScanQrAccountView.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import { Text, View } from 'tamagui' +import { SafeButton } from '@/src/components/SafeButton' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { QrCamera } from '@/src/components/Camera' +import { ToastViewport } from '@tamagui/toast' +import { CameraPermissionStatus, Code } from 'react-native-vision-camera' + +type QrCameraViewProps = { + permission: CameraPermissionStatus + isCameraActive: boolean + onScan: (codes: Code[]) => void + onEnterManuallyPress: () => void + hasPermission: boolean + onActivateCamera: () => void +} + +export const QrCameraView = ({ + permission, + isCameraActive, + onScan, + onEnterManuallyPress, + hasPermission, + onActivateCamera, +}: QrCameraViewProps) => ( + <> + <QrCamera + permission={permission} + hasPermission={hasPermission} + isCameraActive={isCameraActive} + onScan={onScan} + onActivateCamera={onActivateCamera} + heading={permission === 'denied' ? 'Camera access disabled' : 'Scan a QR code'} + footer={ + <> + <Text textAlign={'center'}> + {permission === 'denied' + ? 'Enabling camera will allow you to scan QR codes to import existing Safe Accounts and join new ones with a mobile signer.' + : 'Scan the QR code of the account you want to import. You can find it under Receive or in the sidebar.'} + </Text> + <View alignItems="center" marginTop="$5"> + <SafeButton + secondary + icon={<SafeFontIcon name="copy" size={18} />} + onPress={onEnterManuallyPress} + testID={'enter-manually'} + size="$sm" + > + Enter manually + </SafeButton> + </View> + </> + } + /> + <ToastViewport multipleToasts={false} left={0} right={0} /> + </> +) diff --git a/apps/mobile/src/features/ImportReadOnly/components/VerificationStatus.tsx b/apps/mobile/src/features/ImportReadOnly/components/VerificationStatus.tsx new file mode 100644 index 0000000000..997ab6acf1 --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/components/VerificationStatus.tsx @@ -0,0 +1,31 @@ +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import React from 'react' +import { Text, XStack } from 'tamagui' +import { AvailableNetworks } from '@/src/features/ImportReadOnly/components/AvailableNetworks' +import { Loader } from '@/src/components/Loader' + +type VerificationStatusProps = { + isLoading: boolean + data: SafeOverview[] | undefined + isEnteredAddressValid: boolean +} +export const VerificationStatus: React.FC<VerificationStatusProps> = ({ isLoading, data, isEnteredAddressValid }) => { + if (isLoading) { + return ( + <XStack marginTop={'$5'} gap={'$1'}> + <Loader size={16} /> + <Text marginLeft={'$1'}>Verifying address...</Text> + </XStack> + ) + } + + if (data?.length) { + return <AvailableNetworks networks={data} /> + } + + return ( + <XStack marginTop={'$5'} gap={'$1'}> + {isEnteredAddressValid && <Text color={'$error'}>No Safe deployment found for this this address</Text>} + </XStack> + ) +} diff --git a/apps/mobile/src/features/ImportReadOnly/helpers/safes.test.tsx b/apps/mobile/src/features/ImportReadOnly/helpers/safes.test.tsx new file mode 100644 index 0000000000..9141d867c7 --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/helpers/safes.test.tsx @@ -0,0 +1,56 @@ +import { extractSignersFromSafes, extractChainsFromSafes } from './safes' +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' + +describe('extractSignersFromSafes', () => { + it('should extract signers from safes', () => { + const safes: SafeOverview[] = [ + { + owners: [ + { value: '0x1', name: 'Owner 1' }, + { value: '0x2', name: 'Owner 2' }, + ], + chainId: '1', + address: { value: '0xSafe1' }, + } as SafeOverview, + { + owners: [ + { value: '0x1', name: 'Owner 1' }, + { value: '0x3', name: 'Owner 3' }, + { value: '0x4', name: 'Owner 4' }, + ], + chainId: '2', + address: { value: '0xSafe2' }, + } as SafeOverview, + ] + + const expectedSigners = { + '0x1': { value: '0x1', name: 'Owner 1' }, + '0x2': { value: '0x2', name: 'Owner 2' }, + '0x3': { value: '0x3', name: 'Owner 3' }, + '0x4': { value: '0x4', name: 'Owner 4' }, + } + + expect(extractSignersFromSafes(safes)).toEqual(expectedSigners) + }) +}) + +describe('extractChainsFromSafes', () => { + it('should extract chain IDs from safes', () => { + const safes: SafeOverview[] = [ + { + owners: [{ value: '0x3', name: 'Owner 3' }], + chainId: '1', + address: { value: '0xSafe1' }, + } as SafeOverview, + { + owners: [{ value: '0x3', name: 'Owner 3' }], + chainId: '2', + address: { value: '0xSafe2' }, + } as SafeOverview, + ] + + const expectedChainIds = ['1', '2'] + + expect(extractChainsFromSafes(safes)).toEqual(expectedChainIds) + }) +}) diff --git a/apps/mobile/src/features/ImportReadOnly/helpers/safes.tsx b/apps/mobile/src/features/ImportReadOnly/helpers/safes.tsx new file mode 100644 index 0000000000..5740d5dca7 --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/helpers/safes.tsx @@ -0,0 +1,21 @@ +import { AddressInfo, SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' + +export const extractSignersFromSafes = (safes: { owners: AddressInfo[] }[]): Record<string, AddressInfo> => { + return safes.reduce((acc, safe) => { + const owners = safe.owners + .map((owner) => owner) + .reduce((acc, owner) => { + return { + ...acc, + [owner.value]: owner, + } + }, {}) + return { + ...acc, + ...owners, + } + }, {}) +} +export const extractChainsFromSafes = (safes: SafeOverview[]) => { + return safes.map((safe) => safe.chainId) +} diff --git a/apps/mobile/src/features/ImportReadOnly/hooks/useScan/index.ts b/apps/mobile/src/features/ImportReadOnly/hooks/useScan/index.ts new file mode 100644 index 0000000000..679015427f --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/hooks/useScan/index.ts @@ -0,0 +1,62 @@ +import { Code, useCameraPermission } from 'react-native-vision-camera' +import { useCallback, useRef, useState } from 'react' +import { useRouter } from 'expo-router' +import { useFocusEffect } from '@react-navigation/native' + +import { parsePrefixedAddress } from '@safe-global/utils/utils/addresses' +import { isValidAddress } from '@safe-global/utils/utils/validation' +import { useToastController } from '@tamagui/toast' + +const toastForValueShown: Record<string, boolean> = {} + +export const useScan = () => { + const router = useRouter() + const hasScanned = useRef(false) + const [isCameraActive, setIsCameraActive] = useState(false) + const toast = useToastController() + const { hasPermission } = useCameraPermission() + + const handleFocusEffect = useCallback(() => { + if (!hasPermission) { + return + } + + setIsCameraActive(true) + hasScanned.current = false + + return () => { + setIsCameraActive(false) + } + }, [hasPermission]) + + useFocusEffect(handleFocusEffect) + + const onScan = useCallback( + (codes: Code[]) => { + if (codes.length > 0 && isCameraActive && !hasScanned.current) { + const code = codes[0].value || '' + const { address } = parsePrefixedAddress(code) + + if (isValidAddress(address)) { + hasScanned.current = true + setIsCameraActive(false) + router.push(`/(import-accounts)/form?safeAddress=${address}`) + } else { + // the camera constantly sends us the qr code value, so we would be sending the toast multiple times + // at one point the view was crashing because of this + // not sure what the real cause for that is, but this is a workaround + if (!toastForValueShown[code]) { + toastForValueShown[code] = true + toast.show('Not a valid address', { + native: false, + duration: 2000, + }) + } + } + } + }, + [isCameraActive, router, toast, setIsCameraActive], + ) + + return { onScan, isCameraActive, setIsCameraActive } +} diff --git a/apps/mobile/src/features/ImportReadOnly/hooks/useScan/useScan.test.tsx b/apps/mobile/src/features/ImportReadOnly/hooks/useScan/useScan.test.tsx new file mode 100644 index 0000000000..343de90347 --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/hooks/useScan/useScan.test.tsx @@ -0,0 +1,191 @@ +import { act, renderHook } from '@/src/tests/test-utils' +import { useScan } from './index' +import { Code } from 'react-native-vision-camera' +import { parsePrefixedAddress } from '@safe-global/utils/utils/addresses' +import { isValidAddress } from '@safe-global/utils/utils/validation' + +// Store the focus callback for later testing +let mockFocusCallback: (() => void) | null = null + +// Mock react-native-vision-camera +jest.mock('react-native-vision-camera', () => ({ + Camera: { + getCameraDevice: jest.fn(), + requestCameraPermission: jest.fn(), + }, + useCameraPermission: jest.fn(() => ({ hasPermission: true })), + useCameraDevice: jest.fn(), + useCodeScanner: jest.fn(), +})) + +// Mock React Navigation +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useFocusEffect: jest.fn((callback: () => (() => void) | void) => { + mockFocusCallback = callback + // Don't call the callback immediately - only store it for manual testing + }), +})) + +// Mock the global toastForValueShown object +const mockToastForValueShown: Record<string, boolean> = {} +// @ts-expect-error - intentionally extending global +global.toastForValueShown = mockToastForValueShown + +jest.mock('@safe-global/utils/utils/addresses', () => ({ + parsePrefixedAddress: jest.fn().mockReturnValue({ address: 'mocked-address' }), +})) + +jest.mock('@safe-global/utils/utils/validation', () => ({ + isValidAddress: jest.fn().mockReturnValue(false), +})) + +const mockPush = jest.fn() +jest.mock('expo-router', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +// Mock Toast +const mockShow = jest.fn() +jest.mock('@tamagui/toast', () => ({ + useToastController: () => ({ + show: mockShow, + }), +})) + +describe('useScan', () => { + beforeEach(() => { + jest.clearAllMocks() + + // Clear the toast record + Object.keys(mockToastForValueShown).forEach((key) => { + mockToastForValueShown[key] = false + }) + + // Reset focus callback + mockFocusCallback = null + }) + + it('should initialize with default values', () => { + const { result } = renderHook(() => useScan()) + + expect(result.current.isCameraActive).toBe(false) // Now false by default since focus effect isn't called + expect(typeof result.current.setIsCameraActive).toBe('function') + expect(typeof result.current.onScan).toBe('function') + }) + + describe('Toast handling', () => { + it('should show toast for invalid address and not show duplicate toasts', () => { + const invalidCode = 'invalid-code' + + jest.mocked(parsePrefixedAddress).mockReturnValue({ address: 'invalid-address' }) + jest.mocked(isValidAddress).mockReturnValue(false) + + const { result } = renderHook(() => useScan()) + + // Manually trigger the focus effect to activate camera + if (mockFocusCallback) { + act(() => { + const callback = mockFocusCallback as () => void + callback() + }) + } + + act(() => { + result.current.onScan([{ value: invalidCode } as Code]) + }) + + expect(mockShow).toHaveBeenCalledTimes(1) + expect(mockShow).toHaveBeenCalledWith('Not a valid address', { + native: false, + duration: 2000, + }) + + mockShow.mockClear() + + act(() => { + result.current.onScan([{ value: invalidCode } as Code]) + }) + + expect(mockShow).not.toHaveBeenCalled() + + act(() => { + result.current.onScan([{ value: 'another-invalid-code' } as Code]) + }) + + expect(mockShow).toHaveBeenCalledTimes(1) + }) + }) + + describe('Focus handling', () => { + it('should reset hasScanned when screen gains focus', () => { + const validAddress = '0x1234valid' + jest.mocked(parsePrefixedAddress).mockReturnValue({ address: validAddress }) + jest.mocked(isValidAddress).mockReturnValue(true) + + const { result } = renderHook(() => useScan()) + + // Manually trigger the focus effect to activate camera and reset hasScanned + if (mockFocusCallback) { + act(() => { + const callback = mockFocusCallback as () => void + callback() + }) + } + + // First scan should work (camera is active and hasScanned is false) + act(() => { + result.current.onScan([{ value: `eth:${validAddress}` } as Code]) + }) + + expect(mockPush).toHaveBeenCalledWith(`/(import-accounts)/form?safeAddress=${validAddress}`) + + // Clear mocks + mockPush.mockClear() + + // Second scan should not work (hasScanned is now true) + act(() => { + result.current.onScan([{ value: `eth:${validAddress}` } as Code]) + }) + + expect(mockPush).not.toHaveBeenCalled() + + // Trigger focus effect again to reset hasScanned + if (mockFocusCallback) { + act(() => { + // We've already checked that mockFocusCallback is not null + const callback = mockFocusCallback as () => void + callback() + }) + } + + // Now scanning should work again + act(() => { + result.current.onScan([{ value: `eth:${validAddress}` } as Code]) + }) + + expect(mockPush).toHaveBeenCalledWith(`/(import-accounts)/form?safeAddress=${validAddress}`) + }) + + it('should handle camera permission properly', () => { + // Test with no permission + const mockUseCameraPermission = jest.mocked(require('react-native-vision-camera').useCameraPermission) + mockUseCameraPermission.mockReturnValue({ hasPermission: false }) + + const { result } = renderHook(() => useScan()) + + // Try to trigger focus effect + if (mockFocusCallback) { + act(() => { + const callback = mockFocusCallback as () => void + callback() + }) + } + + // Camera should not be active when there's no permission + expect(result.current.isCameraActive).toBe(false) + }) + }) +}) diff --git a/apps/mobile/src/features/ImportReadOnly/index.tsx b/apps/mobile/src/features/ImportReadOnly/index.tsx new file mode 100644 index 0000000000..effdab9358 --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/index.tsx @@ -0,0 +1,2 @@ +export { ScanQrAccountContainer } from './ScanQrAccount.container' +export { ImportAccountFormContainer } from './ImportAccountForm.container' diff --git a/apps/mobile/src/features/ImportReadOnly/schema.ts b/apps/mobile/src/features/ImportReadOnly/schema.ts new file mode 100644 index 0000000000..7e93e7224c --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/schema.ts @@ -0,0 +1,18 @@ +import { isValidAddress } from '@safe-global/utils/utils/validation' +import { parsePrefixedAddress } from '@safe-global/utils/utils/addresses' +import { z } from 'zod' + +export const formSchema = z.object({ + name: z.string().nonempty('Name is required').max(30, 'Name is too long'), + safeAddress: z + .string() + .nonempty('Safe address is required') + .refine( + (value) => { + return isValidAddress(parsePrefixedAddress(value).address) + }, + { + message: 'Invalid address format', + }, + ), +}) diff --git a/apps/mobile/src/features/ImportReadOnly/types.ts b/apps/mobile/src/features/ImportReadOnly/types.ts new file mode 100644 index 0000000000..9945b073c7 --- /dev/null +++ b/apps/mobile/src/features/ImportReadOnly/types.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' +import { formSchema } from '@/src/features/ImportReadOnly/schema' +import { useLazySafesGetOverviewForManyQuery } from '@safe-global/store/gateway/safes' + +type LazyQueryResult = ReturnType<typeof useLazySafesGetOverviewForManyQuery>[1] + +export type FormValues = z.infer<typeof formSchema> & { + importedSafeResult?: { + data: LazyQueryResult['data'] + isFetching: LazyQueryResult['isFetching'] + error: LazyQueryResult['error'] + } +} diff --git a/apps/mobile/src/features/NetworksSheet/NetworksSheet.container.tsx b/apps/mobile/src/features/NetworksSheet/NetworksSheet.container.tsx new file mode 100644 index 0000000000..1aeabf1fb1 --- /dev/null +++ b/apps/mobile/src/features/NetworksSheet/NetworksSheet.container.tsx @@ -0,0 +1,64 @@ +import { SafeBottomSheet } from '@/src/components/SafeBottomSheet' +import React from 'react' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { RootState } from '@/src/store' +import { selectAllChains, selectChainById } from '@/src/store/chains' +import { switchActiveChain } from '@/src/store/activeSafeSlice' +import { ChainItems } from '../Assets/components/Balance/ChainItems' +import { useSafesGetOverviewForManyQuery } from '@safe-global/store/gateway/safes' +import { SafeOverviewResult } from '@safe-global/store/gateway/types' +import { makeSafeId } from '@/src/utils/formatters' +import { POLLING_INTERVAL } from '@/src/config/constants' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { formatCurrency, formatCurrencyPrecise } from '@safe-global/utils/utils/formatNumber' +import { shouldDisplayPreciseBalance } from '@/src/utils/balance' +import { selectCurrency } from '@/src/store/settingsSlice' + +export const NetworksSheetContainer = () => { + const dispatch = useAppDispatch() + const chains = useAppSelector(selectAllChains) + const activeSafe = useDefinedActiveSafe() + const activeChain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) + const currency = useAppSelector(selectCurrency) + const { data } = useSafesGetOverviewForManyQuery<SafeOverviewResult>( + { + safes: chains.map((chain) => makeSafeId(chain.chainId, activeSafe.address)), + currency: currency.toLowerCase(), + trusted: true, + excludeSpam: true, + }, + { + pollingInterval: POLLING_INTERVAL, + skip: chains.length === 0, + }, + ) + + const handleChainChange = (chainId: string) => { + dispatch(switchActiveChain({ chainId })) + } + + return ( + <SafeBottomSheet + title="Select network" + items={data} + keyExtractor={({ item }) => item.chainId} + renderItem={({ item, onClose }) => ( + <ChainItems + onSelect={(chainId: string) => { + handleChainChange(chainId) + onClose() + }} + activeChain={activeChain} + fiatTotal={ + shouldDisplayPreciseBalance(item.fiatTotal, 8) + ? formatCurrencyPrecise(item.fiatTotal, currency) + : formatCurrency(item.fiatTotal, currency) + } + chains={chains} + chainId={item.chainId} + key={item.chainId} + /> + )} + /> + ) +} diff --git a/apps/mobile/src/features/NetworksSheet/index.tsx b/apps/mobile/src/features/NetworksSheet/index.tsx new file mode 100644 index 0000000000..699a7eafd3 --- /dev/null +++ b/apps/mobile/src/features/NetworksSheet/index.tsx @@ -0,0 +1 @@ +export { NetworksSheetContainer } from './NetworksSheet.container' diff --git a/apps/mobile/src/features/Notifications/Notifications.container.test.tsx b/apps/mobile/src/features/Notifications/Notifications.container.test.tsx deleted file mode 100644 index 294023923d..0000000000 --- a/apps/mobile/src/features/Notifications/Notifications.container.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react' -import { NotificationsContainer } from './Notifications.container' -import { act, fireEvent, render, waitFor } from '@/src/tests/test-utils' -import { SwitchChangeEvent } from 'react-native' - -const mockDispatch = jest.fn() - -jest.mock('@/src/store/hooks', () => ({ - useAppDispatch: () => mockDispatch, - useAppSelector: (selector: (state: unknown) => unknown) => { - if (selector.name === 'selectAppNotificationStatus') { - return true - } - return false - }, -})) - -describe('Notifications Component', () => { - it('renders correctly', () => { - const { getAllByText } = render(<NotificationsContainer />) - expect(getAllByText('Allow notifications')).toHaveLength(1) - }) - - it('triggers notification action on switch change', async () => { - const { getByTestId } = render(<NotificationsContainer />) - const switcher = getByTestId('toggle-app-notifications') - expect(switcher).toBeTruthy() - - act(() => { - fireEvent(switcher, 'onChange', { nativeEvent: { value: true } } as SwitchChangeEvent) - }) - - await waitFor(() => { - expect(mockDispatch).toHaveBeenCalled() - }) - }) -}) diff --git a/apps/mobile/src/features/Notifications/Notifications.container.tsx b/apps/mobile/src/features/Notifications/Notifications.container.tsx deleted file mode 100644 index 2a794f90f5..0000000000 --- a/apps/mobile/src/features/Notifications/Notifications.container.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React, { useCallback } from 'react' -import { Switch } from 'react-native' -import { View, Text } from 'tamagui' - -import { useAppSelector, useAppDispatch } from '@/src/store/hooks' -import { SafeListItem } from '@/src/components/SafeListItem' -import { selectAppNotificationStatus, toggleAppNotifications } from '@/src/store/notificationsSlice' - -export const NotificationsContainer = () => { - const dispatch = useAppDispatch() - const isAppNotificationEnabled = useAppSelector(selectAppNotificationStatus) - - const handleToggleAppNotifications = useCallback(() => { - dispatch(toggleAppNotifications(!isAppNotificationEnabled)) - }, [isAppNotificationEnabled]) - - return ( - <View paddingHorizontal="$4" marginTop="$2" style={{ flex: 1 }}> - <Text fontSize="$8" fontWeight={600} marginBottom="$2"> - Notifications - </Text> - <Text marginBottom="$4"> - Stay up-to-date and get notified about activities in your account, based on your needs. - </Text> - <SafeListItem - label={'Allow notifications'} - rightNode={ - <Switch - testID="toggle-app-notifications" - onChange={handleToggleAppNotifications} - value={isAppNotificationEnabled} - trackColor={{ true: '$primary' }} - /> - } - /> - </View> - ) -} diff --git a/apps/mobile/src/features/Notifications/NotificationsCenter.container.tsx b/apps/mobile/src/features/Notifications/NotificationsCenter.container.tsx new file mode 100644 index 0000000000..47acf3bd1d --- /dev/null +++ b/apps/mobile/src/features/Notifications/NotificationsCenter.container.tsx @@ -0,0 +1,14 @@ +import { COMING_SOON_MESSAGE, COMING_SOON_TITLE } from '@/src/config/constants' +import React from 'react' +import { H3, Text, View } from 'tamagui' + +export const NotificationsCenterContainer = () => { + return ( + <View flex={1} alignItems="center" justifyContent="center"> + <H3 fontWeight={600}>{COMING_SOON_TITLE}</H3> + <Text textAlign="center" color="$colorSecondary" width="70%" fontSize="$4"> + {COMING_SOON_MESSAGE} + </Text> + </View> + ) +} diff --git a/apps/mobile/src/features/Notifications/NotificationsSettings.container.tsx b/apps/mobile/src/features/Notifications/NotificationsSettings.container.tsx new file mode 100644 index 0000000000..4bdea85a60 --- /dev/null +++ b/apps/mobile/src/features/Notifications/NotificationsSettings.container.tsx @@ -0,0 +1,17 @@ +import React from 'react' + +import { NotificationsSettingsView } from '@/src/features/Notifications/components/NotificationsSettingsView' +import { useNotificationManager } from '@/src/hooks/useNotificationManager' +import { useAppSelector } from '@/src/store/hooks' +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { selectSafeSubscriptionStatus } from '@/src/store/safeSubscriptionsSlice' + +export const NotificationsSettingsContainer = () => { + const { toggleNotificationState, isLoading } = useNotificationManager() + const activeSafe = useAppSelector(selectActiveSafe) + const isSubscribed = useAppSelector((state) => + activeSafe ? selectSafeSubscriptionStatus(state, activeSafe.address, activeSafe.chainId) : false, + ) + + return <NotificationsSettingsView onChange={toggleNotificationState} value={!!isSubscribed} isLoading={isLoading} /> +} diff --git a/apps/mobile/src/features/Notifications/components/EmptyBell.tsx b/apps/mobile/src/features/Notifications/components/EmptyBell.tsx new file mode 100644 index 0000000000..7a1d60383f --- /dev/null +++ b/apps/mobile/src/features/Notifications/components/EmptyBell.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import Svg, { Circle, Path } from 'react-native-svg' + +function EmptyBell() { + return ( + <Svg width="81" height="80" viewBox="0 0 81 80" fill="none"> + <Circle cx="30.7586" cy="45.9471" r="24.7586" fill="#1C1C1C" /> + <Path + d="M43.8188 51.3497H36.0698C33.198 51.3497 31.7621 51.3497 31.46 51.1204C31.1206 50.8628 31.0376 50.7107 31.0009 50.2788C30.9682 49.8942 31.8482 48.4134 33.6084 45.4522C35.4257 42.3946 36.9688 37.9428 36.9688 31.6399C36.9688 28.155 38.4114 24.8128 40.9792 22.3486C43.5471 19.8844 47.0298 18.5 50.6612 18.5C54.2927 18.5 57.7753 19.8844 60.3431 22.3486C62.9111 24.8128 64.3536 28.155 64.3536 31.6399C64.3536 37.9428 65.8967 42.3946 67.7142 45.4522C69.4741 48.4134 70.3543 49.8942 70.3216 50.2788C70.2849 50.7107 70.2018 50.8628 69.8625 51.1204C69.5603 51.3497 68.1245 51.3497 65.2527 51.3497H57.5074M43.8188 51.3497L43.815 53.6961C43.815 57.5838 46.8803 60.7353 50.6612 60.7353C54.4424 60.7353 57.5074 57.5838 57.5074 53.6961V51.3497M43.8188 51.3497H57.5074" + stroke="#636669" + strokeWidth="1.33" + strokeLinecap="round" + strokeLinejoin="round" + /> + <Path + d="M3.36 21.844V20.5H11.664V22.228L4.872 30.004V30.22H11.784V31.588H3V29.86L9.792 22.084V21.844H3.36Z" + fill="#636669" + /> + <Path + d="M19.36 8.844V7.5H27.664V9.228L20.872 17.004V17.22H27.784V18.588H19V16.86L25.792 9.084V8.844H19.36Z" + fill="#636669" + /> + <Path + d="M5.36 2.844V1.5H13.664V3.228L6.872 11.004V11.22H13.784V12.588H5V10.86L11.792 3.084V2.844H5.36Z" + fill="#636669" + /> + </Svg> + ) +} + +export default EmptyBell diff --git a/apps/mobile/src/features/Notifications/components/NotificationPermissions.tsx b/apps/mobile/src/features/Notifications/components/NotificationPermissions.tsx new file mode 100644 index 0000000000..967937d95d --- /dev/null +++ b/apps/mobile/src/features/Notifications/components/NotificationPermissions.tsx @@ -0,0 +1,44 @@ +import { Text, View } from 'tamagui' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import React from 'react' +import { Container } from '@/src/components/Container' +import { NOTIFICATION_ACCOUNT_TYPE } from '@/src/store/constants' + +type Props = { + accountType: NOTIFICATION_ACCOUNT_TYPE + isNotificationEnabled: boolean +} +export const NotificationPermissions = ({ accountType, isNotificationEnabled }: Props) => { + const isOwner = accountType === NOTIFICATION_ACCOUNT_TYPE.OWNER + + return ( + isNotificationEnabled && ( + <Container position="relative" paddingHorizontal="$4" marginTop={'$4'}> + <Text marginBottom="$4" fontWeight={400}> + You will receive notifications for: + </Text> + <View flexDirection="row" alignItems="center" gap={8} marginBottom="$4"> + <SafeFontIcon name={'check-filled'} size={18} color="$success" /> + <Text fontWeight={600}>Incoming transactions</Text> + </View> + <View flexDirection="row" alignItems="center" gap={8} marginBottom="$4"> + <SafeFontIcon name={'check-filled'} size={18} color="$success" /> + <Text fontWeight={600}>Outgoing transactions</Text> + </View> + <View flexDirection="row" alignItems="center" gap={8} marginBottom="$4"> + <SafeFontIcon name={'check-filled'} size={18} color={isOwner ? '$success' : '$colorSecondary'} /> + <Text fontWeight={600} color={isOwner ? '$colorPrimmary' : '$colorSecondary'}> + Queued transactions + </Text> + </View> + {!isOwner && ( + <View flexDirection="row" alignItems="center" gap={8} marginBottom="$4"> + <Text fontWeight={400} color={'$colorSecondary'} fontSize="$3"> + You need to import at least one signer to receive transaction requests. + </Text> + </View> + )} + </Container> + ) + ) +} diff --git a/apps/mobile/src/features/Notifications/components/NotificationsScreenEmpty.tsx b/apps/mobile/src/features/Notifications/components/NotificationsScreenEmpty.tsx new file mode 100644 index 0000000000..f1e02558af --- /dev/null +++ b/apps/mobile/src/features/Notifications/components/NotificationsScreenEmpty.tsx @@ -0,0 +1,16 @@ +import React from 'react' + +import { H3, Text, View } from 'tamagui' +import EmptyBell from './EmptyBell' + +export const NotificationsScreenEmpty = () => { + return ( + <View testID="empty-notifications" alignItems="center" gap="$4" marginTop="$6"> + <EmptyBell /> + <H3 fontWeight={600}>All caught up!</H3> + <Text textAlign="center" color="$colorSecondary" width="70%" fontSize="$4"> + Nicely done. You have no pending activity. + </Text> + </View> + ) +} diff --git a/apps/mobile/src/features/Notifications/components/NotificationsSettingsView.tsx b/apps/mobile/src/features/Notifications/components/NotificationsSettingsView.tsx new file mode 100644 index 0000000000..12145954fc --- /dev/null +++ b/apps/mobile/src/features/Notifications/components/NotificationsSettingsView.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { Text, View } from 'tamagui' +import { SafeListItem } from '@/src/components/SafeListItem' +import { NotificationPermissions } from './NotificationPermissions' +import { useNotificationGTWPermissions } from '@/src/hooks/useNotificationGTWPermissions' +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { useAppSelector } from '@/src/store/hooks' +import { LoadableSwitch } from '@/src/components/LoadableSwitch/LoadableSwitch' + +type Props = { + onChange: () => void + value: boolean + isLoading?: boolean +} + +export const NotificationsSettingsView = ({ onChange, value, isLoading = false }: Props) => { + const activeSafe = useAppSelector(selectActiveSafe) + const { getAccountType } = useNotificationGTWPermissions(activeSafe?.address as `0x${string}`, activeSafe?.chainId) + + return ( + <View paddingHorizontal="$4" marginTop="$2" style={{ flex: 1 }} testID={'notifications-popup-screen'}> + <Text fontSize="$8" fontWeight={600} marginBottom="$2"> + Notifications + </Text> + <Text marginBottom="$4"> + Stay up-to-date and get notified about activities in your account, based on your needs. + </Text> + <SafeListItem + label={'Allow notifications'} + rightNode={ + <LoadableSwitch + testID="toggle-app-notifications" + onChange={onChange} + value={value} + isLoading={isLoading} + trackColor={{ true: '$primary' }} + /> + } + /> + + <NotificationPermissions accountType={getAccountType().accountType} isNotificationEnabled={value} /> + </View> + ) +} diff --git a/apps/mobile/src/features/Notifications/index.tsx b/apps/mobile/src/features/Notifications/index.tsx index b5f074338c..6baafc0f66 100644 --- a/apps/mobile/src/features/Notifications/index.tsx +++ b/apps/mobile/src/features/Notifications/index.tsx @@ -1,2 +1,4 @@ -import { NotificationsContainer } from './Notifications.container' -export { NotificationsContainer } +import { NotificationsSettingsContainer } from './NotificationsSettings.container' +import { NotificationsCenterContainer } from './NotificationsCenter.container' + +export { NotificationsSettingsContainer, NotificationsCenterContainer } diff --git a/apps/mobile/src/features/Onboarding/Onboarding.container.test.tsx b/apps/mobile/src/features/Onboarding/Onboarding.container.test.tsx index 0c4fe03678..012d98c85a 100644 --- a/apps/mobile/src/features/Onboarding/Onboarding.container.test.tsx +++ b/apps/mobile/src/features/Onboarding/Onboarding.container.test.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { Onboarding } from './Onboarding.container' import { fireEvent, render } from '@/src/tests/test-utils' +import { Onboarding } from './Onboarding.container' const mockNavigate = jest.fn() @@ -21,6 +21,6 @@ describe('Onboarding Component', () => { const button = getByText('Get started') fireEvent.press(button) - expect(mockNavigate).toHaveBeenCalledWith('/(tabs)') + expect(mockNavigate).toHaveBeenCalledWith('/get-started') }) }) diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.test.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.test.tsx index b96f4c9696..56d96abe62 100644 --- a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.test.tsx +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.test.tsx @@ -1,21 +1,20 @@ import React from 'react' import { CarouselFeedback } from './CarouselFeedback' import { render } from '@/src/tests/test-utils' -import darkPalette from '@/src/theme/palettes/darkPalette' +import { getTokenValue } from 'tamagui' describe('CarouselFeedback', () => { it('renders with active state', () => { const { getByTestId } = render(<CarouselFeedback isActive={true} />) - const carouselFeedback = getByTestId('carousel-feedback') - expect(carouselFeedback.props.style.backgroundColor).toBe(darkPalette.background.default) + expect(carouselFeedback.props.style[0]).toHaveProperty('backgroundColor', getTokenValue('$color.textContrastDark')) }) it('renders with inactive state', () => { const { getByTestId } = render(<CarouselFeedback isActive={false} />) const carouselFeedback = getByTestId('carousel-feedback') - expect(carouselFeedback.props.style.backgroundColor).toBe(darkPalette.primary.light) + expect(carouselFeedback.props.style[0]).toHaveProperty('backgroundColor', getTokenValue('$color.primaryLightDark')) }) }) diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.tsx index b3f4491dff..301a898326 100644 --- a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.tsx +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselFeedback.tsx @@ -1,17 +1,16 @@ import React, { useEffect } from 'react' import Animated, { useSharedValue, withSpring } from 'react-native-reanimated' -import { useTheme } from 'tamagui' +import { getTokenValue } from 'tamagui' interface CarouselFeedbackProps { isActive: boolean } const UNACTIVE_WIDTH = 4 -const ACTIVE_WIDTH = 14 +const ACTIVE_WIDTH = 24 export function CarouselFeedback({ isActive }: CarouselFeedbackProps) { const width = useSharedValue(UNACTIVE_WIDTH) - const theme = useTheme() useEffect(() => { if (isActive) { @@ -26,7 +25,7 @@ export function CarouselFeedback({ isActive }: CarouselFeedbackProps) { testID="carousel-feedback" style={{ borderRadius: 50, - backgroundColor: isActive ? theme.color.get() : theme.colorSecondary.get(), + backgroundColor: isActive ? getTokenValue('$color.textContrastDark') : getTokenValue('$color.primaryLightDark'), height: UNACTIVE_WIDTH, width, }} diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselItem.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselItem.tsx index bc596a35c2..6c74606d6b 100644 --- a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselItem.tsx +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/CarouselItem.tsx @@ -1,4 +1,6 @@ -import { Text, View, YStack } from 'tamagui' +import { getTokenValue, Text, View, YStack } from 'tamagui' +import React from 'react' +import { Dimensions } from 'react-native' export type CarouselItem = { title: string | React.ReactNode @@ -8,18 +10,25 @@ export type CarouselItem = { imagePosition?: 'top' | 'bottom' } +const windowHeight = Dimensions.get('window').height +const maxGoodHeight = 812 interface CarouselItemProps { item: CarouselItem + testID?: string } -export const CarouselItem = ({ item: { title, description, image, imagePosition = 'top' } }: CarouselItemProps) => { +export const CarouselItem = ({ + item: { title, description, image, imagePosition = 'top' }, + testID, +}: CarouselItemProps) => { + const gap = windowHeight <= maxGoodHeight ? '$4' : '$8' return ( - <View gap="$8" alignItems="center" justifyContent="center"> + <View gap={gap} alignItems="center" testID={testID} flex={1}> {imagePosition === 'top' && image} - <YStack gap="$8" paddingHorizontal="$5"> + <YStack gap={gap} paddingHorizontal="$5" flex={1}> <YStack>{title}</YStack> - <Text textAlign="center" fontSize={'$4'}> + <Text textAlign="center" maxWidth={331} fontSize={'$5'} color={getTokenValue('$color.textContrastDark')}> {description} </Text> </YStack> diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.stories.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.native.stories.tsx similarity index 100% rename from apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.stories.tsx rename to apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.native.stories.tsx diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.test.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.test.tsx index d12d74f6ab..c1865c36b6 100644 --- a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.test.tsx +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.test.tsx @@ -1,7 +1,7 @@ import React from 'react' +import { render } from '@/src/tests/test-utils' import { OnboardingCarousel } from './OnboardingCarousel' import { Text } from 'tamagui' -import { render } from '@/src/tests/test-utils' describe('OnboardingCarousel', () => { const items = [ diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.tsx index df34dae260..2dd2116e56 100644 --- a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.tsx +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.tsx @@ -1,45 +1,75 @@ import React, { useState } from 'react' import { CarouselItem } from './CarouselItem' -import { View } from 'tamagui' +import { getTokenValue, View } from 'tamagui' import { SafeButton } from '@/src/components/SafeButton' import { Tabs } from 'react-native-collapsible-tab-view' import { CarouselFeedback } from './CarouselFeedback' import { useRouter } from 'expo-router' +import { useAppDispatch } from '@/src/store/hooks' +import { updateSettings } from '@/src/store/settingsSlice' +import { ONBOARDING_VERSION } from '@/src/config/constants' +import { useSafeAreaInsets } from 'react-native-safe-area-context' + interface OnboardingCarouselProps { items: CarouselItem[] } export function OnboardingCarousel({ items }: OnboardingCarouselProps) { const [activeTab, setActiveTab] = useState(items[0].name) + const dispatch = useAppDispatch() const router = useRouter() + const insets = useSafeAreaInsets() + const onGetStartedPress = () => { - router.navigate('/(tabs)') + dispatch(updateSettings({ onboardingVersionSeen: ONBOARDING_VERSION })) + router.navigate('/get-started') } return ( - <View testID="carrousel" flex={1} justifyContent={'space-between'} position="relative" paddingVertical="$10"> - <Tabs.Container - onTabChange={(event) => setActiveTab(event.tabName)} - initialTabName={items[0].name} - renderTabBar={() => <></>} + <View backgroundColor={getTokenValue('$color.textContrastDark')} flex={1}> + <View + testID="carrousel" + flex={1} + justifyContent={'space-between'} + position="relative" + marginBottom={insets.bottom} + backgroundColor={'white'} + borderBottomLeftRadius="$6" + borderBottomRightRadius="$6" + paddingBottom={'$4'} + paddingTop={'$4'} > - {items.map((item, index) => ( - <Tabs.Tab name={item.name} key={`${item.name}-${index}`}> - <CarouselItem key={index} item={item} /> - </Tabs.Tab> - ))} - </Tabs.Container> - - <View paddingHorizontal={20}> - <View gap="$1" flexDirection="row" alignItems="center" justifyContent="center" marginBottom="$6"> - {items.map((item) => ( - <CarouselFeedback key={item.name} isActive={activeTab === item.name} /> - ))} + <View flex={1}> + <Tabs.Container + onTabChange={(event) => setActiveTab(event.tabName)} + initialTabName={items[0].name} + renderTabBar={() => <></>} + > + {items.map((item, index) => ( + <Tabs.Tab name={item.name} key={`${item.name}-${index}`}> + <CarouselItem key={index} item={item} testID={'carousel-item-' + index} /> + </Tabs.Tab> + ))} + </Tabs.Container> </View> - <View style={{ flexDirection: 'column', justifyContent: 'space-between' }}> - <SafeButton onPress={onGetStartedPress} label="Get started" /> + <View paddingHorizontal={'$5'}> + <View gap="$1" flexDirection="row" alignItems="center" justifyContent="center" marginBottom="$6"> + {items.map((item) => ( + <CarouselFeedback key={item.name} isActive={activeTab === item.name} /> + ))} + </View> + <View style={{ flexDirection: 'column', justifyContent: 'space-between' }}> + <SafeButton + onPress={onGetStartedPress} + testID={'get-started'} + backgroundColor={getTokenValue('$color.textContrastDark')} + color={getTokenValue('$color.textPrimaryDark')} + > + Get started + </SafeButton> + </View> </View> </View> </View> diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/items.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/items.tsx index 55fc6d3ec5..5607c475a6 100644 --- a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/items.tsx +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/items.tsx @@ -1,13 +1,15 @@ import { Dimensions, StyleSheet } from 'react-native' -import { H1, Image, View } from 'tamagui' -import Signing from '@/assets/images/illustration.png' +import { getTokenValue, H1, Image, View } from 'tamagui' +import Signing from '@/assets/images/select-signer.png' +import PersonalisedUpdates from '@/assets/images/personalised-updates.png' import TrackAnywhere from '@/assets/images/anywhere.png' import { CarouselItem } from './CarouselItem' -import { ParticlesLogo } from '../ParticlesLogo' +import React from 'react' const windowHeight = Dimensions.get('window').height - +const windowWidth = Dimensions.get('window').width +const maxGoodWidth = 375 const styles = StyleSheet.create({ image: { width: '100%', @@ -24,82 +26,74 @@ const styles = StyleSheet.create({ textContainer: { textAlign: 'center', flexDirection: 'column', + letterSpacing: -0.1, + color: getTokenValue('$color.textContrastDark'), }, }) export const items: CarouselItem[] = [ { - name: 'multisig', + name: 'tracking', image: ( - <View justifyContent="flex-end" height={Math.abs(windowHeight * 0.35)}> - <ParticlesLogo /> + <View height={300} width={'100%'}> + <Image style={[styles.image, styles.anywhere]} source={TrackAnywhere} /> </View> ), title: ( <> <H1 style={styles.textContainer} fontWeight={600}> - Your main - </H1> - <H1 style={styles.textContainer} fontWeight={600}> - <H1 fontWeight={600} color="$primary"> - Safe - </H1>{' '} - multisig + Track your </H1> <H1 style={styles.textContainer} fontWeight={600}> - companion - </H1> - </> - ), - }, - { - name: 'tracking', - image: <Image style={[styles.image, styles.anywhere]} source={TrackAnywhere} />, - title: ( - <> - <H1 style={styles.textContainer} fontWeight={600}> - Track - </H1> - <H1 style={styles.textContainer} fontWeight={600}> - everything. + accounts. </H1> <H1 style={styles.textContainer} fontWeight={600} color="$primary"> Anywhere. </H1> </> ), - description: 'Quickly check your asset balances and portfolio performance anytime, anywhere.', + description: 'Easily track balances and get real-time updates on account activity — anytime.', }, { name: 'signing', - image: <Image style={[styles.image, styles.signing]} source={Signing} />, + image: ( + <View height={300} width={'100%'}> + <Image style={[styles.image, styles.signing]} source={Signing} /> + </View> + ), title: ( <> - <H1 style={styles.textContainer} fontWeight={600}> - Seamless + <H1 style={styles.textContainer} fontWeight={600} marginHorizontal={windowWidth <= maxGoodWidth ? -10 : 0}> + Sign transactions </H1> + <H1 style={styles.textContainer} fontWeight={600}> - signing + on the go </H1> </> ), - description: - 'Sign and execute transactions securely from your mobile device. Ensuring your assets are protected, even on the move.', + description: 'Enjoy peace of mind with transaction checks, ensuring secure signing.', }, { name: 'update-to-date', - image: <View height={Math.abs(windowHeight * 0.32)} />, + image: ( + <View height={300} width={'100%'}> + <Image style={[styles.image, styles.signing]} source={PersonalisedUpdates} /> + </View> + ), title: ( <> <H1 style={styles.textContainer} fontWeight={600}> - Stay + Get + </H1> + <H1 style={styles.textContainer} fontWeight={600}> + personalized </H1> <H1 style={styles.textContainer} fontWeight={600}> - up-to-date + updates </H1> </> ), - description: - 'Sign and execute transactions securely from your mobile device. Ensuring your assets are protected, even on the move.', + description: 'Stay informed with personalized notifications tailored to your accounts.', }, ] diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.test.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.test.tsx index 99232471d4..0c47dc3e7d 100644 --- a/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.test.tsx +++ b/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.test.tsx @@ -1,10 +1,11 @@ import React from 'react' import { OnboardingHeader } from './OnboardingHeader' -import { render } from '@testing-library/react-native' +import { render } from '@/src/tests/test-utils' +import { screen } from '@testing-library/react-native' test('renders the OnboardingHeader component with the Safe Wallet image', () => { - const { getByLabelText } = render(<OnboardingHeader />) + render(<OnboardingHeader />) - const image = getByLabelText(/Safe Wallet/i) - expect(image).toBeTruthy() + const safeWalletLogo = screen.getByTestId('safe-wallet-logo') + expect(safeWalletLogo).toBeTruthy() }) diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.tsx index 3a9b18e45e..22a2d4eed2 100644 --- a/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.tsx +++ b/apps/mobile/src/features/Onboarding/components/OnboardingHeader/OnboardingHeader.tsx @@ -1,16 +1,22 @@ import React from 'react' -import { Image, styled } from 'tamagui' -import SafeWalletLogo from '@/assets/images/safe-wallet.png' -import { SafeAreaView } from 'react-native' - -export const StyledSafeAreaView = styled(SafeAreaView, { - alignItems: 'center', -}) +import { getTokenValue, View } from 'tamagui' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { SafeWalletLogo } from '@/src/components/SVGs/SafeWalletLogo' export function OnboardingHeader() { + const insets = useSafeAreaInsets() + return ( - <StyledSafeAreaView> - <Image accessibilityLabel="Safe Wallet" source={SafeWalletLogo} /> - </StyledSafeAreaView> + <View paddingTop={insets.top} backgroundColor={getTokenValue('$color.textContrastDark')}> + <View + alignItems={'center'} + backgroundColor={getTokenValue('$color.textPrimaryDark')} + borderTopLeftRadius={'$6'} + borderTopRightRadius={'$6'} + paddingVertical={'$6'} + > + <SafeWalletLogo testID="safe-wallet-logo" /> + </View> + </View> ) } diff --git a/apps/mobile/src/features/Onboarding/components/ParticlesLogo/ParticlesLogo.test.tsx b/apps/mobile/src/features/Onboarding/components/ParticlesLogo/ParticlesLogo.test.tsx deleted file mode 100644 index 861b813bbc..0000000000 --- a/apps/mobile/src/features/Onboarding/components/ParticlesLogo/ParticlesLogo.test.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { render } from '@/src/tests/test-utils' -import { ParticlesLogo } from './ParticlesLogo' - -describe('ParticlesLogo', () => { - it('should render default markup', () => { - const container = render(<ParticlesLogo />) - - expect(container).toMatchSnapshot() - }) -}) diff --git a/apps/mobile/src/features/Onboarding/components/ParticlesLogo/ParticlesLogo.tsx b/apps/mobile/src/features/Onboarding/components/ParticlesLogo/ParticlesLogo.tsx deleted file mode 100644 index b344654ab7..0000000000 --- a/apps/mobile/src/features/Onboarding/components/ParticlesLogo/ParticlesLogo.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react' -import Svg, { Path } from 'react-native-svg' - -export function ParticlesLogo() { - return ( - <Svg width="181" height="180" viewBox="0 0 181 180" fill="none"> - <Path - d="M33.8342 46.3745H24.0899C22.8299 46.3745 21.8084 47.396 21.8084 48.656V58.4003C21.8084 59.6604 22.8299 60.6818 24.0899 60.6818H33.8342C35.0942 60.6818 36.1157 59.6604 36.1157 58.4003V48.656C36.1157 47.396 35.0942 46.3745 33.8342 46.3745Z" - fill="#12FF80" - /> - <Path - d="M40.0865 84.7895C41.288 84.7895 42.4895 84.7895 43.6919 84.7895C44.9519 84.7895 45.9734 83.768 45.9734 82.508V65.3846C45.9734 64.1246 44.9519 63.1031 43.6919 63.1031H26.5685C25.3085 63.1031 24.287 64.1246 24.287 65.3846V94.2242C24.287 95.4842 25.3085 96.5057 26.5685 96.5057H35.5244C36.7844 96.5057 37.8059 95.4842 37.8059 94.2242V87.071C37.8059 85.811 38.8274 84.7895 40.0874 84.7895H40.0865Z" - fill="#12FF80" - /> - <Path - d="M112.749 141.736H101.54C99.8136 141.736 98.4138 143.136 98.4138 144.863V156.072C98.4138 157.798 99.8136 159.198 101.54 159.198H112.749C114.476 159.198 115.876 157.798 115.876 156.072V144.863C115.876 143.136 114.476 141.736 112.749 141.736Z" - fill="#12FF80" - /> - <Path - d="M129.479 130.698H120.298C118.571 130.698 117.172 132.097 117.172 133.824V143.005C117.172 144.732 118.571 146.132 120.298 146.132H129.479C131.206 146.132 132.606 144.732 132.606 143.005V133.824C132.606 132.097 131.206 130.698 129.479 130.698Z" - fill="#12FF80" - /> - <Path - d="M145.447 128.725H139.562C137.835 128.725 136.435 130.125 136.435 131.852V137.737C136.435 139.464 137.835 140.863 139.562 140.863H145.447C147.174 140.863 148.573 139.464 148.573 137.737V131.852C148.573 130.125 147.174 128.725 145.447 128.725Z" - fill="#12FF80" - /> - <Path - d="M82.6709 140.864H70.7999C70.0925 140.864 69.5183 141.437 69.5183 142.145V154.016C69.5183 154.724 70.0916 155.298 70.7999 155.298H82.6709C83.3783 155.298 83.9525 154.725 83.9525 154.016V152.932C83.9525 152.224 84.5258 151.65 85.2341 151.65H89.9231C90.6305 151.65 91.2047 151.077 91.2047 150.369V144.708C91.2047 144 90.6314 143.426 89.9231 143.426H85.2341C84.5267 143.426 83.9525 142.853 83.9525 142.144C83.9525 141.437 83.3792 140.863 82.6709 140.863V140.864Z" - fill="#12FF80" - /> - <Path - d="M51.3807 116.276H49.9722C48.6654 116.276 47.6061 115.217 47.6061 113.91V109.404C47.6061 108.097 46.5468 107.038 45.24 107.038H36.7341C35.4273 107.038 34.368 108.097 34.368 109.404V113.91C34.368 115.217 33.3087 116.276 32.0019 116.276H30.9876C29.6808 116.276 28.6215 117.336 28.6215 118.642V139.033C28.6215 140.34 29.6808 141.399 30.9876 141.399H45.2382C46.545 141.399 47.6043 142.458 47.6043 143.765C47.6043 145.072 48.6636 146.131 49.9704 146.131H60.6723C61.9791 146.131 63.0384 145.072 63.0384 143.765V133.063C63.0384 131.756 61.9791 130.697 60.6723 130.697H56.1102C54.8034 130.697 53.7441 129.638 53.7441 128.331V118.642C53.7441 117.336 52.6848 116.276 51.378 116.276H51.3807Z" - fill="#12FF80" - /> - <Path - d="M159.192 86.1976V59.9491C159.192 58.9222 158.359 58.0906 157.333 58.0906H143.477C142.45 58.0906 141.618 57.2581 141.618 56.2321V47.8396C141.618 46.8127 140.786 45.9811 139.76 45.9811H136.099C135.072 45.9811 134.24 45.1486 134.24 44.1226V33.139C134.24 32.1121 133.408 31.2805 132.382 31.2805H118.863C117.836 31.2805 117.004 30.448 117.004 29.422V28.2394C117.004 27.2125 116.172 26.3809 115.146 26.3809H102.247C101.22 26.3809 100.388 25.5484 100.388 24.5224V22.6639C100.388 21.637 99.5558 20.8054 98.5298 20.8054H79.0403C78.0134 20.8054 77.1818 21.6379 77.1818 22.6639V42.0967C77.1818 43.1236 78.0143 43.9552 79.0403 43.9552H92.3333C93.3602 43.9552 94.1918 44.7877 94.1918 45.8137V47.3347C94.1918 48.3616 95.0243 49.1932 96.0503 49.1932H109.513C110.539 49.1932 111.371 50.0257 111.371 51.0517V52.291C111.371 53.3179 112.204 54.1495 113.23 54.1495H123.594C124.621 54.1495 125.452 54.982 125.452 56.008V60.2893C125.452 61.3162 126.285 62.1478 127.311 62.1478H129.029C130.056 62.1478 130.888 62.9803 130.888 64.0063V86.1994C130.888 87.2263 131.72 88.0579 132.746 88.0579H140.998C142.025 88.0579 142.857 88.8904 142.857 89.9164V92.2825C142.857 93.3094 142.024 94.141 140.998 94.141H138.858C137.831 94.141 136.999 94.9735 136.999 95.9995V107.209C136.999 108.236 136.167 109.067 135.141 109.067C134.114 109.067 133.282 109.9 133.282 110.926V123.149C133.282 124.176 134.115 125.007 135.141 125.007H147.364C148.391 125.007 149.222 124.175 149.222 123.149V117.234C149.222 116.207 150.055 115.376 151.081 115.376H156.376C157.402 115.376 158.234 114.543 158.234 113.517V95.9995C158.234 94.9726 157.402 94.141 156.376 94.141H155.418C154.391 94.141 153.559 93.3085 153.559 92.2825V89.9164C153.559 88.8895 154.392 88.0579 155.418 88.0579H157.333C158.36 88.0579 159.192 87.2254 159.192 86.1994V86.1976Z" - fill="#12FF80" - /> - <Path - d="M62.6459 31.2787H55.7457C54.4857 31.2787 53.4641 32.3002 53.4641 33.5602C53.4641 34.8202 52.4426 35.8417 51.1826 35.8417H41.5508C40.2908 35.8417 39.2693 36.8632 39.2693 38.1232V55.9792C39.2693 57.2392 40.2908 58.2607 41.5508 58.2607H59.4069C60.6668 58.2607 61.6883 57.2392 61.6883 55.9792V51.9238C61.6883 50.6638 62.7098 49.6423 63.9698 49.6423H69.5462C70.8062 49.6423 71.8278 48.6208 71.8278 47.3608V33.5602C71.8278 32.3002 70.8062 31.2787 69.5462 31.2787H62.6459Z" - fill="#12FF80" - /> - </Svg> - ) -} diff --git a/apps/mobile/src/features/Onboarding/components/ParticlesLogo/__snapshots__/ParticlesLogo.test.tsx.snap b/apps/mobile/src/features/Onboarding/components/ParticlesLogo/__snapshots__/ParticlesLogo.test.tsx.snap deleted file mode 100644 index fb8cbe4522..0000000000 --- a/apps/mobile/src/features/Onboarding/components/ParticlesLogo/__snapshots__/ParticlesLogo.test.tsx.snap +++ /dev/null @@ -1,169 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ParticlesLogo should render default markup 1`] = ` -<View> - <RNSVGSvgView - align="xMidYMid" - bbHeight="180" - bbWidth="181" - fill="none" - focusable={false} - height="180" - meetOrSlice={0} - minX={0} - minY={0} - style={ - [ - { - "backgroundColor": "transparent", - "borderWidth": 0, - }, - { - "flex": 0, - "height": 180, - "width": 181, - }, - ] - } - vbHeight={180} - vbWidth={181} - width="181" - > - <RNSVGGroup - fill={null} - propList={ - [ - "fill", - ] - } - > - <RNSVGPath - d="M33.8342 46.3745H24.0899C22.8299 46.3745 21.8084 47.396 21.8084 48.656V58.4003C21.8084 59.6604 22.8299 60.6818 24.0899 60.6818H33.8342C35.0942 60.6818 36.1157 59.6604 36.1157 58.4003V48.656C36.1157 47.396 35.0942 46.3745 33.8342 46.3745Z" - fill={ - { - "payload": 4279435136, - "type": 0, - } - } - propList={ - [ - "fill", - ] - } - /> - <RNSVGPath - d="M40.0865 84.7895C41.288 84.7895 42.4895 84.7895 43.6919 84.7895C44.9519 84.7895 45.9734 83.768 45.9734 82.508V65.3846C45.9734 64.1246 44.9519 63.1031 43.6919 63.1031H26.5685C25.3085 63.1031 24.287 64.1246 24.287 65.3846V94.2242C24.287 95.4842 25.3085 96.5057 26.5685 96.5057H35.5244C36.7844 96.5057 37.8059 95.4842 37.8059 94.2242V87.071C37.8059 85.811 38.8274 84.7895 40.0874 84.7895H40.0865Z" - fill={ - { - "payload": 4279435136, - "type": 0, - } - } - propList={ - [ - "fill", - ] - } - /> - <RNSVGPath - d="M112.749 141.736H101.54C99.8136 141.736 98.4138 143.136 98.4138 144.863V156.072C98.4138 157.798 99.8136 159.198 101.54 159.198H112.749C114.476 159.198 115.876 157.798 115.876 156.072V144.863C115.876 143.136 114.476 141.736 112.749 141.736Z" - fill={ - { - "payload": 4279435136, - "type": 0, - } - } - propList={ - [ - "fill", - ] - } - /> - <RNSVGPath - d="M129.479 130.698H120.298C118.571 130.698 117.172 132.097 117.172 133.824V143.005C117.172 144.732 118.571 146.132 120.298 146.132H129.479C131.206 146.132 132.606 144.732 132.606 143.005V133.824C132.606 132.097 131.206 130.698 129.479 130.698Z" - fill={ - { - "payload": 4279435136, - "type": 0, - } - } - propList={ - [ - "fill", - ] - } - /> - <RNSVGPath - d="M145.447 128.725H139.562C137.835 128.725 136.435 130.125 136.435 131.852V137.737C136.435 139.464 137.835 140.863 139.562 140.863H145.447C147.174 140.863 148.573 139.464 148.573 137.737V131.852C148.573 130.125 147.174 128.725 145.447 128.725Z" - fill={ - { - "payload": 4279435136, - "type": 0, - } - } - propList={ - [ - "fill", - ] - } - /> - <RNSVGPath - d="M82.6709 140.864H70.7999C70.0925 140.864 69.5183 141.437 69.5183 142.145V154.016C69.5183 154.724 70.0916 155.298 70.7999 155.298H82.6709C83.3783 155.298 83.9525 154.725 83.9525 154.016V152.932C83.9525 152.224 84.5258 151.65 85.2341 151.65H89.9231C90.6305 151.65 91.2047 151.077 91.2047 150.369V144.708C91.2047 144 90.6314 143.426 89.9231 143.426H85.2341C84.5267 143.426 83.9525 142.853 83.9525 142.144C83.9525 141.437 83.3792 140.863 82.6709 140.863V140.864Z" - fill={ - { - "payload": 4279435136, - "type": 0, - } - } - propList={ - [ - "fill", - ] - } - /> - <RNSVGPath - d="M51.3807 116.276H49.9722C48.6654 116.276 47.6061 115.217 47.6061 113.91V109.404C47.6061 108.097 46.5468 107.038 45.24 107.038H36.7341C35.4273 107.038 34.368 108.097 34.368 109.404V113.91C34.368 115.217 33.3087 116.276 32.0019 116.276H30.9876C29.6808 116.276 28.6215 117.336 28.6215 118.642V139.033C28.6215 140.34 29.6808 141.399 30.9876 141.399H45.2382C46.545 141.399 47.6043 142.458 47.6043 143.765C47.6043 145.072 48.6636 146.131 49.9704 146.131H60.6723C61.9791 146.131 63.0384 145.072 63.0384 143.765V133.063C63.0384 131.756 61.9791 130.697 60.6723 130.697H56.1102C54.8034 130.697 53.7441 129.638 53.7441 128.331V118.642C53.7441 117.336 52.6848 116.276 51.378 116.276H51.3807Z" - fill={ - { - "payload": 4279435136, - "type": 0, - } - } - propList={ - [ - "fill", - ] - } - /> - <RNSVGPath - d="M159.192 86.1976V59.9491C159.192 58.9222 158.359 58.0906 157.333 58.0906H143.477C142.45 58.0906 141.618 57.2581 141.618 56.2321V47.8396C141.618 46.8127 140.786 45.9811 139.76 45.9811H136.099C135.072 45.9811 134.24 45.1486 134.24 44.1226V33.139C134.24 32.1121 133.408 31.2805 132.382 31.2805H118.863C117.836 31.2805 117.004 30.448 117.004 29.422V28.2394C117.004 27.2125 116.172 26.3809 115.146 26.3809H102.247C101.22 26.3809 100.388 25.5484 100.388 24.5224V22.6639C100.388 21.637 99.5558 20.8054 98.5298 20.8054H79.0403C78.0134 20.8054 77.1818 21.6379 77.1818 22.6639V42.0967C77.1818 43.1236 78.0143 43.9552 79.0403 43.9552H92.3333C93.3602 43.9552 94.1918 44.7877 94.1918 45.8137V47.3347C94.1918 48.3616 95.0243 49.1932 96.0503 49.1932H109.513C110.539 49.1932 111.371 50.0257 111.371 51.0517V52.291C111.371 53.3179 112.204 54.1495 113.23 54.1495H123.594C124.621 54.1495 125.452 54.982 125.452 56.008V60.2893C125.452 61.3162 126.285 62.1478 127.311 62.1478H129.029C130.056 62.1478 130.888 62.9803 130.888 64.0063V86.1994C130.888 87.2263 131.72 88.0579 132.746 88.0579H140.998C142.025 88.0579 142.857 88.8904 142.857 89.9164V92.2825C142.857 93.3094 142.024 94.141 140.998 94.141H138.858C137.831 94.141 136.999 94.9735 136.999 95.9995V107.209C136.999 108.236 136.167 109.067 135.141 109.067C134.114 109.067 133.282 109.9 133.282 110.926V123.149C133.282 124.176 134.115 125.007 135.141 125.007H147.364C148.391 125.007 149.222 124.175 149.222 123.149V117.234C149.222 116.207 150.055 115.376 151.081 115.376H156.376C157.402 115.376 158.234 114.543 158.234 113.517V95.9995C158.234 94.9726 157.402 94.141 156.376 94.141H155.418C154.391 94.141 153.559 93.3085 153.559 92.2825V89.9164C153.559 88.8895 154.392 88.0579 155.418 88.0579H157.333C158.36 88.0579 159.192 87.2254 159.192 86.1994V86.1976Z" - fill={ - { - "payload": 4279435136, - "type": 0, - } - } - propList={ - [ - "fill", - ] - } - /> - <RNSVGPath - d="M62.6459 31.2787H55.7457C54.4857 31.2787 53.4641 32.3002 53.4641 33.5602C53.4641 34.8202 52.4426 35.8417 51.1826 35.8417H41.5508C40.2908 35.8417 39.2693 36.8632 39.2693 38.1232V55.9792C39.2693 57.2392 40.2908 58.2607 41.5508 58.2607H59.4069C60.6668 58.2607 61.6883 57.2392 61.6883 55.9792V51.9238C61.6883 50.6638 62.7098 49.6423 63.9698 49.6423H69.5462C70.8062 49.6423 71.8278 48.6208 71.8278 47.3608V33.5602C71.8278 32.3002 70.8062 31.2787 69.5462 31.2787H62.6459Z" - fill={ - { - "payload": 4279435136, - "type": 0, - } - } - propList={ - [ - "fill", - ] - } - /> - </RNSVGGroup> - </RNSVGSvgView> -</View> -`; diff --git a/apps/mobile/src/features/Onboarding/components/ParticlesLogo/index.ts b/apps/mobile/src/features/Onboarding/components/ParticlesLogo/index.ts deleted file mode 100644 index 41f8265075..0000000000 --- a/apps/mobile/src/features/Onboarding/components/ParticlesLogo/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { ParticlesLogo } from './ParticlesLogo' -export { ParticlesLogo } diff --git a/apps/mobile/src/features/PendingTx/components/PendingTxList/PendingTxList.container.tsx b/apps/mobile/src/features/PendingTx/components/PendingTxList/PendingTxList.container.tsx index 20b6828799..aafc5c5d1d 100644 --- a/apps/mobile/src/features/PendingTx/components/PendingTxList/PendingTxList.container.tsx +++ b/apps/mobile/src/features/PendingTx/components/PendingTxList/PendingTxList.container.tsx @@ -1,7 +1,7 @@ import { SafeListItem } from '@/src/components/SafeListItem' import React from 'react' import { SectionList } from 'react-native' -import { Spinner, View } from 'tamagui' +import { View } from 'tamagui' import { Badge } from '@/src/components/Badge' import { NavBarTitle } from '@/src/components/Title/NavBarTitle' import { LargeHeaderTitle } from '@/src/components/Title/LargeHeaderTitle' @@ -9,6 +9,7 @@ import { useScrollableHeader } from '@/src/navigation/useScrollableHeader' import { TransactionQueuedItem } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' import { PendingTransactionItems } from '@safe-global/store/gateway/types' import { keyExtractor, renderItem } from '@/src/features/PendingTx/utils' +import { Loader } from '@/src/components/Loader' export interface GroupedPendingTxsWithTitle { title: string @@ -33,19 +34,24 @@ export function PendingTxListContainer({ const { handleScroll } = useScrollableHeader({ children: ( <> - <NavBarTitle paddingRight={5}>Pending Transactions</NavBarTitle> - <Badge content={`${amount}${hasMore ? '+' : ''}`} circleSize={'$6'} fontSize={10} /> + <NavBarTitle paddingRight={5}>Pending transactions</NavBarTitle> + <Badge + content={`${amount}${hasMore ? '+' : ''}`} + circleSize={'$6'} + fontSize={10} + themeName="badge_warning_variant2" + /> </> ), }) const LargeHeader = ( - <View flexDirection={'row'} alignItems={'center'} paddingTop={'$3'}> - <LargeHeaderTitle marginRight={5}>Pending Transactions</LargeHeaderTitle> + <View flexDirection={'row'} alignItems={'flex-start'} paddingTop={'$3'}> + <LargeHeaderTitle marginRight={5}>Pending transactions</LargeHeaderTitle> {isLoading ? ( - <Spinner size="large" color="$warning1ContrastTextDark" /> + <Loader size={24} color="$warning1ContrastTextDark" /> ) : ( - <Badge content={`${amount}${hasMore ? '+' : ''}`} /> + <Badge content={`${amount}${hasMore ? '+' : ''}`} themeName="badge_warning_variant2" /> )} </View> ) @@ -59,10 +65,11 @@ export function PendingTxListContainer({ keyExtractor={keyExtractor} renderItem={renderItem} onEndReached={onEndReached} - ListFooterComponent={isLoading ? <Spinner size={'small'} color={'$color'} /> : undefined} + ListFooterComponent={isLoading ? <Loader size={24} color={'$color'} /> : undefined} renderSectionHeader={({ section: { title } }) => <SafeListItem.Header title={title} />} onScroll={handleScroll} scrollEventThrottle={16} + contentContainerStyle={{ paddingHorizontal: 12 }} /> ) } diff --git a/apps/mobile/src/features/PendingTx/utils.tsx b/apps/mobile/src/features/PendingTx/utils.tsx index 0b77d51f5c..9ab7d11071 100644 --- a/apps/mobile/src/features/PendingTx/utils.tsx +++ b/apps/mobile/src/features/PendingTx/utils.tsx @@ -4,6 +4,7 @@ import { getTxHash, isConflictHeaderListItem, isLabelListItem, + isMultisigExecutionInfo, isTransactionListItem, } from '@/src/utils/transaction-guards' import { groupBulkTxs } from '@/src/utils/transactions' @@ -13,8 +14,10 @@ import { TxGroupedCard } from '@/src/components/transactions-list/Card/TxGrouped import { TxConflictingCard } from '@/src/components/transactions-list/Card/TxConflictingCard' import { SafeListItem } from '@/src/components/SafeListItem' import { TxInfo } from '@/src/components/TxInfo' -import React from 'react' +import React, { useCallback } from 'react' import { GroupedPendingTxsWithTitle } from './components/PendingTxList/PendingTxList.container' +import { TxCardPress } from '@/src/components/TxInfo/types' +import { useRouter } from 'expo-router' type GroupedTxs = (PendingTransactionItems | TransactionQueuedItem[])[] @@ -35,8 +38,8 @@ export const groupPendingTxs = (list: PendingTransactionItems[]) => { pointer: -1, amount: 0, sections: [ - { title: 'Ready to execute', data: [] }, - { title: 'Confirmation needed', data: [] }, + { title: 'Next', data: [] }, + { title: 'In queue', data: [] }, ], } @@ -86,6 +89,26 @@ export const renderItem = ({ item: PendingTransactionItems | TransactionQueuedItem[] index: number }) => { + const router = useRouter() + + const onPress = useCallback( + async (transaction?: TxCardPress) => { + if (transaction) { + router.push({ + pathname: '/confirm-transaction', + params: { + txId: transaction.tx.id, + }, + }) + } else { + router.push({ + pathname: '/conflict-transaction-sheet', + }) + } + }, + [router], + ) + if (Array.isArray(item)) { // Handle bulk transactions return ( @@ -93,7 +116,7 @@ export const renderItem = ({ {getBulkGroupTxHash(item) ? ( <TxGroupedCard transactions={item} inQueue /> ) : ( - <TxConflictingCard inQueue transactions={item} /> + <TxConflictingCard inQueue transactions={item} onPress={onPress} /> )} </View> ) @@ -110,7 +133,7 @@ export const renderItem = ({ if (isTransactionListItem(item)) { return ( <View marginTop={index && '$4'}> - <TxInfo inQueue tx={item.transaction} /> + <TxInfo onPress={onPress} inQueue tx={item.transaction} /> </View> ) } @@ -125,10 +148,24 @@ export const keyExtractor = (item: PendingTransactionItems | TransactionQueuedIt return txGroupHash + index } + if (isTransactionListItem(item[0]) && isMultisigExecutionInfo(item[0].transaction.executionInfo)) { + return getTxHash(item[0]) + item[0].transaction.executionInfo.confirmationsSubmitted + index + } + if (isTransactionListItem(item[0])) { return getTxHash(item[0]) + index } + return String(index) } - return String(index) + + if (isTransactionListItem(item) && isMultisigExecutionInfo(item.transaction.executionInfo)) { + return item.transaction.id + item.transaction.executionInfo.confirmationsSubmitted + } + + if (isTransactionListItem(item)) { + return item.transaction.id + } + + return String(item) } diff --git a/apps/mobile/src/features/PrivateKey/PrivateKey.container.tsx b/apps/mobile/src/features/PrivateKey/PrivateKey.container.tsx new file mode 100644 index 0000000000..1a0108bbec --- /dev/null +++ b/apps/mobile/src/features/PrivateKey/PrivateKey.container.tsx @@ -0,0 +1,118 @@ +import React, { useCallback, useState } from 'react' +import { Alert } from 'react-native' +import { useRouter } from 'expo-router' +import { PrivateKeyView } from './components/PrivateKeyView' +import { keyStorageService } from '@/src/services/key-storage' +import { useDelegateCleanup } from '@/src/hooks/useDelegateCleanup' +import { useAppDispatch } from '@/src/store/hooks' +import { type Address } from '@/src/types/address' +import { cleanupSinglePrivateKey } from '@/src/features/AccountsSheet/AccountItem/utils/editAccountHelpers' + +type Props = { + signerAddress: Address +} + +export const PrivateKeyContainer = ({ signerAddress }: Props) => { + const router = useRouter() + const dispatch = useAppDispatch() + const { removeAllDelegatesForOwner } = useDelegateCleanup() + + const [isKeyVisible, setIsKeyVisible] = useState(false) + const [privateKey, setPrivateKey] = useState<string | null>(null) + const [isLoading, setIsLoading] = useState(false) + + const executeViewPrivateKey = useCallback(async () => { + setIsLoading(true) + + try { + const key = await keyStorageService.getPrivateKey(signerAddress) + + if (!key) { + Alert.alert('Error', 'Private key not found') + return + } + + setPrivateKey(key) + setIsKeyVisible(true) + } catch (error) { + console.error('Error retrieving private key:', error) + Alert.alert('Error', 'Failed to retrieve private key') + } finally { + setIsLoading(false) + } + }, [signerAddress]) + + const handleViewPrivateKey = useCallback(() => { + Alert.alert( + 'View private key', + 'Are you sure you want to display your private key on screen? Make sure no one else can see your screen.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Yes, show key', + style: 'destructive', + onPress: executeViewPrivateKey, + }, + ], + ) + }, [executeViewPrivateKey]) + + const showDeleteFailureAlert = useCallback((message?: string) => { + Alert.alert( + 'Cannot delete private key', + message || 'Failed to unsubscribe from push notifications. Please check your internet connection and try again.', + [{ text: 'OK' }], + ) + }, []) + + const executeDeletePrivateKey = useCallback(async () => { + setIsLoading(true) + + try { + const result = await cleanupSinglePrivateKey(signerAddress, removeAllDelegatesForOwner, dispatch) + + if (!result.success) { + showDeleteFailureAlert(result.error?.message) + return + } + + router.back() + Alert.alert('Success', 'Private key has been deleted successfully') + } catch (_error) { + showDeleteFailureAlert('An unexpected error occurred') + } finally { + setIsLoading(false) + } + }, [signerAddress, dispatch, removeAllDelegatesForOwner, router, showDeleteFailureAlert]) + + const handleDeletePrivateKey = useCallback(() => { + Alert.alert( + 'Delete private key', + 'This will make this signer no longer able to sign transactions in this safe and in any other safe on this device that uses this private key. Do you want to proceed?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Yes, delete', + style: 'destructive', + onPress: executeDeletePrivateKey, + }, + ], + ) + }, [executeDeletePrivateKey]) + + const handleHidePrivateKey = useCallback(() => { + setIsKeyVisible(false) + setPrivateKey(null) + }, []) + + return ( + <PrivateKeyView + isKeyVisible={isKeyVisible} + privateKey={privateKey} + isLoading={isLoading} + onViewPrivateKey={handleViewPrivateKey} + onDeletePrivateKey={handleDeletePrivateKey} + onHidePrivateKey={handleHidePrivateKey} + /> + ) +} diff --git a/apps/mobile/src/features/PrivateKey/components/PrivateKeyView.tsx b/apps/mobile/src/features/PrivateKey/components/PrivateKeyView.tsx new file mode 100644 index 0000000000..0afdf30c96 --- /dev/null +++ b/apps/mobile/src/features/PrivateKey/components/PrivateKeyView.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { ScrollView, View, Text, YStack } from 'tamagui' +import { Container } from '@/src/components/Container' +import { CopyButton } from '@/src/components/CopyButton' +import { SafeButton } from '@/src/components/SafeButton' +import { KeyboardAvoidingView, ActivityIndicator } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { SafeInput } from '@/src/components/SafeInput' + +type Props = { + isKeyVisible: boolean + privateKey: string | null + isLoading: boolean + onViewPrivateKey: () => void + onDeletePrivateKey: () => void + onHidePrivateKey: () => void +} + +// Generate a fake 64-character hex string for display when key is hidden +const MASKED_PRIVATE_KEY = '•'.repeat(64) + +export const PrivateKeyView = ({ + isKeyVisible, + privateKey, + isLoading, + onViewPrivateKey, + onDeletePrivateKey, + onHidePrivateKey, +}: Props) => { + const { bottom, top } = useSafeAreaInsets() + + const displayKey = isKeyVisible && privateKey ? privateKey : MASKED_PRIVATE_KEY + + return ( + <YStack flex={1}> + <ScrollView flex={1} contentContainerStyle={{ paddingHorizontal: '$4' }}> + <Container marginTop={'$4'} rowGap={'$1'}> + <Text color={'$colorSecondary'}>Private Key</Text> + <SafeInput + value={displayKey} + editable={false} + multiline + numberOfLines={4} + style={{ fontFamily: 'monospace' }} + right={ + isKeyVisible && privateKey ? ( + <CopyButton value={privateKey} color={'$colorSecondary'} hitSlop={2} text={'Private key copied'} /> + ) : null + } + /> + </Container> + + <View marginTop={'$4'} gap={'$3'}> + {isLoading ? ( + <SafeButton disabled> + <ActivityIndicator color="white" /> + </SafeButton> + ) : isKeyVisible ? ( + <SafeButton onPress={onHidePrivateKey}>Hide private key</SafeButton> + ) : ( + <SafeButton onPress={onViewPrivateKey}>View private key</SafeButton> + )} + </View> + </ScrollView> + + <KeyboardAvoidingView behavior="padding" keyboardVerticalOffset={top + bottom}> + <View paddingHorizontal={'$4'} paddingTop={'$2'} paddingBottom={bottom ?? 60}> + <SafeButton danger={true} onPress={onDeletePrivateKey} disabled={isLoading}> + Delete private key + </SafeButton> + </View> + </KeyboardAvoidingView> + </YStack> + ) +} diff --git a/apps/mobile/src/features/PrivateKey/components/index.tsx b/apps/mobile/src/features/PrivateKey/components/index.tsx new file mode 100644 index 0000000000..e6c36b2f7e --- /dev/null +++ b/apps/mobile/src/features/PrivateKey/components/index.tsx @@ -0,0 +1,2 @@ +import { PrivateKeyView } from './PrivateKeyView' +export { PrivateKeyView } diff --git a/apps/mobile/src/features/PrivateKey/index.tsx b/apps/mobile/src/features/PrivateKey/index.tsx new file mode 100644 index 0000000000..ad3831d2c8 --- /dev/null +++ b/apps/mobile/src/features/PrivateKey/index.tsx @@ -0,0 +1,2 @@ +import { PrivateKeyContainer } from './PrivateKey.container' +export { PrivateKeyContainer } diff --git a/apps/mobile/src/features/Settings/Settings.container.tsx b/apps/mobile/src/features/Settings/Settings.container.tsx index f5eb3930d5..ca65e51da7 100644 --- a/apps/mobile/src/features/Settings/Settings.container.tsx +++ b/apps/mobile/src/features/Settings/Settings.container.tsx @@ -1,15 +1,51 @@ import { useGetSafeQuery } from '@safe-global/store/gateway' import { SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' -import { useSelector } from 'react-redux' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { Settings } from './Settings' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useCallback, useState } from 'react' +import { useAppSelector } from '@/src/store/hooks' +import { selectContactByAddress } from '@/src/store/addressBookSlice' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' +import { selectActiveChain } from '@/src/store/chains' +import { isValidMasterCopy, isMigrationToL2Possible } from '@safe-global/utils/services/contracts/safeContracts' export const SettingsContainer = () => { - const { chainId, address } = useSelector(selectActiveSafe) + const { chainId, address } = useDefinedActiveSafe() + const chain = useAppSelector(selectActiveChain) + const latestSafeVersion = getLatestSafeVersion( + chain ? { chainId, recommendedMasterCopyVersion: chain.recommendedMasterCopyVersion } : undefined, + ) + const { data = {} as SafeState } = useGetSafeQuery({ chainId: chainId, safeAddress: address, }) - return <Settings address={address} data={data} /> + const isUnsupportedMasterCopy = !isValidMasterCopy(data.implementationVersionState) && isMigrationToL2Possible(data) + + const needsUpdate = data.implementationVersionState === 'OUTDATED' + const isLatestVersion = data.version && !needsUpdate + + const contact = useAppSelector(selectContactByAddress(address)) + const [displayDevMenu, setDisplayDevMenu] = useState(false) + const [tappedCount, setTappedCount] = useState(0) + const onImplementationTap = useCallback(() => { + setTappedCount((count) => count + 1) + if (tappedCount >= 2) { + setDisplayDevMenu(true) + } + }, [tappedCount, setTappedCount, setDisplayDevMenu]) + + return ( + <Settings + address={address} + data={data} + displayDevMenu={displayDevMenu} + onImplementationTap={onImplementationTap} + contact={contact} + isLatestVersion={!!isLatestVersion} + latestSafeVersion={latestSafeVersion} + isUnsupportedMasterCopy={isUnsupportedMasterCopy} + /> + ) } diff --git a/apps/mobile/src/features/Settings/Settings.tsx b/apps/mobile/src/features/Settings/Settings.tsx index 44f3195ed3..4874fc0826 100644 --- a/apps/mobile/src/features/Settings/Settings.tsx +++ b/apps/mobile/src/features/Settings/Settings.tsx @@ -1,9 +1,9 @@ -import React from 'react' -import { H2, ScrollView, Text, View, XStack, YStack } from 'tamagui' -import { SafeFontIcon as Icon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import React, { useCallback } from 'react' +import { H2, ScrollView, Text, Theme, View, XStack, YStack } from 'tamagui' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' import { SafeListItem } from '@/src/components/SafeListItem' import { Skeleton } from 'moti/skeleton' -import { Pressable } from 'react-native' +import { Pressable, TouchableOpacity, useColorScheme } from 'react-native' import { EthAddress } from '@/src/components/EthAddress' import { SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { Address } from '@/src/types/address' @@ -11,149 +11,220 @@ import { router } from 'expo-router' import { IdenticonWithBadge } from '@/src/features/Settings/components/IdenticonWithBadge' import { Navbar } from '@/src/features/Settings/components/Navbar/Navbar' +import { type Contact } from '@/src/store/addressBookSlice' +import { Alert2 } from '@/src/components/Alert2' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useCopyAndDispatchToast } from '@/src/hooks/useCopyAndDispatchToast' interface SettingsProps { data: SafeState address: `0x${string}` + displayDevMenu: boolean + onImplementationTap: () => void + contact: Contact | null + isLatestVersion: boolean + latestSafeVersion: string + isUnsupportedMasterCopy: boolean } -export const Settings = ({ address, data }: SettingsProps) => { +export const Settings = ({ + address, + data, + onImplementationTap, + displayDevMenu, + contact, + isLatestVersion, + latestSafeVersion, + isUnsupportedMasterCopy, +}: SettingsProps) => { + const activeSafe = useDefinedActiveSafe() + const copy = useCopyAndDispatchToast() const { owners = [], threshold, implementation } = data + const colorScheme = useColorScheme() + + const onPressAddressCopy = useCallback(() => { + copy(activeSafe.address) + }, [activeSafe.address]) return ( <> - <Navbar safeAddress={address} /> - <ScrollView - style={{ - marginTop: -20, - paddingTop: 0, - }} - contentContainerStyle={{ - marginTop: -15, - }} - > - <YStack flex={1} padding="$4" paddingTop={'$10'}> - <Skeleton.Group show={!owners.length}> - <YStack alignItems="center" space="$3" marginBottom="$6"> - <IdenticonWithBadge - address={address} - badgeContent={owners.length ? `${threshold}/${owners.length}` : ''} - /> - <H2 color="$foreground" fontWeight={600}> - My DAO - </H2> - <View> - <EthAddress - address={address as Address} - copy - textProps={{ - color: '$colorSecondary', - }} + <Theme name={'settings'}> + <Navbar safeAddress={address} /> + <ScrollView + style={{ + marginTop: -20, + paddingTop: 0, + }} + contentContainerStyle={{ + marginTop: -15, + }} + > + <YStack flex={1} padding="$2" paddingTop={'$10'}> + <Skeleton.Group show={!owners.length}> + <YStack alignItems="center" space="$3" marginBottom="$6"> + <IdenticonWithBadge + address={address} + badgeContent={owners.length ? `${threshold}/${owners.length}` : ''} /> - </View> - - <View> - <Skeleton> - <Text color="$primary">saaafe.xyz</Text> - </Skeleton> - </View> - </YStack> - - <XStack justifyContent="center" marginBottom="$6"> - <YStack - alignItems="center" - backgroundColor={'$backgroundPaper'} - padding={'$2'} - borderRadius={'$6'} - width={80} - marginRight={'$2'} - > - <View width={40}> - <Skeleton> - <Text fontWeight="700" textAlign="center" fontSize={'$4'}> - {owners.length} - </Text> - </Skeleton> + <H2 color="$foreground" fontWeight={600} numberOfLines={1}> + {contact?.name || 'Unnamed Safe'} + </H2> + <View> + <TouchableOpacity onPress={onPressAddressCopy}> + <EthAddress + address={address as Address} + copy + textProps={{ + color: '$colorSecondary', + }} + /> + </TouchableOpacity> </View> - <Text color="$colorHover" fontSize={'$3'}> - Signers - </Text> </YStack> - <YStack - alignItems="center" - backgroundColor={'$backgroundPaper'} - padding={'$2'} - borderRadius={'$6'} - width={80} - > - <View width={40}> - <Skeleton> - <Text fontWeight="bold" textAlign="center" fontSize={'$4'}> - {threshold}/{owners.length} - </Text> - </Skeleton> - </View> - <Text color="$colorHover" fontSize={'$3'}> - Threshold - </Text> - </YStack> - </XStack> + <XStack justifyContent="center" marginBottom="$6"> + <YStack + alignItems="center" + backgroundColor={'$background'} + paddingTop={'$3'} + paddingBottom={'$2'} + borderRadius={'$6'} + width={80} + marginRight={'$2'} + > + <View width={30}> + <Skeleton colorMode={colorScheme === 'dark' ? 'dark' : 'light'}> + <Text fontWeight="bold" textAlign="center" fontSize={'$4'}> + {owners.length} + </Text> + </Skeleton> + </View> + <Text color="$colorHover" fontSize={'$3'}> + Signers + </Text> + </YStack> - <YStack space="$4"> - <View backgroundColor="$backgroundDark" padding="$4" borderRadius="$3" gap={'$2'}> - <Text color="$foreground">Members</Text> - <Pressable - style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1.0 }]} - onPress={() => { - router.push('/signers') - }} + <YStack + alignItems="center" + backgroundColor={'$background'} + paddingTop={'$3'} + paddingBottom={'$2'} + borderRadius={'$6'} + width={80} > - <SafeListItem - label={'Signers'} - leftNode={<Icon name={'owners'} />} - rightNode={ - <View flexDirection={'row'} alignItems={'center'} justifyContent={'center'}> - <Skeleton height={17}> - <Text minWidth={15} marginRight={'$3'}> - {owners.length} - </Text> - </Skeleton> - <View> - <Icon name={'arrow-right'} /> - </View> - </View> - } - /> - </Pressable> - </View> + <View width={30}> + <Skeleton colorMode={colorScheme === 'dark' ? 'dark' : 'light'}> + <Text fontWeight="bold" textAlign="center" fontSize={'$4'}> + {threshold}/{owners.length} + </Text> + </Skeleton> + </View> + <Text color="$colorHover" fontSize={'$3'}> + Threshold + </Text> + </YStack> + </XStack> - <View backgroundColor="$backgroundDark" padding="$4" borderRadius="$3" gap={'$2'}> - <Text color="$foreground">General</Text> - <View backgroundColor={'$background'} borderRadius={'$3'}> + <YStack space="$4"> + <View padding="$4" borderRadius="$3" gap={'$2'}> + <Text color="$colorSecondary" fontWeight={500}> + Members + </Text> <Pressable style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1.0 }]} onPress={() => { - router.push('/notifications') + router.push('/signers') }} > <SafeListItem - label={'Notifications'} - leftNode={<Icon name={'bell'} />} - rightNode={<Icon name={'arrow-right'} />} + label={'Signers'} + leftNode={<SafeFontIcon name={'owners'} color={'$colorSecondary'} />} + rightNode={ + <View flexDirection={'row'} alignItems={'center'} justifyContent={'center'}> + <Skeleton colorMode={colorScheme === 'dark' ? 'dark' : 'light'} height={17}> + <Text minWidth={15} marginRight={'$3'} color={'$colorSecondary'}> + {owners.length} + </Text> + </Skeleton> + <View> + <SafeFontIcon name={'chevron-right'} /> + </View> + </View> + } /> </Pressable> </View> - </View> - </YStack> - </Skeleton.Group> - {/* Footer */} - <Text textAlign="center" color="$colorSecondary" marginTop="$8"> - {implementation?.name} - </Text> - </YStack> - </ScrollView> + <View backgroundColor="$backgroundDark" padding="$4" borderRadius="$3" gap={'$2'}> + <Text color="$colorSecondary">General</Text> + <View backgroundColor={'$background'} borderRadius={'$3'}> + <Pressable + style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1.0 }]} + onPress={() => { + router.push('/notifications-settings') + }} + > + <SafeListItem + label={'Notifications'} + leftNode={<SafeFontIcon name={'bell'} color={'$colorSecondary'} />} + rightNode={<SafeFontIcon name={'chevron-right'} />} + /> + </Pressable> + </View> + </View> + + {displayDevMenu && ( + <View backgroundColor="$backgroundDark" padding="$4" borderRadius="$3" gap={'$2'}> + <Text color="$foreground">Developer</Text> + <View backgroundColor={'$background'} borderRadius={'$3'}> + <Pressable + style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1.0 }]} + onPress={() => { + router.push('/developer') + }} + > + <SafeListItem + label={'Developer'} + leftNode={<SafeFontIcon name={'alert-triangle'} color={'$colorSecondary'} />} + rightNode={<SafeFontIcon name={'chevron-right'} />} + /> + </Pressable> + </View> + </View> + )} + </YStack> + </Skeleton.Group> + + {/* Footer */} + <Pressable + onPress={onImplementationTap} + style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: '$2', + marginTop: 14, + }} + > + {isLatestVersion && <SafeFontIcon testID="check-icon" name={'check-filled'} color={'$success'} />} + <Text marginLeft={'$2'} textAlign="center" color="$colorSecondary"> + {implementation?.name} {isLatestVersion ? `(Latest version)` : `(Latest version: ${latestSafeVersion})`} + </Text> + </Pressable> + + {isUnsupportedMasterCopy && ( + <View flex={1} padding="$5"> + <Alert2 + type="warning" + message="Your Safe Account's base contract is not supported. You should migrate it to a compatible + version. Use the web app for this." + title="Base contract is not supported" + /> + </View> + )} + </YStack> + </ScrollView> + </Theme> </> ) } diff --git a/apps/mobile/src/features/Settings/__tests__/Settings.test.tsx b/apps/mobile/src/features/Settings/__tests__/Settings.test.tsx new file mode 100644 index 0000000000..d798d05cee --- /dev/null +++ b/apps/mobile/src/features/Settings/__tests__/Settings.test.tsx @@ -0,0 +1,141 @@ +import React from 'react' +import { render } from '@/src/tests/test-utils' +import { Settings } from '../Settings' +import { SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { RootState } from '@/src/store' +import { NavigationContainer } from '@react-navigation/native' + +// Mock expo-router +jest.mock('expo-router', () => ({ + router: { + push: jest.fn(), + }, + useRouter: () => ({ + push: jest.fn(), + navigate: jest.fn(), + back: jest.fn(), + canGoBack: jest.fn(() => true), + setParams: jest.fn(), + }), + useNavigation: () => ({ + navigate: jest.fn(), + dispatch: jest.fn(), + }), + useSegments: () => ['test'], + usePathname: () => '/test-path', +})) + +const mockSafeState: SafeState = { + address: { value: '0x123' }, + chainId: '1', + nonce: 0, + threshold: 2, + owners: [{ value: '0x123' }, { value: '0x456' }], + implementation: { value: '0x789', name: 'Safe v1.3.0' }, + implementationVersionState: 'UP_TO_DATE', + modules: null, + fallbackHandler: null, + guard: null, + version: '1.3.0', + collectiblesTag: null, + txQueuedTag: null, + txHistoryTag: null, + messagesTag: null, +} + +const mockProps = { + address: '0x123' as `0x${string}`, + data: mockSafeState, + displayDevMenu: false, + onImplementationTap: jest.fn(), + contact: null, + isLatestVersion: false, + latestSafeVersion: '1.4.0', + isUnsupportedMasterCopy: false, +} + +const initialStore: Partial<RootState> = { + activeSafe: { + address: '0x123', + chainId: '1', + }, +} + +// Custom wrapper with NavigationContainer +const wrapper = ({ children }: { children: React.ReactNode }) => <NavigationContainer>{children}</NavigationContainer> + +describe('Settings', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Version Display', () => { + it('should display the implementation name and latest version when not on latest version', () => { + const { getByText } = render(<Settings {...mockProps} />, { + initialStore, + wrapper, + }) + + const versionText = getByText('Safe v1.3.0 (Latest version: 1.4.0)') + expect(versionText).toBeTruthy() + }) + + it('should display the implementation name with "Latest version" text when on latest version', () => { + const { getByText } = render(<Settings {...mockProps} isLatestVersion={true} />, { + initialStore, + wrapper, + }) + + const versionText = getByText('Safe v1.3.0 (Latest version)') + expect(versionText).toBeTruthy() + }) + + it('should show check icon when on latest version', () => { + const { getByTestId } = render(<Settings {...mockProps} isLatestVersion={true} />, { + initialStore, + wrapper, + }) + + const checkIcon = getByTestId('check-icon') + expect(checkIcon).toBeTruthy() + }) + + it('should not show check icon when not on latest version', () => { + const { queryByTestId } = render(<Settings {...mockProps} isLatestVersion={false} />, { + initialStore, + wrapper, + }) + + const checkIcon = queryByTestId('check-icon') + expect(checkIcon).toBeNull() + }) + }) + + describe('Unsupported Master Copy Warning', () => { + it('should display warning when master copy is unsupported', () => { + const { getByText } = render(<Settings {...mockProps} isUnsupportedMasterCopy={true} />, { + initialStore, + wrapper, + }) + + const warningTitle = getByText('Base contract is not supported') + const warningMessage = getByText(/Your Safe Account's base contract is not supported/) + + expect(warningTitle).toBeTruthy() + expect(warningMessage).toBeTruthy() + }) + + it('should not display warning when master copy is supported', () => { + const { queryByText } = render(<Settings {...mockProps} isUnsupportedMasterCopy={false} />, { + initialStore, + wrapper, + }) + + const warningTitle = queryByText('Base contract is not supported') + const warningMessage = queryByText(/Your Safe Account's base contract is not supported/) + + expect(warningTitle).toBeNull() + expect(warningMessage).toBeNull() + }) + }) +}) diff --git a/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx index c0eec6e4d0..a1ab318b13 100644 --- a/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx +++ b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx @@ -1,21 +1,177 @@ -import React, { useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice' -import { Address } from '@/src/types/address' +import React from 'react' +import { Linking } from 'react-native' +import { router } from 'expo-router' + +import { Text, View } from 'tamagui' import { AppSettings } from './AppSettings' +import { useTheme } from '@/src/theme/hooks/useTheme' +import { SafeFontIcon as Icon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import { FloatingMenu } from '../FloatingMenu' +import { LoadableSwitch } from '@/src/components/LoadableSwitch' +import { useBiometrics } from '@/src/hooks/useBiometrics' +import { useNotificationManager } from '@/src/hooks/useNotificationManager' +import { useAppSelector } from '@/src/store/hooks' +import { selectAppNotificationStatus } from '@/src/store/notificationsSlice' +import { selectCurrency } from '@/src/store/settingsSlice' +import { capitalize } from '@/src/utils/formatters' +import { SAFE_WEB_FEEDBACK_URL } from '@/src/config/constants' export const AppSettingsContainer = () => { - const dispatch = useDispatch() - const activeSafe = useSelector(selectActiveSafe) - const [safeAddress, setSafeAddress] = useState('') + const { toggleBiometrics, isBiometricsEnabled, isLoading: isBiometricsLoading, getBiometricsUIInfo } = useBiometrics() + const { enableNotification, disableNotification, isLoading: isNotificationsLoading } = useNotificationManager() + const isAppNotificationEnabled = useAppSelector(selectAppNotificationStatus) + const currency = useAppSelector(selectCurrency) + const { themePreference, setThemePreference } = useTheme() - const handleSubmit = () => { - dispatch( - setActiveSafe({ - chainId: activeSafe.chainId, - address: safeAddress as Address, - }), - ) + const handleToggleNotifications = () => { + if (isAppNotificationEnabled) { + disableNotification() + } else { + enableNotification() + } } - return <AppSettings onSubmit={handleSubmit} address={safeAddress} onAddressChange={setSafeAddress} /> + + const settingsSections = [ + { + sectionName: 'Preferences', + items: [ + { + label: 'Currency', + leftIcon: 'token', + onPress: () => router.push('/currency'), + disabled: false, + rightNode: ( + <View flexDirection="row" alignItems="center" gap={4}> + <Text color="$colorSecondary">{currency.toUpperCase()}</Text> + <Icon name={'chevron-right'} /> + </View> + ), + }, + { + label: 'Appearance', + leftIcon: 'appearance', + disabled: false, + type: 'floating-menu', + rightNode: ( + <FloatingMenu + themeVariant={themePreference} + onPressAction={({ nativeEvent }) => { + const mode = nativeEvent.event as 'auto' | 'dark' | 'light' + setThemePreference(mode) + }} + actions={[ + { + id: 'auto', + title: 'Auto', + }, + { + id: 'dark', + title: 'Dark', + }, + { + id: 'light', + title: 'Light', + }, + ]} + > + <View flexDirection="row" alignItems="center" gap={4}> + <Text color="$colorSecondary">{capitalize(themePreference)}</Text> + <Icon name={'chevron-down'} /> + </View> + </FloatingMenu> + ), + }, + ], + }, + { + sectionName: 'Security', + items: [ + { + label: getBiometricsUIInfo().label, + leftIcon: getBiometricsUIInfo().icon, + type: 'switch', + rightNode: ( + <LoadableSwitch + testID="toggle-app-biometrics" + onChange={() => toggleBiometrics(!isBiometricsEnabled)} + value={isBiometricsEnabled} + isLoading={isBiometricsLoading} + trackColor={{ true: '$primary' }} + /> + ), + disabled: false, + }, + { + label: 'Change passcode', + leftIcon: 'lock', + onPress: () => console.log('change passcode'), + disabled: true, + tag: 'Coming soon', + }, + ], + }, + { + sectionName: 'General', + items: [ + { + label: 'Address book', + leftIcon: 'address-book', + type: 'menu', + onPress: () => router.push('/address-book'), + disabled: false, + }, + { + label: 'Allow notifications', + leftIcon: 'bell', + type: 'switch', + rightNode: ( + <LoadableSwitch + testID="toggle-global-notifications" + onChange={handleToggleNotifications} + value={isAppNotificationEnabled} + isLoading={isNotificationsLoading} + trackColor={{ true: '$primary' }} + /> + ), + disabled: false, + }, + ], + }, + { + sectionName: 'About', + items: [ + { + label: 'Rate us', + leftIcon: 'star', + onPress: () => console.log('rate us'), + disabled: true, + type: 'external-link', + tag: 'Coming soon', + }, + { + label: 'Follow us on X', + leftIcon: 'twitter-x', + onPress: () => Linking.openURL('https://x.com/safe?s=21'), + disabled: false, + type: 'external-link', + }, + { + label: 'Leave feedback', + leftIcon: 'chat', + onPress: () => Linking.openURL(SAFE_WEB_FEEDBACK_URL), + disabled: false, + type: 'external-link', + }, + { + label: 'Help center', + leftIcon: 'question', + onPress: () => Linking.openURL('https://help.safe.global/en/'), + disabled: false, + type: 'external-link', + }, + ], + }, + ] + + return <AppSettings sections={settingsSections} /> } diff --git a/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.tsx b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.tsx index bfc6dc0c79..e52cee7da5 100644 --- a/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.tsx +++ b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.tsx @@ -1,29 +1,88 @@ -import { View, Text, Input } from 'tamagui' -import { TouchableOpacity } from 'react-native' +import { ScrollView, Text, Theme, View, YStack } from 'tamagui' +import { SafeListItem } from '@/src/components/SafeListItem' +import { SafeFontIcon as Icon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import { Pressable } from 'react-native' +import { type SettingsSection } from './AppSettings.types' +import { IconName } from '@/src/types/iconTypes' +import { LargeHeaderTitle, NavBarTitle } from '@/src/components/Title' +import { useMemo } from 'react' +import { useScrollableHeader } from '@/src/navigation/useScrollableHeader' +import { useSafeAreaInsets } from 'react-native-safe-area-context' interface AppSettingsProps { - address: string - onSubmit: () => void - onAddressChange: (address: string) => void + sections: SettingsSection[] } -export const AppSettings = ({ address, onAddressChange, onSubmit }: AppSettingsProps) => { +export const AppSettings = ({ sections }: AppSettingsProps) => { + const memoizedSections = useMemo(() => sections, [sections]) + const insets = useSafeAreaInsets() + const { handleScroll } = useScrollableHeader({ + children: <NavBarTitle paddingRight={5}>Settings</NavBarTitle>, + }) + return ( - <View paddingHorizontal={'$3'}> - <View padding="$3"> - <Input - value={address} - onChangeText={onAddressChange} - placeholder="Enter Safe Address" - placeholderTextColor="#666" - fontSize={20} - height={30} - marginVertical={10} - /> - <TouchableOpacity onPress={onSubmit}> - <Text>Set Safe Address</Text> - </TouchableOpacity> - </View> - </View> + <Theme name={'settings'}> + <ScrollView + contentContainerStyle={{ + paddingTop: 10, + }} + contentInset={{ bottom: insets.bottom }} + keyboardShouldPersistTaps="handled" + scrollEventThrottle={16} + showsVerticalScrollIndicator={false} + overScrollMode="never" + onScroll={handleScroll} + > + <LargeHeaderTitle marginLeft={16} marginTop={8}> + Settings + </LargeHeaderTitle> + <YStack flex={1} paddingHorizontal="$3"> + <YStack space="$4"> + {memoizedSections.map((section, sectionIndex) => ( + <View + key={`section-${sectionIndex}`} + backgroundColor="$backgroundDark" + padding="$1" + borderRadius="$3" + gap={'$2'} + > + {section.sectionName && <Text color="$colorSecondary">{section.sectionName}</Text>} + <View backgroundColor={'$background'} borderRadius={'$3'}> + {section.items.map((item, itemIndex) => { + if (item.type === 'floating-menu') { + return ( + <SafeListItem + key={`item-${sectionIndex}-${itemIndex}`} + label={item.label} + leftNode={<Icon name={item.leftIcon as IconName} color={'$colorSecondary'} />} + rightNode={item.rightNode ?? <Icon name={'chevron-right'} />} + tag={item.tag} + /> + ) + } + + return ( + <Pressable + key={`item-${sectionIndex}-${itemIndex}`} + style={({ pressed }) => [{ opacity: pressed || item.disabled ? 0.5 : 1.0 }]} + onPress={item.onPress} + disabled={item.disabled} + > + <SafeListItem + label={item.label} + leftNode={<Icon name={item.leftIcon as IconName} color={'$colorSecondary'} />} + rightNode={item.rightNode ?? <Icon name={'chevron-right'} />} + tag={item.tag} + /> + </Pressable> + ) + })} + </View> + </View> + ))} + </YStack> + </YStack> + </ScrollView> + </Theme> ) } diff --git a/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.types.ts b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.types.ts new file mode 100644 index 0000000000..b0204d6a26 --- /dev/null +++ b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.types.ts @@ -0,0 +1,16 @@ +import { ReactNode } from 'react' + +export interface SettingsItem { + label: string + leftIcon: string + rightNode?: ReactNode + onPress?: () => void + disabled?: boolean + type?: string + tag?: string +} + +export interface SettingsSection { + sectionName?: string + items: SettingsItem[] +} diff --git a/apps/mobile/src/features/Settings/components/Currency/Currency.container.tsx b/apps/mobile/src/features/Settings/components/Currency/Currency.container.tsx new file mode 100644 index 0000000000..18b85c4847 --- /dev/null +++ b/apps/mobile/src/features/Settings/components/Currency/Currency.container.tsx @@ -0,0 +1,55 @@ +import React, { useState, useMemo } from 'react' +import { CurrencyView } from './CurrencyView' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { selectCurrency, setCurrency } from '@/src/store/settingsSlice' +import useCurrencies from '@/src/hooks/useCurrencies' +import { useRouter } from 'expo-router' +import { getCurrencyName, getCurrencySymbol } from '@/src/utils/currency' + +const CRYPTO_CURRENCIES = ['BTC', 'ETH'] + +export const CurrencyContainer = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const selectedCurrency = useAppSelector(selectCurrency) + const supportedCurrencies = useCurrencies() + const [searchQuery, setSearchQuery] = useState('') + + const handleCurrencySelect = (currency: string) => { + dispatch(setCurrency(currency)) + router.back() + } + + // Filter currencies based on search query + const filteredCurrencies = useMemo(() => { + if (!supportedCurrencies) { + return [] + } + + return supportedCurrencies.filter((currency) => { + const currencyCode = currency.toUpperCase() + const currencyName = getCurrencyName(currencyCode) + const currencySymbol = getCurrencySymbol(currencyCode) + const searchLower = searchQuery.toLowerCase() + return ( + currency.toLowerCase().includes(searchLower) || + currencyName.toLowerCase().includes(searchLower) || + currencySymbol.toLowerCase().includes(searchLower) + ) + }) + }, [supportedCurrencies, searchQuery]) + + // Separate crypto and fiat currencies + const cryptoCurrencies = filteredCurrencies.filter((currency) => CRYPTO_CURRENCIES.includes(currency.toUpperCase())) + const fiatCurrencies = filteredCurrencies.filter((currency) => !CRYPTO_CURRENCIES.includes(currency.toUpperCase())) + + return ( + <CurrencyView + selectedCurrency={selectedCurrency} + cryptoCurrencies={cryptoCurrencies} + fiatCurrencies={fiatCurrencies} + onCurrencySelect={handleCurrencySelect} + onSearchQueryChange={setSearchQuery} + /> + ) +} diff --git a/apps/mobile/src/features/Settings/components/Currency/Currency.types.ts b/apps/mobile/src/features/Settings/components/Currency/Currency.types.ts new file mode 100644 index 0000000000..cab46bbd5c --- /dev/null +++ b/apps/mobile/src/features/Settings/components/Currency/Currency.types.ts @@ -0,0 +1,30 @@ +import type { FiatCurrencies } from '@safe-global/store/gateway/types' + +export interface CurrencyItemProps { + code: string + symbol: string + name: string + isSelected: boolean + onPress: () => void +} + +export interface CurrencySectionProps { + title: string + currencies: string[] + selectedCurrency: string + onCurrencySelect: (currency: string) => void +} + +export interface CurrencyViewProps { + selectedCurrency: string + cryptoCurrencies: string[] + fiatCurrencies: string[] + onCurrencySelect: (currency: string) => void + onSearchQueryChange: (query: string) => void +} + +export interface CurrencyScreenProps { + selectedCurrency: string + supportedCurrencies?: FiatCurrencies + onCurrencySelect: (currency: string) => void +} diff --git a/apps/mobile/src/features/Settings/components/Currency/CurrencyItem/CurrencyItem.tsx b/apps/mobile/src/features/Settings/components/Currency/CurrencyItem/CurrencyItem.tsx new file mode 100644 index 0000000000..61764cd644 --- /dev/null +++ b/apps/mobile/src/features/Settings/components/Currency/CurrencyItem/CurrencyItem.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { View, Text } from 'tamagui' +import { Pressable } from 'react-native' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import type { CurrencyItemProps } from '../Currency.types' + +export const CurrencyItem: React.FC<CurrencyItemProps> = ({ code, symbol, name, isSelected, onPress }) => ( + <Pressable onPress={onPress}> + <View borderRadius="$2"> + <View flexDirection="row" justifyContent="space-between" alignItems="center"> + <View flex={1}> + <Text fontSize="$5" fontWeight="600" color="$color"> + {code} {code !== symbol && `- ${symbol}`} + </Text> + <Text fontSize="$4" color="$colorSecondary"> + {name} + </Text> + </View> + {isSelected && <SafeFontIcon name="check" size={24} color="$color" />} + </View> + </View> + </Pressable> +) diff --git a/apps/mobile/src/features/Settings/components/Currency/CurrencyItem/index.ts b/apps/mobile/src/features/Settings/components/Currency/CurrencyItem/index.ts new file mode 100644 index 0000000000..ed74a690d8 --- /dev/null +++ b/apps/mobile/src/features/Settings/components/Currency/CurrencyItem/index.ts @@ -0,0 +1 @@ +export { CurrencyItem } from './CurrencyItem' diff --git a/apps/mobile/src/features/Settings/components/Currency/CurrencySection/CurrencySection.tsx b/apps/mobile/src/features/Settings/components/Currency/CurrencySection/CurrencySection.tsx new file mode 100644 index 0000000000..fa94758ac5 --- /dev/null +++ b/apps/mobile/src/features/Settings/components/Currency/CurrencySection/CurrencySection.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { View, Text, YStack } from 'tamagui' +import { CurrencyItem } from '../CurrencyItem' +import { getCurrencyName, getCurrencySymbol } from '@/src/utils/currency' +import type { CurrencySectionProps } from '../Currency.types' + +export const CurrencySection: React.FC<CurrencySectionProps> = ({ + title, + currencies, + selectedCurrency, + onCurrencySelect, +}) => ( + <YStack marginBottom="$4"> + <View paddingVertical="$2"> + <Text fontSize="$4" fontWeight="500" color="$colorSecondary"> + {title} + </Text> + </View> + <YStack gap="$4"> + {currencies.map((currency) => { + const currencyName = getCurrencyName(currency) + const currencySymbol = getCurrencySymbol(currency) + if (!currencyName || !currencySymbol) { + return null + } + + return ( + <CurrencyItem + key={currency} + code={currency.toUpperCase()} + symbol={currencySymbol} + name={currencyName} + isSelected={selectedCurrency.toUpperCase() === currency.toUpperCase()} + onPress={() => onCurrencySelect(currency.toLowerCase())} + /> + ) + })} + </YStack> + </YStack> +) diff --git a/apps/mobile/src/features/Settings/components/Currency/CurrencySection/index.ts b/apps/mobile/src/features/Settings/components/Currency/CurrencySection/index.ts new file mode 100644 index 0000000000..6584f16585 --- /dev/null +++ b/apps/mobile/src/features/Settings/components/Currency/CurrencySection/index.ts @@ -0,0 +1 @@ +export { CurrencySection } from './CurrencySection' diff --git a/apps/mobile/src/features/Settings/components/Currency/CurrencyView.tsx b/apps/mobile/src/features/Settings/components/Currency/CurrencyView.tsx new file mode 100644 index 0000000000..a44f0f517b --- /dev/null +++ b/apps/mobile/src/features/Settings/components/Currency/CurrencyView.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { ScrollView, YStack, View } from 'tamagui' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { CurrencySection } from './CurrencySection' +import { NavBarTitle } from '@/src/components/Title/NavBarTitle' +import { LargeHeaderTitle } from '@/src/components/Title/LargeHeaderTitle' +import { useScrollableHeader } from '@/src/navigation/useScrollableHeader' +import SafeSearchBar from '@/src/components/SafeSearchBar/SafeSearchBar' +import type { CurrencyViewProps } from './Currency.types' +import { useColorScheme } from 'react-native' + +export const CurrencyView: React.FC<CurrencyViewProps> = ({ + selectedCurrency, + cryptoCurrencies, + fiatCurrencies, + onCurrencySelect, + onSearchQueryChange, +}) => { + const insets = useSafeAreaInsets() + const colorScheme = useColorScheme() + const isDark = colorScheme === 'dark' + + const { handleScroll } = useScrollableHeader({ + children: <NavBarTitle>Currency</NavBarTitle>, + }) + + const LargeHeader = ( + <View paddingTop={'$3'} paddingHorizontal={'$4'}> + <LargeHeaderTitle>Currency</LargeHeaderTitle> + </View> + ) + + const SearchBarComponent = ( + <View paddingHorizontal={'$4'} paddingVertical={'$2'} backgroundColor={isDark ? '$background' : '$backgroundPaper'}> + <SafeSearchBar placeholder="Search" onSearch={onSearchQueryChange} /> + </View> + ) + + return ( + <View flex={1}> + <ScrollView + flex={1} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ paddingBottom: insets.bottom + 20 }} + onScroll={handleScroll} + scrollEventThrottle={16} + stickyHeaderIndices={[1]} + > + {LargeHeader} + {SearchBarComponent} + + <YStack paddingHorizontal="$4" paddingTop="$2"> + {cryptoCurrencies.length > 0 && ( + <CurrencySection + title="Crypto" + currencies={cryptoCurrencies} + selectedCurrency={selectedCurrency} + onCurrencySelect={onCurrencySelect} + /> + )} + + {fiatCurrencies.length > 0 && ( + <CurrencySection + title="Fiat" + currencies={fiatCurrencies} + selectedCurrency={selectedCurrency} + onCurrencySelect={onCurrencySelect} + /> + )} + </YStack> + </ScrollView> + </View> + ) +} diff --git a/apps/mobile/src/features/Settings/components/Currency/index.ts b/apps/mobile/src/features/Settings/components/Currency/index.ts new file mode 100644 index 0000000000..820d1f8419 --- /dev/null +++ b/apps/mobile/src/features/Settings/components/Currency/index.ts @@ -0,0 +1,2 @@ +export { CurrencyContainer as CurrencyScreenContainer } from './Currency.container' +export { CurrencyView } from './CurrencyView' diff --git a/apps/mobile/src/features/Settings/components/FloatingMenu.tsx b/apps/mobile/src/features/Settings/components/FloatingMenu.tsx new file mode 100644 index 0000000000..38196c639c --- /dev/null +++ b/apps/mobile/src/features/Settings/components/FloatingMenu.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { Pressable } from 'react-native' +import { MenuAction, MenuView, NativeActionEvent } from '@react-native-menu/menu' +import { ThemePreference } from '@/src/types/theme' + +type FloatingMenuProps = { + onPressAction: (event: NativeActionEvent) => void + actions: MenuAction[] + children: React.ReactNode + themeVariant?: ThemePreference +} +export const FloatingMenu = ({ onPressAction, actions, children, themeVariant }: FloatingMenuProps) => { + return ( + <MenuView themeVariant={themeVariant} onPressAction={onPressAction} actions={actions} shouldOpenOnLongPress={false}> + <Pressable testID={'settings-screen-header-more-settings-button'}>{children}</Pressable> + </MenuView> + ) +} diff --git a/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx b/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx index 2a1de35dfe..6a100c5dd8 100644 --- a/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx +++ b/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx @@ -9,9 +9,11 @@ import { Address } from '@/src/types/address' type IdenticonWithBadgeProps = { address: Address badgeContent?: string + badgeSize?: number size?: number testID?: string fontSize?: number + variant?: 'sm' | 'md' } export const IdenticonWithBadge = ({ @@ -20,12 +22,14 @@ export const IdenticonWithBadge = ({ badgeContent, fontSize = 12, size = 56, + badgeSize = 28, + variant = 'md', }: IdenticonWithBadgeProps) => { return ( <View style={styles.container} testID={testID}> - <Identicon address={address} rounded size={size} /> - <View style={styles.badge}> - <Skeleton colorMode={'dark'} radius="round" height={28} width={28}> + <Identicon address={address} size={size} /> + <View style={[variant === 'sm' ? styles.badgeSm : styles.badge]}> + <Skeleton colorMode={'dark'} radius="round" height={badgeSize} width={badgeSize}> {badgeContent && ( <Badge content={badgeContent} @@ -33,8 +37,9 @@ export const IdenticonWithBadge = ({ fontSize, fontWeight: 500, }} - themeName={'badge_success'} - circleProps={{ bordered: true }} + circleSize={badgeSize} + themeName={'badge_success_variant2'} + circleProps={{ bordered: true, borderColor: '$colorContrast' }} /> )} </Skeleton> @@ -52,4 +57,9 @@ const styles = StyleSheet.create({ top: -5, right: -10, }, + badgeSm: { + position: 'absolute', + top: -5, + right: -5, + }, }) diff --git a/apps/mobile/src/features/Settings/components/Navbar/Navbar.tsx b/apps/mobile/src/features/Settings/components/Navbar/Navbar.tsx index 1aa862e81a..81d7656f0a 100644 --- a/apps/mobile/src/features/Settings/components/Navbar/Navbar.tsx +++ b/apps/mobile/src/features/Settings/components/Navbar/Navbar.tsx @@ -1,36 +1,9 @@ import React from 'react' -import { View } from 'tamagui' import { SettingsMenu } from '@/src/features/Settings/components/Navbar/SettingsMenu' -import { SettingsButton } from '@/src/features/Settings/components/Navbar/SettingsButton' -import { BlurredIdenticonBackground } from '@/src/components/BlurredIdenticonBackground/BlurredIdenticonBackground' -import { StyleSheet } from 'react-native' -import { SafeAreaView } from 'react-native-safe-area-context' import { Address } from '@/src/types/address' export const Navbar = (props: { safeAddress: Address }) => { const { safeAddress } = props - return ( - <View> - <BlurredIdenticonBackground address={safeAddress} height={140}> - <SafeAreaView style={styles.headerContainer}> - <View flexDirection="row" alignItems="center" columnGap="$3"> - <SettingsButton /> - <SettingsMenu safeAddress={safeAddress} /> - </View> - </SafeAreaView> - </BlurredIdenticonBackground> - </View> - ) + return <SettingsMenu safeAddress={safeAddress} /> } - -const styles = StyleSheet.create({ - headerContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-end', - paddingHorizontal: 10, - paddingVertical: 16, - paddingBottom: 0, - }, -}) diff --git a/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx b/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx index db7e12d200..b0b8d5d82f 100644 --- a/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx +++ b/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx @@ -1,147 +1,210 @@ -import { Button, useTheme } from 'tamagui' -import { MenuView, NativeActionEvent } from '@react-native-menu/menu' -import { Linking, Platform } from 'react-native' +import { getTokenValue, Theme, useTheme, View } from 'tamagui' +import { Linking, Platform, Pressable, Alert } from 'react-native' import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' import React from 'react' -import { getExplorerLink } from '@/src/utils/gateway' +import { getExplorerLink } from '@safe-global/utils/utils/gateway' import { useCopyAndDispatchToast } from '@/src/hooks/useCopyAndDispatchToast' import { useToastController } from '@tamagui/toast' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { selectChainById } from '@/src/store/chains' import { RootState } from '@/src/store' import { useAppSelector } from '@/src/store/hooks' - +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useEditAccountItem } from '@/src/features/AccountsSheet/AccountItem/hooks/useEditAccountItem' +import { type Address } from '@/src/types/address' +import { router } from 'expo-router' +import { FloatingMenu } from '../FloatingMenu' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { trackEvent } from '@/src/services/analytics/firebaseAnalytics' +import { createAppSettingsOpenEvent, createSettingsMenuActionEvent } from '@/src/services/analytics/events/settings' type Props = { safeAddress: string | undefined } export const SettingsMenu = ({ safeAddress }: Props) => { const toast = useToastController() - const activeSafe = useAppSelector(selectActiveSafe) + const insets = useSafeAreaInsets() + const activeSafe = useDefinedActiveSafe() + const { deleteSafe } = useEditAccountItem() const activeChain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) const copyAndDispatchToast = useCopyAndDispatchToast() const theme = useTheme() const color = theme.color?.get() const colorError = 'red' - const toBeImplemented = () => { - toast.show('This feature is not implemented yet.', { - native: true, - duration: 2000, - burntOptions: { - preset: 'error', - }, - }) - } if (!safeAddress) { return null } return ( - <Menu - onPressAction={({ nativeEvent }) => { - console.warn(JSON.stringify(nativeEvent)) + <Theme name="navbar"> + <View + style={{ + flexDirection: 'row', + paddingTop: getTokenValue('$3') + insets.top, + paddingHorizontal: 16, + paddingBottom: getTokenValue('$2'), + backgroundColor: '$background', + marginRight: 4, + alignItems: 'center', + justifyContent: 'flex-end', + gap: 10, + zIndex: 1, + }} + > + <Pressable + testID={'settings-screen-header-app-settings-button'} + hitSlop={6} + onPressIn={() => { + try { + const event = createAppSettingsOpenEvent() + trackEvent(event) + } catch (error) { + console.error('Error tracking app settings open event:', error) + } + router.push('/app-settings') + }} + > + <View + backgroundColor={'$backgroundSkeleton'} + alignItems={'center'} + justifyContent={'center'} + borderRadius={16} + height={32} + width={32} + > + <SafeFontIcon name={'settings'} size={20} color={'$color'} /> + </View> + </Pressable> - if (nativeEvent.event === 'rename') { - console.log('rename') - toBeImplemented() - } + <FloatingMenu + onPressAction={({ nativeEvent }) => { + const action = nativeEvent.event as 'rename' | 'explorer' | 'copy' | 'share' | 'remove' - if (nativeEvent.event === 'explorer') { - const link = getExplorerLink(safeAddress, activeChain.blockExplorerUriTemplate) - Linking.openURL(link.href) - } + // Track analytics for supported actions (copy is already tracked via useCopyAndDispatchToast) + if (action !== 'copy') { + try { + const event = createSettingsMenuActionEvent(action) + trackEvent(event) + } catch (error) { + console.error('Error tracking settings menu action:', error) + } + } - if (nativeEvent.event === 'copy') { - console.log('copy') - copyAndDispatchToast(safeAddress) - } + if (nativeEvent.event === 'rename') { + router.push({ + pathname: '/signers/[address]', + params: { address: safeAddress, editMode: 'true', title: 'Rename safe' }, + }) + } - if (nativeEvent.event === 'remove') { - console.log('remove') - toBeImplemented() - } + if (nativeEvent.event === 'explorer') { + const link = getExplorerLink(safeAddress, activeChain.blockExplorerUriTemplate) + Linking.openURL(link.href) + } - if (nativeEvent.event === 'share') { - console.log('share') - toBeImplemented() - } - }} - color={color} - destructiveColor={colorError} - /> - ) -} + if (nativeEvent.event === 'copy') { + copyAndDispatchToast(safeAddress) + } -type MenuProps = { - onPressAction: (event: NativeActionEvent) => void - color: string - destructiveColor: string -} -const Menu = ({ onPressAction, color, destructiveColor }: MenuProps) => { - return ( - <MenuView - onPressAction={onPressAction} - actions={[ - { - id: 'rename', - title: 'Rename', - image: Platform.select({ - ios: 'pencil', - android: 'baseline_create_24', - }), - imageColor: Platform.select({ ios: color, android: '#000' }), - }, - { - id: 'explorer', - title: 'View on Explorer', - image: Platform.select({ - ios: 'link', - android: 'baseline_explore_24', - }), - imageColor: Platform.select({ ios: color, android: '#000' }), - }, - { - id: 'copy', - title: 'Copy address', - image: Platform.select({ - ios: 'doc.on.doc', - android: 'baseline_auto_awesome_motion_24', - }), - imageColor: Platform.select({ ios: color, android: '#000' }), - }, - { - id: 'share', - title: 'Share account', - image: Platform.select({ - ios: 'square.and.arrow.up.on.square', - android: 'baseline_arrow_outward_24', - }), - imageColor: Platform.select({ ios: color, android: '#000' }), - }, - { - id: 'remove', - title: 'Remove account', - attributes: { - destructive: true, - }, - image: Platform.select({ - ios: 'trash', - android: 'baseline_delete_24', - }), - imageColor: destructiveColor, - }, - ]} - shouldOpenOnLongPress={false} - > - <Button - testID={'settings-screen-header-more-settings-button'} - size={'$8'} - circular={true} - scaleSpace={1.5} - backgroundColor={'$backgroundSkeleton'} - > - <SafeFontIcon name={'options-horizontal'} size={16} /> - </Button> - </MenuView> + if (nativeEvent.event === 'remove') { + Alert.alert('Remove account', 'Are you sure you want to remove this account?', [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Remove', + onPress: async () => { + try { + await deleteSafe(safeAddress as Address) + toast.show(`The safe with address ${safeAddress} was deleted.`, { + native: true, + duration: 2000, + }) + } catch (error) { + if (error instanceof Error && error.message === 'User cancelled deletion') { + return + } + console.error('Error deleting safe:', error) + toast.show('Failed to delete safe. Please try again.', { + native: true, + duration: 3000, + }) + } + }, + style: 'destructive', + }, + ]) + } + + if (nativeEvent.event === 'share') { + router.push('/share') + } + }} + actions={[ + { + id: 'rename', + title: 'Rename', + image: Platform.select({ + ios: 'pencil', + android: 'baseline_create_24', + }), + imageColor: Platform.select({ ios: color, android: '#000' }), + }, + { + id: 'explorer', + title: 'View on explorer', + image: Platform.select({ + ios: 'link', + android: 'baseline_explore_24', + }), + imageColor: Platform.select({ ios: color, android: '#000' }), + }, + { + id: 'copy', + title: 'Copy address', + image: Platform.select({ + ios: 'doc.on.doc', + android: 'baseline_auto_awesome_motion_24', + }), + imageColor: Platform.select({ ios: color, android: '#000' }), + }, + { + id: 'share', + title: 'Share account', + image: Platform.select({ + ios: 'square.and.arrow.up.on.square', + android: 'baseline_arrow_outward_24', + }), + imageColor: Platform.select({ ios: color, android: '#000' }), + }, + { + id: 'remove', + title: 'Remove account', + attributes: { + destructive: true, + }, + image: Platform.select({ + ios: 'trash', + android: 'baseline_delete_24', + }), + imageColor: colorError, + }, + ]} + > + <Pressable hitSlop={6} testID={'settings-screen-header-more-settings-button'}> + <View + backgroundColor={'$backgroundSkeleton'} + alignItems={'center'} + justifyContent={'center'} + borderRadius={16} + height={32} + width={32} + > + <SafeFontIcon name={'options-horizontal'} size={20} color={'$color'} /> + </View> + </Pressable> + </FloatingMenu> + </View> + </Theme> ) } diff --git a/apps/mobile/src/features/Share/Share.container.tsx b/apps/mobile/src/features/Share/Share.container.tsx new file mode 100644 index 0000000000..28b9a6a032 --- /dev/null +++ b/apps/mobile/src/features/Share/Share.container.tsx @@ -0,0 +1,13 @@ +import { ShareView } from '@/src/features/Share/components' +import { selectSafeChains } from '@/src/store/safesSlice' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { useAppSelector } from '@/src/store/hooks' +import { RootState } from '@/src/store' +import { getChainsByIds } from '@/src/store/chains' + +export const ShareContainer = () => { + const activeSafe = useDefinedActiveSafe() + const chainsIds = useAppSelector((state: RootState) => selectSafeChains(state, activeSafe.address)) + const safeAvailableOnChains = useAppSelector((state: RootState) => getChainsByIds(state, chainsIds)) + return <ShareView activeSafe={activeSafe} availableChains={safeAvailableOnChains} /> +} diff --git a/apps/mobile/src/features/Share/components/ShareView.test.tsx b/apps/mobile/src/features/Share/components/ShareView.test.tsx new file mode 100644 index 0000000000..c1673bfbd8 --- /dev/null +++ b/apps/mobile/src/features/Share/components/ShareView.test.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import { render, fireEvent, waitFor } from '@/src/tests/test-utils' +import { ShareView } from './ShareView' +import Share from 'react-native-share' +import { useCopyAndDispatchToast } from '@/src/hooks/useCopyAndDispatchToast' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { SafeInfo } from '@/src/types/address' + +// Mock react-native-share +jest.mock('react-native-share', () => ({ + open: jest.fn().mockResolvedValue({}), +})) + +// Mock the copy hook +jest.mock('@/src/hooks/useCopyAndDispatchToast', () => ({ + useCopyAndDispatchToast: jest.fn(), +})) + +// Mock chain names util to return a fixed string +jest.mock('@/src/utils/chains', () => ({ + getAvailableChainsNames: jest.fn(() => 'Ethereum and Polygon'), +})) + +jest.mock('@tamagui/toast', () => ({ + ToastViewport: () => null, +})) + +const mockCopyAndDispatchToast = jest.fn() + +describe('ShareView', () => { + beforeEach(() => { + ;(useCopyAndDispatchToast as jest.Mock).mockReturnValue(mockCopyAndDispatchToast) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('renders safe address and chain names when activeSafe is provided', () => { + const activeSafe = { address: '0x123', chainId: '1' } as SafeInfo + const availableChains = [{ chainName: 'Ethereum' }, { chainName: 'Polygon' }] as Chain[] + const { getByText } = render(<ShareView activeSafe={activeSafe} availableChains={availableChains} />, { + initialStore: { + addressBook: { + contacts: { + [activeSafe.address]: { name: 'Test Safe', value: activeSafe.address, chainIds: [] }, + }, + selectedContact: null, + }, + }, + }) + expect(getByText(activeSafe.address)).toBeTruthy() + // Check that the chains text is rendered as expected. + expect(getByText(/Ethereum and Polygon/)).toBeTruthy() + }) + + it('calls Share.open with the correct parameters when share button is pressed', async () => { + const activeSafe = { address: '0x123', chainId: '1' } as SafeInfo + const availableChains = [{ chainName: 'Ethereum' }] as Chain[] + const { getByText } = render(<ShareView activeSafe={activeSafe} availableChains={availableChains} />) + const shareButton = getByText('Share') + fireEvent.press(shareButton) + await waitFor(() => { + expect(Share.open).toHaveBeenCalledWith({ + title: 'Your safe Address', + message: activeSafe.address, + }) + }) + }) + + it('calls copyAndDispatchToast with safe address when copy button is pressed', () => { + const activeSafe = { address: '0x123', chainId: '1' } as SafeInfo + const availableChains = [{ chainName: 'Ethereum' }] as Chain[] + const { getByText } = render(<ShareView activeSafe={activeSafe} availableChains={availableChains} />) + const copyButton = getByText('Copy') + fireEvent.press(copyButton) + expect(mockCopyAndDispatchToast).toHaveBeenCalledWith(activeSafe.address) + }) +}) diff --git a/apps/mobile/src/features/Share/components/ShareView.tsx b/apps/mobile/src/features/Share/components/ShareView.tsx new file mode 100644 index 0000000000..a5ff82917e --- /dev/null +++ b/apps/mobile/src/features/Share/components/ShareView.tsx @@ -0,0 +1,143 @@ +import { H3, Text, View, XStack, YStack } from 'tamagui' +import { SafeInfo } from '@/src/types/address' +import { Container } from '@/src/components/Container' +import Share from 'react-native-share' +import { SafeButton } from '@/src/components/SafeButton' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { Identicon } from '@/src/components/Identicon' +import QRCodeStyled from 'react-native-qrcode-styled' +import { StyleSheet } from 'react-native' +import { useCopyAndDispatchToast } from '@/src/hooks/useCopyAndDispatchToast' +import React, { useCallback } from 'react' +import { ToastViewport } from '@tamagui/toast' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { ChainsDisplay } from '@/src/components/ChainsDisplay' +import { getAvailableChainsNames } from '@/src/utils/chains' +import { useAppSelector } from '@/src/store/hooks' +import { selectContactByAddress } from '@/src/store/addressBookSlice' + +type ShareViewProps = { + activeSafe: SafeInfo + availableChains: Chain[] +} + +export const ShareView = ({ activeSafe, availableChains }: ShareViewProps) => { + const copyAndDispatchToast = useCopyAndDispatchToast() + const contact = useAppSelector(selectContactByAddress(activeSafe.address)) + + const safeAddress = activeSafe.address + + const onPressShare = async () => { + Share.open({ + title: 'Your safe Address', + message: safeAddress, + }).then((res) => { + // what to do with the result? + console.log(res) + }) + } + + const onPressCopy = useCallback(() => { + copyAndDispatchToast(safeAddress) + }, [safeAddress]) + + return ( + <> + <YStack flex={1}> + <YStack flex={1} justifyContent={'flex-end'} alignItems={'center'} marginBottom={'$6'}> + <H3 fontWeight={600}>{contact ? contact.name : 'Unnamed safe'}</H3> + </YStack> + <YStack flex={3} alignItems={'center'}> + <Container marginHorizontal={'$10'}> + <View> + <View style={styles.root}> + <QRCodeStyled + data={safeAddress} + style={styles.svg} + padding={22} + pieceSize={6} + pieceCornerType={'rounded'} + pieceBorderRadius={3} + isPiecesGlued + color={'#000'} + errorCorrectionLevel={'H'} + innerEyesOptions={styles.innerEyesOptions} + outerEyesOptions={styles.outerEyesOptions} + /> + + <View style={styles.logoContainer}> + <Identicon address={safeAddress} size={56} /> + </View> + </View> + </View> + <Text + marginTop={'$4'} + fontSize={16} + lineHeight={22} + letterSpacing={0.2} + color={'$colorLight'} + textAlign={'center'} + > + {activeSafe.address} + </Text> + <View alignItems={'center'} marginTop={'$4'}> + <ChainsDisplay activeChainId={activeSafe.chainId} chains={availableChains} max={5} /> + </View> + </Container> + <XStack gap={'$3'} marginTop={'$6'}> + <SafeButton size={'$sm'} onPress={onPressShare} icon={<SafeFontIcon name={'export'} size={16} />} secondary> + Share + </SafeButton> + <SafeButton size={'$sm'} onPress={onPressCopy} icon={<SafeFontIcon name={'copy'} size={16} />} secondary> + Copy + </SafeButton> + </XStack> + </YStack> + <YStack flex={1} justifyContent={'flex-end'} alignItems={'center'}> + <Text color={'$colorLight'} textAlign={'center'} fontSize={'$3'}> + This account is only available on + <Text color={'$color'} fontWeight={600}> + {' '} + {getAvailableChainsNames(availableChains)}. + </Text> + </Text> + </YStack> + </YStack> + <ToastViewport multipleToasts={false} left={0} right={0} /> + </> + ) +} + +const styles = StyleSheet.create({ + root: { + justifyContent: 'center', + alignItems: 'center', + }, + svg: { + backgroundColor: '#fff', + borderRadius: 20, + overflow: 'hidden', + flex: 1, + }, + logoContainer: { + position: 'absolute', + width: 64, + height: 64, + backgroundColor: '#fff', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 32, + }, + logo: { + width: '90%', + height: '90%', + top: -2, + }, + innerEyesOptions: { + borderRadius: 0, + color: '#000', + }, + outerEyesOptions: { + borderRadius: 15, + }, +}) diff --git a/apps/mobile/src/features/Share/components/index.ts b/apps/mobile/src/features/Share/components/index.ts new file mode 100644 index 0000000000..e3db93936f --- /dev/null +++ b/apps/mobile/src/features/Share/components/index.ts @@ -0,0 +1 @@ +export { ShareView } from './ShareView' diff --git a/apps/mobile/src/features/Share/index.ts b/apps/mobile/src/features/Share/index.ts new file mode 100644 index 0000000000..63d884fb5d --- /dev/null +++ b/apps/mobile/src/features/Share/index.ts @@ -0,0 +1 @@ +export { ShareContainer } from './Share.container' diff --git a/apps/mobile/src/features/Signer/Signer.container.tsx b/apps/mobile/src/features/Signer/Signer.container.tsx new file mode 100644 index 0000000000..04ffe7bda5 --- /dev/null +++ b/apps/mobile/src/features/Signer/Signer.container.tsx @@ -0,0 +1,104 @@ +import { SignerView } from '@/src/features/Signer/components/SignerView' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { selectContactByAddress, upsertContact } from '@/src/store/addressBookSlice' +import { selectSignerHasPrivateKey } from '@/src/store/signersSlice' +import React, { useCallback, useState } from 'react' +import { Alert, Linking } from 'react-native' +import { selectActiveChain } from '@/src/store/chains' +import { getHashedExplorerUrl } from '@safe-global/utils/utils/gateway' +import { usePreventLeaveScreen } from '@/src/hooks/usePreventLeaveScreen' +import { SubmitHandler, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { FormValues } from '@/src/features/Signer/types' +import { formSchema } from '@/src/features/Signer/schema' + +export const SignerContainer = () => { + const { address } = useLocalSearchParams<{ address: string }>() + const router = useRouter() + const dispatch = useAppDispatch() + const activeChain = useAppSelector(selectActiveChain) + const local = useLocalSearchParams<{ editMode: string }>() + const contact = useAppSelector(selectContactByAddress(address)) + const hasPrivateKey = useAppSelector(selectSignerHasPrivateKey(address)) + const [editMode, setEditMode] = useState(Boolean(local.editMode)) + + usePreventLeaveScreen(editMode) + + const onPressExplorer = useCallback(() => { + if (!activeChain) { + return + } + const url = getHashedExplorerUrl(address, activeChain.blockExplorerUriTemplate) + Linking.openURL(url) + }, [address, activeChain]) + + const onPressViewPrivateKey = useCallback(() => { + router.push(`/signers/${address}/private-key`) + }, [address, router]) + + // Initialize the form with React Hook Form and Zod schema resolver + const { + control, + handleSubmit, + watch, + formState: { errors, dirtyFields, isValid }, + reset, + clearErrors, + } = useForm<FormValues>({ + resolver: zodResolver(formSchema), + mode: 'onChange', + defaultValues: { + name: contact?.name || '', + }, + }) + + const onSubmit: SubmitHandler<FormValues> = (data) => { + dispatch(upsertContact({ ...contact, value: address, name: data.name, chainIds: contact?.chainIds || [] })) + + clearErrors() + reset(data, { keepValues: true }) + } + + const onPressEdit = useCallback(() => { + if (editMode) { + if (!isValid) { + Alert.alert('Cancel edit', 'Your form contains errors. Do you want to cancel the edit?', [ + { + text: 'No', + onPress: () => console.log('Cancel Pressed'), + }, + { + text: 'Yes', + onPress: () => { + clearErrors() + reset() + setEditMode(() => !editMode) + }, + }, + ]) + + return + } + handleSubmit(onSubmit)() + } + setEditMode(() => !editMode) + }, [editMode, handleSubmit, onSubmit, isValid]) + + const formName = watch('name') + + return ( + <SignerView + signerAddress={address} + onPressExplorer={onPressExplorer} + onPressEdit={onPressEdit} + onPressViewPrivateKey={hasPrivateKey ? onPressViewPrivateKey : undefined} + editMode={editMode} + hasPrivateKey={hasPrivateKey} + control={control} + dirtyFields={dirtyFields} + errors={errors} + name={formName || contact?.name || ''} + /> + ) +} diff --git a/apps/mobile/src/features/Signer/components/SignerHeader.tsx b/apps/mobile/src/features/Signer/components/SignerHeader.tsx new file mode 100644 index 0000000000..3637b2b75d --- /dev/null +++ b/apps/mobile/src/features/Signer/components/SignerHeader.tsx @@ -0,0 +1,7 @@ +import { useLocalSearchParams } from 'expo-router' +import { Text } from 'tamagui' + +export const SignerHeader = () => { + const { title = 'Signer' } = useLocalSearchParams<{ title: string }>() + return <Text>{title}</Text> +} diff --git a/apps/mobile/src/features/Signer/components/SignerView.tsx b/apps/mobile/src/features/Signer/components/SignerView.tsx new file mode 100644 index 0000000000..90cd9f1ac0 --- /dev/null +++ b/apps/mobile/src/features/Signer/components/SignerView.tsx @@ -0,0 +1,114 @@ +import { ScrollView, View, Text, H2, XStack, YStack } from 'tamagui' +import { Identicon } from '@/src/components/Identicon' +import { type Address } from '@/src/types/address' +import React from 'react' +import { Container } from '@/src/components/Container' +import { CopyButton } from '@/src/components/CopyButton' +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import { KeyboardAvoidingView, Pressable, TouchableOpacity } from 'react-native' +import { SafeButton } from '@/src/components/SafeButton' +import { SafeInputWithLabel } from '@/src/components/SafeInput/SafeInputWithLabel' +import { Controller, FieldNamesMarkedBoolean, type Control, type FieldErrors } from 'react-hook-form' +import { type FormValues } from '@/src/features/Signer/types' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { SafeListItem } from '@/src/components/SafeListItem' +type Props = { + signerAddress: string + onPressExplorer: () => void + onPressEdit: () => void + onPressViewPrivateKey?: () => void + editMode: boolean + name: string + hasPrivateKey: boolean + control: Control<FormValues> + errors: FieldErrors<FormValues> + dirtyFields: FieldNamesMarkedBoolean<FormValues> +} + +export const SignerView = ({ + control, + errors, + dirtyFields, + signerAddress, + onPressExplorer, + onPressEdit, + onPressViewPrivateKey, + editMode, + name, + hasPrivateKey, +}: Props) => { + const { bottom, top } = useSafeAreaInsets() + + return ( + <YStack flex={1}> + <ScrollView flex={1}> + <View justifyContent={'center'} alignItems={'center'}> + <Identicon address={signerAddress as Address} size={56} /> + </View> + <View justifyContent={'center'} alignItems={'center'} marginTop={'$4'}> + <H2 numberOfLines={1} maxWidth={300} marginTop={'$2'} textAlign={'center'}> + {name || 'Unnamed signer'} + </H2> + </View> + + <View marginTop={'$4'}> + <Controller + control={control} + name="name" + render={({ field: { onChange, onBlur, value } }) => { + return ( + <SafeInputWithLabel + label={'Name'} + value={editMode ? value : value || (!dirtyFields.name ? 'Unnamed signer' : '')} + onBlur={onBlur} + disabled={!editMode} + onChangeText={onChange} + placeholder={'Enter signer name'} + error={dirtyFields.name && !!errors.name} + success={dirtyFields.name && !errors.name} + right={ + <TouchableOpacity onPress={onPressEdit} hitSlop={8}> + <SafeFontIcon name={editMode ? 'close' : 'edit'} color="$textSecondaryLight" size={16} /> + </TouchableOpacity> + } + /> + ) + }} + /> + {errors.name && <Text color={'$error'}>{errors.name.message}</Text>} + </View> + + <Container marginTop={'$4'} rowGap={'$1'}> + <Text color={'$colorSecondary'}>Address</Text> + <XStack columnGap={'$3'}> + <Text flex={1}>{signerAddress}</Text> + <YStack justifyContent={'flex-start'}> + <XStack alignItems={'center'} gap="$1"> + <CopyButton value={signerAddress} color={'$colorSecondary'} hitSlop={2} /> + <Pressable onPress={onPressExplorer} hitSlop={2}> + <SafeFontIcon name={'external-link'} size={14} color={'$colorSecondary'} /> + </Pressable> + </XStack> + </YStack> + </XStack> + </Container> + + {hasPrivateKey && !editMode && ( + <View marginTop={'$4'} borderTopWidth={1} borderColor={'$borderLight'} paddingTop={'$4'}> + <SafeListItem + label="View private key" + rightNode={<SafeFontIcon name="chevron-right" />} + onPress={onPressViewPrivateKey} + pressStyle={{ opacity: 0.2 }} + /> + </View> + )} + </ScrollView> + <KeyboardAvoidingView behavior="padding" keyboardVerticalOffset={top + bottom}> + <View paddingHorizontal={'$4'} paddingTop={'$2'} paddingBottom={bottom ?? 60}> + {editMode ? <SafeButton onPress={onPressEdit}>Save</SafeButton> : null} + </View> + </KeyboardAvoidingView> + </YStack> + ) +} diff --git a/apps/mobile/src/features/Signer/index.ts b/apps/mobile/src/features/Signer/index.ts new file mode 100644 index 0000000000..d53f007026 --- /dev/null +++ b/apps/mobile/src/features/Signer/index.ts @@ -0,0 +1 @@ +export { SignerContainer } from './Signer.container' diff --git a/apps/mobile/src/features/Signer/schema.ts b/apps/mobile/src/features/Signer/schema.ts new file mode 100644 index 0000000000..33323025e7 --- /dev/null +++ b/apps/mobile/src/features/Signer/schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const formSchema = z.object({ + name: z + .string() + .min(1, { message: 'Name must be at least 1 characters long' }) + .max(20, { message: 'Name must be at most 20 characters long' }), +}) diff --git a/apps/mobile/src/features/Signer/types.ts b/apps/mobile/src/features/Signer/types.ts new file mode 100644 index 0000000000..31f9f89b56 --- /dev/null +++ b/apps/mobile/src/features/Signer/types.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' +import { formSchema } from '@/src/features/Signer/schema' + +export type FormValues = z.infer<typeof formSchema> diff --git a/apps/mobile/src/features/Signers/Signers.container.tsx b/apps/mobile/src/features/Signers/Signers.container.tsx index aea2b4335b..0c7d2d3b8d 100644 --- a/apps/mobile/src/features/Signers/Signers.container.tsx +++ b/apps/mobile/src/features/Signers/Signers.container.tsx @@ -4,7 +4,6 @@ import React, { useMemo } from 'react' import { SafeButton } from '@/src/components/SafeButton' import { SignersList } from './components/SignersList' -import { Dimensions } from 'react-native' import { useSignersGroupService } from './hooks/useSignersGroupService' import { useRouter } from 'expo-router' @@ -25,18 +24,18 @@ export const SignersContainer = () => { }, [group]) return ( - <View gap="$6"> - <View height={Dimensions.get('window').height - 230}> + <View gap="$6" testID={'signers-screen'} flex={1}> + <View flex={1}> <SignersList isFetching={isFetching} - hasLocalSingers={!!group.imported?.data.length} + hasLocalSigners={!!group.imported?.data.length} signersGroup={signersSections} /> </View> - <View paddingHorizontal={'$3'}> - <SafeButton onPress={onImportSigner} label="Import signer" /> - </View> + <SafeButton onPress={onImportSigner} testID={'import-signer-button'}> + Import signer + </SafeButton> </View> ) } diff --git a/apps/mobile/src/features/Signers/components/SignersList/SignersList.tsx b/apps/mobile/src/features/Signers/components/SignersList/SignersList.tsx index 719d4f62e2..5f50e1086b 100644 --- a/apps/mobile/src/features/Signers/components/SignersList/SignersList.tsx +++ b/apps/mobile/src/features/Signers/components/SignersList/SignersList.tsx @@ -1,8 +1,7 @@ -import React, { useMemo } from 'react' +import React from 'react' import { SafeListItem } from '@/src/components/SafeListItem' -import { getTokenValue, Spinner } from 'tamagui' - +import { Loader } from '@/src/components/Loader' import { SectionList } from 'react-native' import { useCallback } from 'react' import { useScrollableHeader } from '@/src/navigation/useScrollableHeader' @@ -13,6 +12,7 @@ import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transacti import SignersListItem from './SignersListItem' export type SignerSection = { + id: string title: string data: SafeState['owners'] } @@ -22,12 +22,14 @@ const keyExtractor = (item: AddressInfo, index: number) => item.value + index interface SignersListProps { signersGroup: SignerSection[] isFetching: boolean - hasLocalSingers: boolean + hasLocalSigners: boolean + navbarTitle?: string } -export function SignersList({ signersGroup, isFetching, hasLocalSingers }: SignersListProps) { +export function SignersList({ signersGroup, isFetching, hasLocalSigners, navbarTitle }: SignersListProps) { + const title = navbarTitle || 'Signers' const { handleScroll } = useScrollableHeader({ - children: <NavBarTitle>Signers</NavBarTitle>, + children: <NavBarTitle>{title}</NavBarTitle>, }) const renderItem = useCallback( @@ -37,12 +39,9 @@ export function SignersList({ signersGroup, isFetching, hasLocalSingers }: Signe [signersGroup], ) - const ListHeaderComponent = useCallback(() => <SignersListHeader withAlert={!hasLocalSingers} />, [hasLocalSingers]) - const contentContainerStyle = useMemo( - () => ({ - paddingHorizontal: getTokenValue('$3'), - }), - [], + const ListHeaderComponent = useCallback( + () => <SignersListHeader sectionTitle={title} withAlert={!hasLocalSigners} />, + [hasLocalSigners], ) return ( @@ -53,11 +52,10 @@ export function SignersList({ signersGroup, isFetching, hasLocalSingers }: Signe stickySectionHeadersEnabled contentInsetAdjustmentBehavior="automatic" sections={signersGroup} - ListFooterComponent={isFetching ? <Spinner size="small" color="$color" /> : undefined} + ListFooterComponent={isFetching ? <Loader size={24} color="$color" /> : undefined} keyExtractor={keyExtractor} renderItem={renderItem} scrollEventThrottle={16} - contentContainerStyle={contentContainerStyle} renderSectionHeader={({ section: { title } }) => <SafeListItem.Header title={title} />} /> ) diff --git a/apps/mobile/src/features/Signers/components/SignersList/SignersListHeader.tsx b/apps/mobile/src/features/Signers/components/SignersList/SignersListHeader.tsx index 7b535df099..007e7baa94 100644 --- a/apps/mobile/src/features/Signers/components/SignersList/SignersListHeader.tsx +++ b/apps/mobile/src/features/Signers/components/SignersList/SignersListHeader.tsx @@ -5,14 +5,15 @@ import { SectionTitle } from '@/src/components/Title' interface SignersListHeaderProps { withAlert: boolean + sectionTitle?: string } -export function SignersListHeader({ withAlert }: SignersListHeaderProps) { +export function SignersListHeader({ withAlert, sectionTitle }: SignersListHeaderProps) { return ( <View gap="$6"> <SectionTitle paddingHorizontal={'$0'} - title="Signers" + title={sectionTitle || 'Signers'} description="Signers have full control over the account, they can propose, sign and execute the transactions, as well as reject them." /> @@ -21,7 +22,8 @@ export function SignersListHeader({ withAlert }: SignersListHeaderProps) { <Alert type="warning" message="Before you import signers..." - info="Make sure to import signers from this list only. Others will not be imported." + info={`Make sure to import signers from this list only.\nOthers will not be imported.`} + orientation="left" /> </View> )} diff --git a/apps/mobile/src/features/Signers/components/SignersList/SignersListItem.tsx b/apps/mobile/src/features/Signers/components/SignersList/SignersListItem.tsx index 62486e4281..45307433d9 100644 --- a/apps/mobile/src/features/Signers/components/SignersList/SignersListItem.tsx +++ b/apps/mobile/src/features/Signers/components/SignersList/SignersListItem.tsx @@ -1,6 +1,5 @@ import React from 'react' -import { shortenAddress } from '@/src/utils/formatters' -import { MenuView } from '@react-native-menu/menu' +import { MenuView, NativeActionEvent, MenuAction } from '@react-native-menu/menu' import { useSignersActions } from './hooks/useSignersActions' import { SafeFontIcon } from '@/src/components/SafeFontIcon' import { SignersCard } from '@/src/components/transactions-list/Card/SignersCard' @@ -8,7 +7,12 @@ import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transacti import { SignerSection } from './SignersList' import { View } from 'tamagui' import { TouchableOpacity } from 'react-native-gesture-handler' -import { useRouter } from 'expo-router' +import { useColorScheme } from 'react-native' +import { useAppSelector } from '@/src/store/hooks' +import { selectContactByAddress } from '@/src/store/addressBookSlice' +import { useCopyAndDispatchToast } from '@/src/hooks/useCopyAndDispatchToast' +import { router, useLocalSearchParams } from 'expo-router' +import logger from '@/src/utils/logger' interface SignersListItemProps { item: AddressInfo @@ -17,34 +21,104 @@ interface SignersListItemProps { } function SignersListItem({ item, index, signersGroup }: SignersListItemProps) { - const router = useRouter() - const actions = useSignersActions() + const colorScheme = useColorScheme() + const contact = useAppSelector(selectContactByAddress(item.value)) + const local = useLocalSearchParams<{ safeAddress: string; chainId: string; import_safe: string }>() + + // Check if the current item belongs to the 'Imported signers' section + const isMySigner = signersGroup.some( + (section) => section.id === 'imported_signers' && section.data.some((signer) => signer.value === item.value), + ) + + const fullActions = useSignersActions(isMySigner) // This was necessary to prevent typescript from complaining about the actions array + // Filter out any false values to ensure the array type matches MenuAction[] + const actions = fullActions.filter(Boolean) as MenuAction[] const isLastItem = signersGroup.some((section) => section.data.length === index + 1) + const copy = useCopyAndDispatchToast() + + const redirectToDetails = (editMode?: boolean) => { + router.push({ + pathname: '/signers/[address]', + params: { address: item.value, editMode: editMode?.toString() }, + }) + } + + const redirectToImport = () => { + router.push({ + pathname: '/import-signers', + params: { + safeAddress: local.safeAddress, + chainId: local.chainId, + import_safe: local.import_safe, + }, + }) + } - const onPress = () => { - router.push(`/signers/${item.value}`) + const handleItemPress = () => { + if (local.import_safe && !isMySigner) { + return redirectToImport() + } + + return redirectToDetails() + } + + const onPressMenuAction = ({ nativeEvent }: NativeActionEvent) => { + if (nativeEvent.event === 'rename') { + return redirectToDetails(true) + } + + if (nativeEvent.event === 'copy') { + return copy(item.value as string) + } + + if (nativeEvent.event === 'import' && !isMySigner) { + return redirectToImport() + } + + logger.error('No action found for nativeEvent', nativeEvent) } return ( - <TouchableOpacity onPress={onPress}> + <View position="relative"> + <TouchableOpacity onPress={handleItemPress} testID={`signer-${item.value}`}> + <View + backgroundColor={colorScheme === 'dark' ? '$backgroundPaper' : '$background'} + borderTopRightRadius={index === 0 ? '$4' : undefined} + borderTopLeftRadius={index === 0 ? '$4' : undefined} + borderBottomRightRadius={isLastItem ? '$4' : undefined} + borderBottomLeftRadius={isLastItem ? '$4' : undefined} + > + <SignersCard + name={contact ? (contact.name as string) : (item.name as string)} + address={item.value as `0x${string}`} + /> + </View> + </TouchableOpacity> + <View - backgroundColor={'$backgroundPaper'} - borderTopRightRadius={index === 0 ? '$4' : undefined} - borderTopLeftRadius={index === 0 ? '$4' : undefined} - borderBottomRightRadius={isLastItem ? '$4' : undefined} - borderBottomLeftRadius={isLastItem ? '$4' : undefined} + position="absolute" + right={0} + top={0} + height={'100%'} + display="flex" + alignItems="center" + justifyContent="center" > - <SignersCard - name={item.name ?? shortenAddress(item.value)} - address={item.value as `0x${string}`} - rightNode={ - <MenuView onPressAction={console.log} actions={actions}> - <SafeFontIcon name="options-horizontal" /> - </MenuView> - } - /> + <MenuView + onPressAction={onPressMenuAction} + actions={actions} + style={{ + height: '100%', + justifyContent: 'center', + alignItems: 'center', + paddingRight: 16, + paddingLeft: 16, + }} + > + <SafeFontIcon name="options-horizontal" /> + </MenuView> </View> - </TouchableOpacity> + </View> ) } diff --git a/apps/mobile/src/features/Signers/components/SignersList/hooks/useSignersActions.ts b/apps/mobile/src/features/Signers/components/SignersList/hooks/useSignersActions.ts index d1a74f2918..4334467b28 100644 --- a/apps/mobile/src/features/Signers/components/SignersList/hooks/useSignersActions.ts +++ b/apps/mobile/src/features/Signers/components/SignersList/hooks/useSignersActions.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { Platform } from 'react-native' import { useTheme } from 'tamagui' -export const useSignersActions = () => { +export const useSignersActions = (disableImport: boolean) => { const theme = useTheme() const color = theme.color?.get() const actions = useMemo( @@ -17,7 +17,16 @@ export const useSignersActions = () => { imageColor: Platform.select({ ios: color, android: '#000' }), }, { - id: 'share', + id: 'copy', + title: 'Copy address', + image: Platform.select({ + ios: 'doc.on.doc', + android: 'baseline_content_copy_24', + }), + imageColor: Platform.select({ ios: color, android: '#000' }), + }, + !disableImport && { + id: 'import', title: 'Import signer', image: Platform.select({ ios: 'square.and.arrow.up.on.square', @@ -26,7 +35,7 @@ export const useSignersActions = () => { imageColor: Platform.select({ ios: color, android: '#000' }), }, ], - [color], + [color, disableImport], ) return actions diff --git a/apps/mobile/src/features/Signers/constants.ts b/apps/mobile/src/features/Signers/constants.ts index 8ea64271c0..cd33655729 100644 --- a/apps/mobile/src/features/Signers/constants.ts +++ b/apps/mobile/src/features/Signers/constants.ts @@ -2,10 +2,12 @@ import { SignerSection } from './components/SignersList/SignersList' export const groupedSigners: Record<string, SignerSection> = { imported: { - title: 'Imported signers', + id: 'imported_signers', + title: 'My signers', data: [], }, notImported: { + id: 'not_imported_signers', title: 'Not imported signers', data: [], }, diff --git a/apps/mobile/src/features/Signers/hooks/useSignersGroupService.ts b/apps/mobile/src/features/Signers/hooks/useSignersGroupService.ts index 369cc45a02..07121eab73 100644 --- a/apps/mobile/src/features/Signers/hooks/useSignersGroupService.ts +++ b/apps/mobile/src/features/Signers/hooks/useSignersGroupService.ts @@ -1,37 +1,39 @@ import { useMemo } from 'react' -import { useSafesGetSafeV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { AddressInfo, useSafesGetSafeV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { useAppSelector } from '@/src/store/hooks' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { groupedSigners } from '../constants' import { selectSigners } from '@/src/store/signersSlice' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { extractSignersFromSafes } from '../../ImportReadOnly/helpers/safes' export const useSignersGroupService = () => { - const activeSafe = useAppSelector(selectActiveSafe) + const activeSafe = useDefinedActiveSafe() const appSigners = useAppSelector(selectSigners) const { data, isFetching } = useSafesGetSafeV1Query({ safeAddress: activeSafe.address, chainId: activeSafe.chainId, }) - const group = useMemo(() => { - const sections = - data?.owners?.reduce<typeof groupedSigners>( - (acc, owner) => { - if (appSigners[owner.value]) { - acc.imported.data.push(owner) - } else { - acc.notImported.data.push(owner) - } - - return acc - }, - JSON.parse(JSON.stringify(groupedSigners)), - ) || {} - - return sections - }, [data?.owners, appSigners]) + const signers = extractSignersFromSafes(data ? [data] : []) + const group = useMemo(() => groupSigners(Object.values(signers), appSigners), [signers, appSigners]) return { group, isFetching } } + +export const groupSigners = (owners: AddressInfo[] | undefined, appSigners: Record<string, AddressInfo>) => { + return ( + owners?.reduce<typeof groupedSigners>( + (acc, owner) => { + if (appSigners[owner.value]) { + acc.imported.data.push(owner) + } else { + acc.notImported.data.push(owner) + } + return acc + }, + JSON.parse(JSON.stringify(groupedSigners)), + ) || {} + ) +} diff --git a/apps/mobile/src/features/TransactionActions/TransactionActions.container.tsx b/apps/mobile/src/features/TransactionActions/TransactionActions.container.tsx new file mode 100644 index 0000000000..4bdb62178e --- /dev/null +++ b/apps/mobile/src/features/TransactionActions/TransactionActions.container.tsx @@ -0,0 +1,42 @@ +import { useLocalSearchParams } from 'expo-router' +import React from 'react' +import { ScrollView, View } from 'tamagui' +import { useTransactionsGetTransactionByIdV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +import { LargeHeaderTitle, NavBarTitle } from '@/src/components/Title' +import { useScrollableHeader } from '@/src/navigation/useScrollableHeader' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { Alert } from '@/src/components/Alert' + +import { LoadingTx } from '../ConfirmTx/components/LoadingTx' +import { TxActionsList } from './components/TxActionsList' + +export function TransactionActionsContainer() { + const { txId } = useLocalSearchParams<{ txId: string }>() + const activeSafe = useDefinedActiveSafe() + + const { data, isFetching, isError } = useTransactionsGetTransactionByIdV1Query({ + chainId: activeSafe.chainId, + id: txId, + }) + + const { handleScroll } = useScrollableHeader({ + children: <NavBarTitle>Actions</NavBarTitle>, + }) + + if (isError) { + return ( + <View margin="$4"> + <Alert type="error" message="Error fetching transaction actions" /> + </View> + ) + } + + return ( + <ScrollView onScroll={handleScroll}> + <LargeHeaderTitle paddingHorizontal="$4">Actions</LargeHeaderTitle> + + {isFetching || !data ? <LoadingTx /> : <TxActionsList txDetails={data} />} + </ScrollView> + ) +} diff --git a/apps/mobile/src/features/TransactionActions/components/TxActionsList.tsx b/apps/mobile/src/features/TransactionActions/components/TxActionsList.tsx new file mode 100644 index 0000000000..6e847e72ed --- /dev/null +++ b/apps/mobile/src/features/TransactionActions/components/TxActionsList.tsx @@ -0,0 +1,126 @@ +import React, { useMemo } from 'react' +import { Text, View, YStack } from 'tamagui' +import { TransactionDetails, MultiSend, NativeToken } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ActionValueDecoded, AddressInfoIndex } from '@safe-global/store/gateway/types' +import { formatVisualAmount, shortenAddress } from '@safe-global/utils/utils/formatters' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { router } from 'expo-router' +import { useLocalSearchParams } from 'expo-router' +import { Container } from '@/src/components/Container' +import { useTxTokenInfo } from '@safe-global/utils/hooks/useTxTokenInfo' +import { useAppSelector } from '@/src/store/hooks' +import { selectActiveChainCurrency } from '@/src/store/chains' +import { Identicon } from '@/src/components/Identicon' + +interface TxActionsListProps { + txDetails: TransactionDetails +} + +export const getActionName = (action: ActionValueDecoded | MultiSend, addressInfoIndex?: AddressInfoIndex): string => { + const contractName = (addressInfoIndex as AddressInfoIndex)?.[action.to]?.name + let name = shortenAddress(action.to) + + if (action.dataDecoded) { + name = action.dataDecoded.method + } + + return contractName ? `${contractName}: ${name}` : action.dataDecoded?.method || 'contract interaction' +} + +interface TxActionItemProps { + action: ActionValueDecoded | MultiSend + index: number + addressInfoIndex?: AddressInfoIndex + txData: TransactionDetails['txData'] +} + +const TxActionItem = ({ action, index, addressInfoIndex, txData }: TxActionItemProps) => { + const valueDecoded = txData?.dataDecoded?.parameters?.[0].valueDecoded + const tx = Array.isArray(valueDecoded) ? valueDecoded[index] : undefined + const nativeCurrency = useAppSelector(selectActiveChainCurrency) + + const transferTokenInfo = useTxTokenInfo( + tx?.data?.toString() || undefined, + tx?.value || undefined, + tx?.to || '', + nativeCurrency as NativeToken, + txData?.tokenInfoIndex ?? {}, + ) + + if (!tx) { + return null + } + + return ( + <> + <View alignItems="center" flexDirection="row" justifyContent="space-between" gap={'$2'} flexWrap="wrap"> + <View flexDirection="row" alignItems="center" gap={'$2'} maxWidth="80%"> + <SafeFontIcon name="transaction-contract" color="$colorSecondary" size={18} /> + <Text>{index + 1}</Text> + + {transferTokenInfo?.tokenInfo?.symbol ? ( + <View flexDirection="row" alignItems="center" gap={'$2'}> + <Text fontSize="$4" flex={1} numberOfLines={1} ellipsizeMode="tail"> + Send {formatVisualAmount(transferTokenInfo.transferValue, transferTokenInfo?.tokenInfo?.decimals, 6)}{' '} + {transferTokenInfo.tokenInfo.symbol} to + </Text> + <Identicon address={tx.to as `0x${string}`} size={20} />{' '} + <Text fontSize="$4" numberOfLines={1} ellipsizeMode="tail" flexShrink={1}> + {shortenAddress(tx.to)} + </Text> + </View> + ) : ( + <Text fontSize="$4" flexShrink={1} flexWrap="wrap"> + {getActionName(action, addressInfoIndex as AddressInfoIndex)} + </Text> + )} + </View> + + <SafeFontIcon name="chevron-right" size={18} /> + </View> + </> + ) +} + +export function TxActionsList({ txDetails }: TxActionsListProps) { + const { txId } = useLocalSearchParams<{ txId: string }>() + + const { dataDecoded, addressInfoIndex } = txDetails.txData || {} + + const onActionPress = (action: MultiSend) => { + router.push({ + pathname: '/action-details', + params: { + txId, + actionName: getActionName(action, addressInfoIndex as AddressInfoIndex), + action: JSON.stringify(action), + }, + }) + } + + const transaction = dataDecoded?.parameters?.find((action) => action.name === 'transactions' && action.valueDecoded) + const actions = useMemo(() => { + return Array.isArray(transaction?.valueDecoded) ? transaction?.valueDecoded : [transaction?.valueDecoded] + }, [transaction]) + + return ( + <YStack gap="$2" padding="$4"> + {actions?.map((action, index) => { + if (!action || !('operation' in action)) { + return null + } + return ( + <Container + key={`${getActionName(action, addressInfoIndex as AddressInfoIndex)}-${index}`} + padding="$42" + gap="$5" + borderRadius="$3" + onPress={() => onActionPress(action)} + > + <TxActionItem txData={txDetails.txData} action={action} index={index} /> + </Container> + ) + })} + </YStack> + ) +} diff --git a/apps/mobile/src/features/TransactionActions/index.ts b/apps/mobile/src/features/TransactionActions/index.ts new file mode 100644 index 0000000000..efe996db33 --- /dev/null +++ b/apps/mobile/src/features/TransactionActions/index.ts @@ -0,0 +1 @@ +export { TransactionActionsContainer } from './TransactionActions.container' diff --git a/apps/mobile/src/features/TransactionChecks/TransactionChecks.container.tsx b/apps/mobile/src/features/TransactionChecks/TransactionChecks.container.tsx new file mode 100644 index 0000000000..133dd3021f --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/TransactionChecks.container.tsx @@ -0,0 +1,78 @@ +import { useSimulation } from '@/src/features/TransactionChecks/tenderly/useSimulation' +import { useBlockaid } from '@/src/features/TransactionChecks/blockaid/useBlockaid' +import { createExistingTx } from '@/src/services/tx/tx-sender' +import extractTxInfo from '@/src/services/tx/extractTx' +import { useSafeInfo } from '@/src/hooks/useSafeInfo' +import { useEffect } from 'react' +import { RouteProp, useRoute } from '@react-navigation/native' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { + MultisigExecutionDetails, + useTransactionsGetTransactionByIdV1Query, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import React from 'react' +import { TransactionChecksView } from './components/TransactionChecksView' +import { useAppSelector } from '@/src/store/hooks' +import { selectActiveChain } from '@/src/store/chains' +import { isTxSimulationEnabled } from '@safe-global/utils/components/tx/security/tenderly/utils' +import { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { useTxSigner } from '@/src/features/ConfirmTx/hooks/useTxSigner' +import { FEATURES } from '@safe-global/utils/utils/chains' +import { useHasFeature } from '@/src/hooks/useHasFeature' + +export const TransactionChecksContainer = () => { + const { simulation, simulateTransaction, simulationLink, _simulationRequestStatus } = useSimulation() + const { scanTransaction, blockaidPayload, error: blockaidError, loading: blockaidLoading } = useBlockaid() + const activeSafe = useDefinedActiveSafe() + const safeInfo = useSafeInfo() + const chain = useAppSelector(selectActiveChain) + const simulationEnabled = chain ? isTxSimulationEnabled(chain) : false + const blockaidEnabled = useHasFeature(FEATURES.RISK_MITIGATION) ?? false + const txId = useRoute<RouteProp<{ params: { txId: string } }>>().params.txId + + const { data } = useTransactionsGetTransactionByIdV1Query({ + chainId: activeSafe.chainId, + id: txId, + }) + + const { activeSigner } = useTxSigner(data?.detailedExecutionInfo as MultisigExecutionDetails) + + useEffect(() => { + const getSafeTx = async () => { + if (!data) { + return + } + + const { txParams, signatures } = extractTxInfo(data, activeSafe.address) + + const safeTx = await createExistingTx(txParams, signatures) + const executionOwner = activeSigner ? activeSigner.value : safeInfo.safe.owners[0].value + + // Simulate with Tenderly if enabled + await Promise.all( + [ + simulationEnabled && + simulateTransaction({ + safe: safeInfo.safe as SafeInfo, + executionOwner, + transactions: safeTx, + }), + blockaidEnabled && + scanTransaction({ + data: safeTx, + signer: executionOwner, + }), + ].filter(Boolean), + ) + } + + getSafeTx() + }, [data]) + + return ( + <TransactionChecksView + tenderly={{ enabled: simulationEnabled, fetchStatus: _simulationRequestStatus, simulationLink, simulation }} + blockaid={{ enabled: blockaidEnabled, loading: blockaidLoading, error: blockaidError, payload: blockaidPayload }} + /> + ) +} diff --git a/apps/mobile/src/features/TransactionChecks/blockaid/useBlockaid.ts b/apps/mobile/src/features/TransactionChecks/blockaid/useBlockaid.ts new file mode 100644 index 0000000000..b3a6dad6a7 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/blockaid/useBlockaid.ts @@ -0,0 +1,85 @@ +import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' +import { useHasFeature } from '@/src/hooks/useHasFeature' +import useSafeInfo from '@/src/hooks/useSafeInfo' +import type { SecurityResponse } from '@safe-global/utils/services/security/modules/types' +import type { SafeTransaction } from '@safe-global/types-kit' + +import { useCallback, useState } from 'react' +import { + BlockaidModule, + type BlockaidModuleResponse, +} from '@safe-global/utils/services/security/modules/BlockaidModule' +import { FEATURES } from '@safe-global/utils/utils/chains' +import Logger from '@/src/utils/logger' +import { asError } from '@safe-global/utils/services/exceptions/utils' + +const BlockaidModuleInstance = new BlockaidModule() + +const DEFAULT_ERROR_MESSAGE = 'Unavailable' + +export type BlockaidScanParams = { + data: SafeTransaction | TypedData + signer: string + origin?: string +} + +export type UseBlockaidReturn = { + scanTransaction: (params: BlockaidScanParams) => Promise<void> + blockaidPayload: SecurityResponse<BlockaidModuleResponse> | undefined + error: Error | undefined + loading: boolean + resetBlockaid: () => void +} + +export const useBlockaid = (): UseBlockaidReturn => { + const { safe, safeAddress } = useSafeInfo() + const isFeatureEnabled = useHasFeature(FEATURES.RISK_MITIGATION) + + const [blockaidPayload, setBlockaidPayload] = useState<SecurityResponse<BlockaidModuleResponse> | undefined>() + const [error, setError] = useState<Error | undefined>() + const [loading, setLoading] = useState<boolean>(false) + + const resetBlockaid = useCallback(() => { + setBlockaidPayload(undefined) + setError(undefined) + setLoading(false) + }, []) + + const scanTransaction = useCallback( + async (params: BlockaidScanParams) => { + if (!isFeatureEnabled || !safeAddress) { + return + } + + setLoading(true) + setError(undefined) + + try { + const result = await BlockaidModuleInstance.scanTransaction({ + chainId: Number(safe.chainId), + data: params.data, + safeAddress, + walletAddress: params.signer, + threshold: safe.threshold, + origin: params.origin, + }) + + setBlockaidPayload(result) + } catch (err) { + Logger.error(asError(err).message) + setError(new Error(DEFAULT_ERROR_MESSAGE)) + } finally { + setLoading(false) + } + }, + [safe.chainId, safe.threshold, safeAddress, isFeatureEnabled], + ) + + return { + scanTransaction, + blockaidPayload, + error, + loading, + resetBlockaid, + } +} diff --git a/apps/mobile/src/features/TransactionChecks/components/TransactionChecksView.tsx b/apps/mobile/src/features/TransactionChecks/components/TransactionChecksView.tsx new file mode 100644 index 0000000000..2b6edce2c5 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/components/TransactionChecksView.tsx @@ -0,0 +1,117 @@ +import React from 'react' +import { LargeHeaderTitle, NavBarTitle } from '@/src/components/Title' +import { SafeButton } from '@/src/components/SafeButton' +import { Container } from '@/src/components/Container' +import { FETCH_STATUS, TenderlySimulation } from '@safe-global/utils/components/tx/security/tenderly/types' +import { Linking } from 'react-native' +import { View, ScrollView, Text, XStack, YStack } from 'tamagui' +import { useScrollableHeader } from '@/src/navigation/useScrollableHeader' +import { CircleSnail } from 'react-native-progress' +import { Badge } from '@/src/components/Badge' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { SecurityResponse } from '@safe-global/utils/services/security/modules/types' +import { BlockaidModuleResponse } from '@safe-global/utils/services/security/modules/BlockaidModule' +import { BlockaidBalanceChanges } from './blockaid/balance/BlockaidBalanceChanges' +import { BlockaidWarning } from './blockaid/scans/BlockaidWarning' +import { InfoSheet } from '@/src/components/InfoSheet' + +type Props = { + tenderly: { + enabled: boolean + fetchStatus: FETCH_STATUS + simulationLink: string + simulation?: TenderlySimulation + } + blockaid: { + enabled: boolean + loading: boolean + error?: Error + payload?: SecurityResponse<BlockaidModuleResponse> + } +} + +export const TransactionChecksView = ({ tenderly, blockaid }: Props) => { + const { enabled, fetchStatus } = tenderly + const { handleScroll } = useScrollableHeader({ + children: <NavBarTitle>Transaction checks</NavBarTitle>, + }) + + return ( + <ScrollView contentContainerStyle={{ paddingHorizontal: '$4', paddingTop: '$3' }} onScroll={handleScroll}> + <View> + <LargeHeaderTitle marginBottom={'$5'}>Transaction checks</LargeHeaderTitle> + </View> + <YStack gap={'$4'}> + <Container gap={'$3'}> + {blockaid.enabled ? ( + <BlockaidBalanceChanges blockaidResponse={blockaid.payload} fetchStatusLoading={blockaid.loading} /> + ) : ( + <Text>Security check is disabled</Text> + )} + </Container> + <Container gap={'$3'}> + {enabled ? ( + <> + <XStack justifyContent="space-between"> + <XStack gap={'$2'}> + <Text fontWeight={600}>Transaction simulation</Text> + <InfoSheet + title="Simulation" + info="The transaction can be simulated before execution to ensure that it will succeed. You can view a full detailed report on Tenderly." + /> + </XStack> + + {fetchStatus === FETCH_STATUS.LOADING ? ( + <Badge + circular={false} + content={ + <XStack gap={'$2'} justifyContent="center" alignItems="center"> + <CircleSnail size={12} borderWidth={0} thickness={1} /> + <Text fontSize={12}>Loading</Text> + </XStack> + } + /> + ) : tenderly.simulation?.simulation.status ? ( + <Badge + circular={false} + themeName="badge_success_variant1" + content={ + <XStack gap={'$2'} justifyContent="center" alignItems="center"> + <SafeFontIcon name="check-filled" size={12} /> + <Text fontSize={12}>Success</Text> + </XStack> + } + /> + ) : ( + <Badge circular={false} themeName="badge_error" content={<Text fontSize={12}>Failed</Text>} /> + )} + </XStack> + {tenderly.fetchStatus === FETCH_STATUS.SUCCESS && ( + <SafeButton + size="$sm" + secondary + onPress={() => { + Linking.openURL(tenderly.simulationLink) + }} + > + View details on Tenderly + </SafeButton> + )} + {tenderly.fetchStatus === FETCH_STATUS.LOADING && ( + <XStack gap={'$2'}> + <CircleSnail size={16} borderWidth={0} thickness={1} /> + <Text>Simulating with Tenderly...</Text> + </XStack> + )} + {tenderly.fetchStatus === FETCH_STATUS.ERROR && <Text>Error</Text>} + </> + ) : ( + <Text>Transaction simulation is disabled</Text> + )} + </Container> + + {blockaid.enabled && <BlockaidWarning blockaidResponse={blockaid.payload} />} + </YStack> + </ScrollView> + ) +} diff --git a/apps/mobile/src/features/TransactionChecks/components/blockaid/PoweredByBlockaid.tsx b/apps/mobile/src/features/TransactionChecks/components/blockaid/PoweredByBlockaid.tsx new file mode 100644 index 0000000000..c686b26d86 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/components/blockaid/PoweredByBlockaid.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { XStack, Text } from 'tamagui' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' + +export const PoweredByBlockaid = () => ( + <XStack gap="$1" alignItems="center" marginTop="$2"> + <Text fontSize={12} color="$colorSecondary"> + Powered by + </Text> + <SafeFontIcon name="shield" size={14} color="$colorSecondary" /> + <Text fontSize={12} color="$colorSecondary"> + Blockaid + </Text> + </XStack> +) diff --git a/apps/mobile/src/features/TransactionChecks/components/blockaid/ResultDescription.tsx b/apps/mobile/src/features/TransactionChecks/components/blockaid/ResultDescription.tsx new file mode 100644 index 0000000000..24b074cb54 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/components/blockaid/ResultDescription.tsx @@ -0,0 +1,22 @@ +import { CLASSIFICATION_MAPPING, REASON_MAPPING } from '@safe-global/utils/components/tx/security/blockaid/utils' +import { AlertTitleStyled } from '@/src/components/Alert' +import React from 'react' + +export const ResultDescription = ({ + description, + reason, + classification, +}: { + description: string | undefined + reason: string | undefined + classification: string | undefined +}) => { + let text: string | undefined = '' + if (reason && classification && REASON_MAPPING[reason] && CLASSIFICATION_MAPPING[classification]) { + text = `The transaction ${REASON_MAPPING[reason]} ${CLASSIFICATION_MAPPING[classification]}.` + } else { + text = description + } + + return <AlertTitleStyled message={text ?? 'The transaction is malicious.'} /> +} diff --git a/apps/mobile/src/features/TransactionChecks/components/blockaid/balance/BalanceChange.tsx b/apps/mobile/src/features/TransactionChecks/components/blockaid/balance/BalanceChange.tsx new file mode 100644 index 0000000000..9d2a078bc1 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/components/blockaid/balance/BalanceChange.tsx @@ -0,0 +1,32 @@ +import type { + AssetDiff, + Erc1155Diff, + Erc20Diff, + Erc721Diff, + GeneralAssetDiff, + NativeDiff, +} from '@safe-global/utils/services/security/modules/BlockaidModule/types' +import { XStack } from 'tamagui' +import React from 'react' +import { NFTBalanceChange } from '@/src/features/TransactionChecks/components/blockaid/balance/NFTBalanceChange' +import { FungibleBalanceChange } from '@/src/features/TransactionChecks/components/blockaid/balance/FungibleBalanceChange' + +export const BalanceChange = ({ + asset, + positive = false, + diff, +}: { + asset: NonNullable<AssetDiff['asset']> + positive?: boolean + diff: GeneralAssetDiff +}) => { + return ( + <XStack alignItems="center" paddingVertical="$1"> + {asset.type === 'ERC721' || asset.type === 'ERC1155' ? ( + <NFTBalanceChange asset={asset} change={diff as Erc721Diff | Erc1155Diff} /> + ) : ( + <FungibleBalanceChange asset={asset} change={diff as NativeDiff | Erc20Diff} positive={positive} /> + )} + </XStack> + ) +} diff --git a/apps/mobile/src/features/TransactionChecks/components/blockaid/balance/BlockaidBalanceChanges.tsx b/apps/mobile/src/features/TransactionChecks/components/blockaid/balance/BlockaidBalanceChanges.tsx new file mode 100644 index 0000000000..7efb6c4cc0 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/components/blockaid/balance/BlockaidBalanceChanges.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { Text, XStack, YStack } from 'tamagui' +import { BlockaidModuleResponse } from '@safe-global/utils/services/security/modules/BlockaidModule' +import { CircleSnail } from 'react-native-progress' +import { InfoSheet } from '@/src/components/InfoSheet' +import { PoweredByBlockaid } from '../PoweredByBlockaid' +import { BalanceChange } from '@/src/features/TransactionChecks/components/blockaid/balance/BalanceChange' + +type BlockaidBalanceChangesProps = { + blockaidResponse?: { + severity?: number + isLoading?: boolean + error?: Error + payload?: BlockaidModuleResponse + } + fetchStatusLoading?: boolean +} + +const BalanceChanges = ({ blockaidResponse }: BlockaidBalanceChangesProps) => { + const { isLoading, error, payload } = blockaidResponse ?? {} + + const totalBalanceChanges = payload?.balanceChange + ? payload.balanceChange.reduce((prev, current) => prev + current.in.length + current.out.length, 0) + : 0 + + if (isLoading) { + return ( + <XStack gap="$2" alignItems="center"> + <CircleSnail size={16} borderWidth={0} thickness={1} /> + <Text fontSize={14} color="$textSecondary"> + Calculating... + </Text> + </XStack> + ) + } + if (error) { + return ( + <Text fontSize={14} color="$textSecondary"> + Could not calculate balance changes. + </Text> + ) + } + if (totalBalanceChanges === 0) { + return ( + <Text fontSize={14} color="$textSecondary"> + No balance change detected + </Text> + ) + } + + return ( + <YStack> + {payload?.balanceChange?.map((change, assetIdx) => ( + <React.Fragment key={assetIdx}> + {change.in.map((diff, changeIdx) => ( + <BalanceChange key={`${assetIdx}-in-${changeIdx}`} asset={change.asset} positive diff={diff} /> + ))} + {change.out.map((diff, changeIdx) => ( + <BalanceChange key={`${assetIdx}-out-${changeIdx}`} asset={change.asset} diff={diff} /> + ))} + </React.Fragment> + ))} + </YStack> + ) +} + +export const BlockaidBalanceChanges = ({ blockaidResponse, fetchStatusLoading }: BlockaidBalanceChangesProps) => { + return ( + <YStack> + <XStack gap="$2"> + <Text fontWeight="700" marginBottom="$2"> + Balance change + </Text> + <InfoSheet + title="Balance change" + info="The balance change gives an overview of the implications of a transaction. You can see which assets will be sent and received after the transaction is executed." + /> + </XStack> + {fetchStatusLoading ? ( + <XStack gap={'$2'}> + <CircleSnail size={16} borderWidth={0} thickness={1} /> + <Text>Checking balance with Blockaid...</Text> + </XStack> + ) : ( + <> + <BalanceChanges blockaidResponse={blockaidResponse} /> + <PoweredByBlockaid /> + </> + )} + </YStack> + ) +} diff --git a/apps/mobile/src/features/TransactionChecks/components/blockaid/balance/FungibleBalanceChange.tsx b/apps/mobile/src/features/TransactionChecks/components/blockaid/balance/FungibleBalanceChange.tsx new file mode 100644 index 0000000000..654318c9a9 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/components/blockaid/balance/FungibleBalanceChange.tsx @@ -0,0 +1,52 @@ +import type { + AssetDiff, + Erc20Diff, + NativeDiff, +} from '@safe-global/utils/services/security/modules/BlockaidModule/types' +import { useBalances } from '@/src/hooks/useBalances' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import { Text, View, XStack } from 'tamagui' +import { Logo } from '@/src/components/Logo' +import { Badge } from '@/src/components/Badge' +import { formatAmount } from '@safe-global/utils/utils/formatNumber' +import React from 'react' + +export const FungibleBalanceChange = ({ + change, + asset, + positive, +}: { + asset: AssetDiff['asset'] + change: Erc20Diff | NativeDiff + positive?: boolean +}) => { + const { balances } = useBalances() + const logoUri = + asset.logo_url ?? + balances?.items.find((item) => { + return asset.type === 'NATIVE' + ? item.tokenInfo.type === TokenType.NATIVE_TOKEN + : sameAddress(item.tokenInfo.address, asset.address) + })?.tokenInfo.logoUri + + return ( + <XStack alignItems="center" gap="$2"> + <Logo size={'$5'} logoUri={logoUri} imageBackground="$background" /> + <Text fontSize={14} fontWeight="700" marginLeft="$1"> + {asset.symbol} + </Text> + <Badge + themeName={positive ? 'badge_success_variant1' : 'badge_error'} + circular={false} + content={ + <Text fontSize={12}> + {positive ? '+' : '-'} {change.value ? formatAmount(change.value) : 'unknown'} + </Text> + } + /> + <View flex={1} /> + <Badge themeName="badge_background" circular={false} content={<Text fontSize={12}>{asset.type}</Text>} /> + </XStack> + ) +} diff --git a/apps/mobile/src/features/TransactionChecks/components/blockaid/balance/NFTBalanceChange.tsx b/apps/mobile/src/features/TransactionChecks/components/blockaid/balance/NFTBalanceChange.tsx new file mode 100644 index 0000000000..ddc1b84c27 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/components/blockaid/balance/NFTBalanceChange.tsx @@ -0,0 +1,43 @@ +import type { + Erc1155Diff, + Erc1155TokenDetails, + Erc721Diff, + Erc721TokenDetails, +} from '@safe-global/utils/services/security/modules/BlockaidModule/types' +import { Text, View, XStack } from 'tamagui' +import React from 'react' +import { EthAddress } from '@/src/components/EthAddress' +import { Logo } from '@/src/components/Logo' +import { Address } from '@/src/types/address' +import { Badge } from '@/src/components/Badge' + +export const NFTBalanceChange = ({ + change, + asset, +}: { + asset: Erc721TokenDetails | Erc1155TokenDetails + change: Erc721Diff | Erc1155Diff +}) => { + return ( + <> + {asset.symbol ? ( + <XStack alignItems="center" gap="$2"> + <Logo size={'$5'} logoUri={asset.logo_url} /> + <Text fontSize={14} fontWeight="700" marginLeft="$1"> + {asset.symbol} + </Text> + </XStack> + ) : ( + <Text fontSize={14} marginLeft="$1"> + <Logo size={'$5'} logoUri={asset.logo_url} /> + <EthAddress address={asset.address as Address} copy={true} /> + </Text> + )} + <Text fontSize={14} marginLeft="$1"> + #{Number(change.token_id)} + </Text> + <View flex={1} /> + <Badge themeName="badge_background" circular={false} content={<Text fontSize={12}>NFT</Text>} /> + </> + ) +} diff --git a/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/BlockaidError.tsx b/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/BlockaidError.tsx new file mode 100644 index 0000000000..add3d074d3 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/BlockaidError.tsx @@ -0,0 +1,17 @@ +import { Text, View } from 'tamagui' +import { BlockaidMessage } from '@/src/features/TransactionChecks/components/blockaid/scans/BlockaidMessage' +import React from 'react' + +export const BlockaidError = () => { + return ( + <View backgroundColor="$backgroundSecondary" padding="$3" borderRadius="$2"> + <Text fontWeight="700" fontSize={16} marginBottom="$2"> + Proceed with caution + </Text> + <Text fontSize={14}> + The transaction could not be checked for security alerts. Verify the details and addresses before proceeding. + </Text> + <BlockaidMessage /> + </View> + ) +} diff --git a/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/BlockaidHint.tsx b/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/BlockaidHint.tsx new file mode 100644 index 0000000000..019cced129 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/BlockaidHint.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import { YStack, Text } from 'tamagui' + +export const BlockaidHint = ({ warnings }: { warnings: string[] }) => { + return ( + <YStack gap="$1"> + {warnings.map((warning, index) => ( + <Text key={index} fontSize={'$3'} fontFamily={'$body'}> + • {warning} + </Text> + ))} + </YStack> + ) +} diff --git a/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/BlockaidMessage.tsx b/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/BlockaidMessage.tsx new file mode 100644 index 0000000000..2e40cb8523 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/BlockaidMessage.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { YStack } from 'tamagui' +import { BlockaidModuleResponse } from '@safe-global/utils/services/security/modules/BlockaidModule' +import { BlockaidHint } from './BlockaidHint' +import groupBy from 'lodash/groupBy' + +type BlockaidMessageProps = { + blockaidResponse?: { + severity?: number + isLoading?: boolean + error?: Error + payload?: BlockaidModuleResponse + } +} + +export const BlockaidMessage = ({ blockaidResponse }: BlockaidMessageProps) => { + if (!blockaidResponse) { + return null + } + + const { issues } = blockaidResponse.payload ?? {} + + /* Evaluate security warnings */ + const groupedShownWarnings = groupBy(issues, (warning) => warning.severity) + const sortedSeverities = Object.keys(groupedShownWarnings).sort((a, b) => (Number(a) < Number(b) ? 1 : -1)) + + if (sortedSeverities.length === 0) { + return null + } + + return ( + <YStack gap="$2"> + {sortedSeverities.map((key) => ( + <BlockaidHint key={key} warnings={groupedShownWarnings[key].map((warning) => warning.description)} /> + ))} + </YStack> + ) +} diff --git a/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/BlockaidWarning.tsx b/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/BlockaidWarning.tsx new file mode 100644 index 0000000000..27c0dde3c7 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/BlockaidWarning.tsx @@ -0,0 +1,70 @@ +import React from 'react' +import { View, YStack } from 'tamagui' +import { SecuritySeverity } from '@safe-global/utils/services/security/modules/types' +import { BlockaidModuleResponse } from '@safe-global/utils/services/security/modules/BlockaidModule' +import { BlockaidMessage } from './BlockaidMessage' +import { ContractChangeWarning } from './ContractChangeWarning' +import { PoweredByBlockaid } from '../PoweredByBlockaid' +import { AlertType } from '@/src/components/Alert' +import { BlockaidError } from '@/src/features/TransactionChecks/components/blockaid/scans/BlockaidError' +import { ResultDescription } from '@/src/features/TransactionChecks/components/blockaid/ResultDescription' +import { Alert2 } from '@/src/components/Alert2' + +type BlockaidWarningProps = { + blockaidResponse?: { + severity?: SecuritySeverity + isLoading?: boolean + error?: Error + payload?: BlockaidModuleResponse + } +} + +export const BlockaidWarning = ({ blockaidResponse }: BlockaidWarningProps) => { + const { severity, isLoading, error, payload } = blockaidResponse ?? {} + + if (error) { + return <BlockaidError /> + } + + if (isLoading || !blockaidResponse) { + return null + } + let type = 'success' + + if (severity === SecuritySeverity.HIGH) { + type = 'error' + } else if (severity === SecuritySeverity.MEDIUM) { + type = 'warning' + } + return ( + <YStack gap="$3"> + {blockaidResponse.severity ? ( + <View> + <Alert2 + type={type as AlertType} + message={ + <> + <ResultDescription + classification={payload?.classification} + reason={payload?.reason} + description={payload?.description} + /> + <BlockaidMessage blockaidResponse={blockaidResponse} /> + <PoweredByBlockaid /> + </> + } + /> + </View> + ) : payload?.contractManagement && payload.contractManagement.length > 0 ? ( + <View> + <YStack gap="$2"> + {payload.contractManagement.map((contractChange) => ( + <ContractChangeWarning key={contractChange.type} contractChange={contractChange} /> + ))} + </YStack> + <PoweredByBlockaid /> + </View> + ) : null} + </YStack> + ) +} diff --git a/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/ContractChangeWarning.tsx b/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/ContractChangeWarning.tsx new file mode 100644 index 0000000000..44b444b154 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/components/blockaid/scans/ContractChangeWarning.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { View, YStack, Text } from 'tamagui' +import type { + ModulesChangeManagement, + OwnershipChangeManagement, + ProxyUpgradeManagement, +} from '@safe-global/utils/services/security/modules/BlockaidModule/types' +import { EthAddress } from '@/src/components/EthAddress' +import { Address } from '@/src/types/address' +import { CONTRACT_CHANGE_TITLES_MAPPING } from '@safe-global/utils/components/tx/security/blockaid/utils' +import { Alert2 } from '@/src/components/Alert2' + +const ProxyUpgradeSummary = ({ beforeAddress, afterAddress }: { beforeAddress: string; afterAddress: string }) => { + return ( + <YStack gap="$1"> + <Text fontSize={14} marginBottom="$2"> + Please verify that this change is intended and correct as it may overwrite the ownership of your account + </Text> + <Text fontSize={12} textTransform="uppercase"> + Current mastercopy: + </Text> + <View padding="$2" borderRadius="$2" backgroundColor="$backgroundSecondary"> + <EthAddress address={beforeAddress as Address} copy={true} /> + </View> + + <Text fontSize={12} textTransform="uppercase"> + New mastercopy: + </Text> + <View padding="$2" borderRadius="$2" backgroundColor="$backgroundSecondary"> + <EthAddress address={afterAddress as Address} copy={true} /> + </View> + </YStack> + ) +} + +export const ContractChangeWarning = ({ + contractChange, +}: { + contractChange: ProxyUpgradeManagement | OwnershipChangeManagement | ModulesChangeManagement +}) => { + const title = CONTRACT_CHANGE_TITLES_MAPPING[contractChange.type] + const { before, after, type } = contractChange + const isProxyUpgrade = type === 'PROXY_UPGRADE' + + const warningContent = ( + <> + {isProxyUpgrade && 'address' in before && 'address' in after ? ( + <ProxyUpgradeSummary beforeAddress={before.address} afterAddress={after.address} /> + ) : ( + <Text>Please verify that this change is intended and correct.</Text> + )} + </> + ) + + return <Alert2 type="warning" title={title} message={warningContent} /> +} diff --git a/apps/mobile/src/features/TransactionChecks/index.ts b/apps/mobile/src/features/TransactionChecks/index.ts new file mode 100644 index 0000000000..53e61389a5 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/index.ts @@ -0,0 +1 @@ +export { TransactionChecksContainer } from './TransactionChecks.container' diff --git a/apps/mobile/src/features/TransactionChecks/tenderly/useSimulation.ts b/apps/mobile/src/features/TransactionChecks/tenderly/useSimulation.ts new file mode 100644 index 0000000000..86435fff56 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/tenderly/useSimulation.ts @@ -0,0 +1,60 @@ +import { useCallback, useMemo, useState } from 'react' +import { getSimulationPayload } from '@/src/features/TransactionChecks/tenderly/utils' +import { FETCH_STATUS, type TenderlySimulation } from '@safe-global/utils/components/tx/security/tenderly/types' +import { asError } from '@safe-global/utils/services/exceptions/utils' +import { type UseSimulationReturn } from '@safe-global/utils/components/tx/security/tenderly/useSimulation' +import { + getSimulation, + getSimulationLink, + type SimulationTxParams, +} from '@safe-global/utils/components/tx/security/tenderly/utils' +import { useAppSelector } from '@/src/store/hooks' +import { selectTenderly } from '@/src/store/settingsSlice' +import Logger from '@/src/utils/logger' + +export const useSimulation = (): UseSimulationReturn => { + const [simulation, setSimulation] = useState<TenderlySimulation | undefined>() + const [simulationRequestStatus, setSimulationRequestStatus] = useState<FETCH_STATUS>(FETCH_STATUS.NOT_ASKED) + const [requestError, setRequestError] = useState<string | undefined>(undefined) + const tenderly = useAppSelector(selectTenderly) + + const simulationLink = useMemo(() => getSimulationLink(simulation?.simulation.id || ''), [simulation]) + + const resetSimulation = useCallback(() => { + setSimulationRequestStatus(FETCH_STATUS.NOT_ASKED) + setRequestError(undefined) + setSimulation(undefined) + }, []) + + const simulateTransaction = useCallback( + async (params: SimulationTxParams) => { + setSimulationRequestStatus(FETCH_STATUS.LOADING) + setRequestError(undefined) + + try { + const simulationPayload = await getSimulationPayload(params) + + const data = await getSimulation(simulationPayload, tenderly) + + setSimulation(data) + setSimulationRequestStatus(FETCH_STATUS.SUCCESS) + } catch (error) { + Logger.error(asError(error).message) + + setRequestError(asError(error).message) + setSimulationRequestStatus(FETCH_STATUS.ERROR) + } + }, + [tenderly], + ) + + return { + simulateTransaction, + // This is only used by the provider + _simulationRequestStatus: simulationRequestStatus, + simulation, + simulationLink, + requestError, + resetSimulation, + } as UseSimulationReturn +} diff --git a/apps/mobile/src/features/TransactionChecks/tenderly/utils.ts b/apps/mobile/src/features/TransactionChecks/tenderly/utils.ts new file mode 100644 index 0000000000..0695d5bae5 --- /dev/null +++ b/apps/mobile/src/features/TransactionChecks/tenderly/utils.ts @@ -0,0 +1,109 @@ +import { generatePreValidatedSignature } from '@safe-global/protocol-kit/dist/src/utils/signatures' +import EthSafeTransaction from '@safe-global/protocol-kit/dist/src/utils/transactions/SafeTransaction' +import { encodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils/transactions/utils' + +import { + getReadOnlyCurrentGnosisSafeContract, + getReadOnlyMultiSendCallOnlyContract, +} from '@/src/services/contracts/safeContracts' +import type { TenderlySimulatePayload } from '@safe-global/utils/components/tx/security/tenderly/types' +import { getWeb3ReadOnly } from '@/src/hooks/wallets/web3' + +import type { + MultiSendTransactionSimulationParams, + SimulationTxParams, + SingleTransactionSimulationParams, +} from '@safe-global/utils/components/tx/security/tenderly/utils' +import { + _getStateOverride, + getStateOverwrites, + isSingleTransactionSimulation, +} from '@safe-global/utils/components/tx/security/tenderly/utils' + +export const _getSingleTransactionPayload = async ( + params: SingleTransactionSimulationParams, +): Promise<Pick<TenderlySimulatePayload, 'to' | 'input'>> => { + console.log('single transaction payload', params) + // If a transaction is executable we simulate with the proposed/selected gasLimit and the actual signatures + let transaction = params.transactions + const hasOwnerSignature = transaction.signatures.has(params.executionOwner) + // If the owner's sig is missing and the tx threshold is not reached we add the owner's preValidated signature + const needsOwnerSignature = !hasOwnerSignature && transaction.signatures.size < params.safe.threshold + if (needsOwnerSignature) { + const simulatedTransaction = new EthSafeTransaction(transaction.data) + + transaction.signatures.forEach((signature) => { + simulatedTransaction.addSignature(signature) + }) + simulatedTransaction.addSignature(generatePreValidatedSignature(params.executionOwner)) + + transaction = simulatedTransaction + } + + const readOnlySafeContract = await getReadOnlyCurrentGnosisSafeContract(params.safe) + + const input = readOnlySafeContract.encode('execTransaction', [ + transaction.data.to, + transaction.data.value, + transaction.data.data, + transaction.data.operation, + transaction.data.safeTxGas, + transaction.data.baseGas, + transaction.data.gasPrice, + transaction.data.gasToken, + transaction.data.refundReceiver, + transaction.encodedSignatures(), + ]) + + return { + to: await readOnlySafeContract.getAddress(), + input, + } +} + +export const _getMultiSendCallOnlyPayload = async ( + params: MultiSendTransactionSimulationParams, +): Promise<Pick<TenderlySimulatePayload, 'to' | 'input'>> => { + const data = encodeMultiSendData(params.transactions) as `0x${string}` + const readOnlyMultiSendContract = await getReadOnlyMultiSendCallOnlyContract(params.safe.version) + + return { + to: await readOnlyMultiSendContract.getAddress(), + input: readOnlyMultiSendContract.encode('multiSend', [data]), + } +} + +const getLatestBlockGasLimit = async (): Promise<number> => { + const web3ReadOnly = getWeb3ReadOnly() + const latestBlock = await web3ReadOnly?.getBlock('latest') + if (!latestBlock) { + throw Error('Could not determine block gas limit') + } + return Number(latestBlock.gasLimit) +} + +export const getSimulationPayload = async (params: SimulationTxParams): Promise<TenderlySimulatePayload> => { + const gasLimit = params.gasLimit ?? (await getLatestBlockGasLimit()) + + const payload = isSingleTransactionSimulation(params) + ? await _getSingleTransactionPayload(params) + : await _getMultiSendCallOnlyPayload(params) + + const stateOverwrites = getStateOverwrites(params) + const stateOverwritesLength = Object.keys(stateOverwrites).length + + return { + ...payload, + network_id: params.safe.chainId, + from: params.executionOwner, + gas: gasLimit, + // With gas price 0 account don't need token for gas + gas_price: '0', + state_objects: + stateOverwritesLength > 0 + ? _getStateOverride(params.safe.address.value, undefined, undefined, stateOverwrites) + : undefined, + save: true, + save_if_fails: true, + } +} diff --git a/apps/mobile/src/features/TxHistory/TxHistory.container.test.tsx b/apps/mobile/src/features/TxHistory/TxHistory.container.test.tsx new file mode 100644 index 0000000000..d84d350dbe --- /dev/null +++ b/apps/mobile/src/features/TxHistory/TxHistory.container.test.tsx @@ -0,0 +1,475 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor, act } from '@/src/tests/test-utils' +import { TxHistoryContainer } from './TxHistory.container' +import { server } from '@/src/tests/server' +import { http, HttpResponse } from 'msw' +import { GATEWAY_URL } from '@/src/config/constants' +import { faker } from '@faker-js/faker' + +// Create a mutable object for the mock +const mockSafeState = { + safe: { chainId: '1', address: faker.finance.ethereumAddress() as `0x${string}` }, +} + +// Mock active safe selector to use the mutable state +jest.mock('@/src/store/hooks/activeSafe', () => ({ + useDefinedActiveSafe: () => mockSafeState.safe, +})) + +jest.mock('react-native-collapsible-tab-view', () => { + const { SectionList } = require('react-native') + return { Tabs: { SectionList } } +}) + +const sender = faker.finance.ethereumAddress() +const recipient = faker.finance.ethereumAddress() +const tokenAddress = faker.finance.ethereumAddress() +const txHash = faker.string.hexadecimal({ length: 66 }) +const txHash1 = faker.string.hexadecimal({ length: 66 }) +const mockTransactions = [ + { type: 'DATE_LABEL', timestamp: 1742830570000 }, + { + type: 'TRANSACTION', + transaction: { + txInfo: { + type: 'Transfer', + humanDescription: null, + sender: { value: sender, name: null, logoUri: null }, + recipient: { value: recipient, name: null, logoUri: null }, + direction: 'INCOMING', + transferInfo: { type: 'NATIVE_COIN', value: '10000000000000' }, + }, + id: `transfer_${recipient}_${txHash}`, + timestamp: 1742830570000, + txStatus: 'SUCCESS', + executionInfo: null, + safeAppInfo: null, + txHash, + }, + conflictType: 'None', + }, +] + +const nextPageTransactions = [ + { + type: 'TRANSACTION', + transaction: { + txInfo: { + type: 'Transfer', + humanDescription: null, + sender: { + value: sender, + name: null, + logoUri: null, + }, + recipient: { + value: recipient, + name: null, + logoUri: null, + }, + direction: 'INCOMING', + transferInfo: { + type: 'ERC721', + tokenAddress, + tokenId: '0', + tokenName: null, + tokenSymbol: null, + logoUri: null, + trusted: null, + }, + }, + id: `transfer_${recipient}_${txHash1}`, + timestamp: 1737029389000, + txStatus: 'SUCCESS', + executionInfo: null, + safeAppInfo: null, + txHash: txHash1, + }, + conflictType: 'None', + }, +] + +describe('TxHistoryContainer', () => { + beforeEach(() => { + // Reset the mock state before each test + mockSafeState.safe = { chainId: '1', address: faker.finance.ethereumAddress() as `0x${string}` } + + server.use( + http.get(`${GATEWAY_URL}/v1/chains/:chainId/safes/:safeAddress/transactions/history`, ({ request }) => { + if (request.url.includes('cursor=next_page')) { + return HttpResponse.json({ + count: 3, + next: null, + previous: `${GATEWAY_URL}/v1/chains/1/safes/0x123/transactions/history`, + results: nextPageTransactions, + }) + } + + // if safe address is 0x456, return mockTransactions + if (request.url.includes('0x456')) { + return HttpResponse.json({ + count: 3, + next: `${GATEWAY_URL}/v1/chains/1/safes/0x456/transactions/history?cursor=next_page`, + previous: null, + results: [...mockTransactions, ...nextPageTransactions], + }) + } + + return HttpResponse.json({ + next: `${GATEWAY_URL}/v1/chains/1/safes/0x123/transactions/history?cursor=next_page`, + previous: null, + results: mockTransactions, + }) + }), + ) + }) + + it('renders transaction history list', async () => { + render(<TxHistoryContainer />) + + // Wait for the transactions to be loaded + await waitFor(() => { + expect(screen.getByText('Received')).toBeTruthy() + }) + + // Check if both transactions are rendered + const transfers = screen.getAllByText('Received') + expect(transfers).toHaveLength(1) + }) + + it('loads more transactions when scrolling to the bottom', async () => { + render(<TxHistoryContainer />) + + // Wait for initial transactions to load + await waitFor(() => { + const transfers = screen.getAllByText('Received') + expect(transfers).toHaveLength(1) + }) + + // Simulate scrolling to the bottom + const list = screen.getByTestId('tx-history-list') + + // I'm failing to simulate the onScroll event, so going to use the onEndReached prop which then triggers the loading of the next page + await act(async () => { + fireEvent(list, 'onEndReached') + }) + + // Wait for additional transactions to load + await waitFor(() => { + const transfers = screen.getAllByText('Received') + expect(transfers).toHaveLength(2) + }) + }) + + it('shows initial loading skeleton when first loading transactions', async () => { + // Mock server to return delayed response to capture loading state + server.use( + http.get(`${GATEWAY_URL}/v1/chains/:chainId/safes/:safeAddress/transactions/history`, async () => { + // Add short delay to capture loading state + await new Promise((resolve) => setTimeout(resolve, 50)) + return HttpResponse.json({ + next: null, + previous: null, + results: mockTransactions, + }) + }), + ) + + render(<TxHistoryContainer />) + + // Check if initial loading skeleton is shown + expect(screen.getByTestId('tx-history-initial-loader')).toBeTruthy() + + // Wait for transactions to load and loading skeleton to disappear + await waitFor( + () => { + expect(screen.queryByTestId('tx-history-initial-loader')).toBeNull() + expect(screen.getByText('Received')).toBeTruthy() + }, + { timeout: 3000 }, + ) + }, 10000) + + it('shows pagination loading skeleton when loading more transactions', async () => { + render(<TxHistoryContainer />) + + // Wait for initial transactions to load + await waitFor(() => { + expect(screen.getByText('Received')).toBeTruthy() + }) + + // Mock server to return delayed response for next page + server.use( + http.get(`${GATEWAY_URL}/v1/chains/:chainId/safes/:safeAddress/transactions/history`, async ({ request }) => { + if (request.url.includes('cursor=next_page')) { + // Add delay to capture loading state + await new Promise((resolve) => setTimeout(resolve, 80)) + return HttpResponse.json({ + count: 3, + next: null, + previous: `${GATEWAY_URL}/v1/chains/1/safes/0x123/transactions/history`, + results: nextPageTransactions, + }) + } + return HttpResponse.json({ + next: `${GATEWAY_URL}/v1/chains/1/safes/0x123/transactions/history?cursor=next_page`, + previous: null, + results: mockTransactions, + }) + }), + ) + + // Trigger loading more transactions + const list = screen.getByTestId('tx-history-list') + + await act(async () => { + fireEvent(list, 'onEndReached') + }) + + // Check if pagination loading skeleton is shown + await waitFor( + () => { + expect(screen.getByTestId('tx-history-pagination-loader')).toBeTruthy() + }, + { timeout: 2000 }, + ) + + // Wait for additional transactions to load + await waitFor( + () => { + const transfers = screen.getAllByText('Received') + expect(transfers).toHaveLength(2) + expect(screen.queryByTestId('tx-history-pagination-loader')).toBeNull() + }, + { timeout: 3000 }, + ) + }, 10000) + + it('resets list when active safe changes', async () => { + const { rerender } = render(<TxHistoryContainer />) + + // Wait for initial transactions to load + await waitFor(() => { + const transfers = screen.getAllByText('Received') + expect(transfers).toHaveLength(1) + }) + + // Update the mock state with a new safe address + mockSafeState.safe = { chainId: '1', address: faker.finance.ethereumAddress() as `0x${string}` } + + // Rerender to trigger the new mock state + rerender(<TxHistoryContainer />) + + // Wait for list to reset and new transactions to load + await waitFor(() => { + const transfers = screen.getAllByText('Received') + expect(transfers).toHaveLength(1) + }) + }) + + describe('refresh functionality', () => { + it('triggers refresh functionality when onRefresh is called', async () => { + render(<TxHistoryContainer />) + + // Wait for initial transactions to load + await waitFor(() => { + expect(screen.getByText('Received')).toBeTruthy() + }) + + const list = screen.getByTestId('tx-history-list') + + // Verify refresh control is properly configured + expect(list).toBeTruthy() + + // Trigger refresh and verify it works without errors + await act(async () => { + fireEvent(list, 'onRefresh') + }) + + // The refresh should complete successfully (no errors) + await waitFor(() => { + expect(screen.getByText('Received')).toBeTruthy() + }) + + // Verify the list is still rendered after refresh + expect(screen.getByTestId('tx-history-list')).toBeTruthy() + }) + + it('shows progress indicator when refreshing', async () => { + render(<TxHistoryContainer />) + + // Wait for initial transactions to load + await waitFor(() => { + expect(screen.getByText('Received')).toBeTruthy() + }) + + // Reset server to use delayed response for refresh, so we can capture the refreshing state + server.use( + http.get(`${GATEWAY_URL}/v1/chains/:chainId/safes/:safeAddress/transactions/history`, async () => { + // Add delay to capture refreshing state + await new Promise((resolve) => setTimeout(resolve, 100)) + return HttpResponse.json({ + next: null, + previous: null, + results: mockTransactions, + }) + }), + ) + + const list = screen.getByTestId('tx-history-list') + + // Trigger refresh + await act(async () => { + fireEvent(list, 'onRefresh') + }) + + // Check if custom progress indicator is shown during refresh + await waitFor( + () => { + expect(screen.getByTestId('tx-history-progress-indicator')).toBeTruthy() + }, + { timeout: 500 }, + ) + + // Wait for refresh to complete and progress indicator to disappear + await waitFor( + () => { + expect(screen.queryByTestId('tx-history-progress-indicator')).toBeNull() + }, + { timeout: 2000 }, + ) + + // Verify the list is still functional after refresh + expect(screen.getByText('Received')).toBeTruthy() + }, 10000) + + it('does not show initial skeleton when refreshing', async () => { + render(<TxHistoryContainer />) + + // Wait for initial transactions to load + await waitFor(() => { + expect(screen.getByText('Received')).toBeTruthy() + }) + + // Trigger refresh + const list = screen.getByTestId('tx-history-list') + + await act(async () => { + fireEvent(list, 'onRefresh') + }) + + // Should not show initial skeleton during refresh + expect(screen.queryByTestId('tx-history-initial-loader')).toBeNull() + }) + }) + + it('handles empty state when no transactions exist', async () => { + server.use( + http.get(`${GATEWAY_URL}/v1/chains/:chainId/safes/:safeAddress/transactions/history`, () => { + return HttpResponse.json({ + next: null, + previous: null, + results: [], + }) + }), + ) + + render(<TxHistoryContainer />) + + // Wait for loading to complete + await waitFor( + () => { + expect(screen.queryByTestId('tx-history-initial-loader')).toBeNull() + }, + { timeout: 3000 }, + ) + + // Should not show any transaction items + expect(screen.queryByText('Received')).toBeNull() + + // List should still be rendered but empty + expect(screen.getByTestId('tx-history-list')).toBeTruthy() + }, 10000) + + it('renders section headers for date grouping', async () => { + // Mock with transactions on different dates + const multiDateTransactions = [ + { type: 'DATE_LABEL', timestamp: 1742830570000 }, // Jan 21, 2025 + { + type: 'TRANSACTION', + transaction: { + ...mockTransactions[1].transaction, + id: 'tx1', + timestamp: 1742830570000, + }, + conflictType: 'None', + }, + { type: 'DATE_LABEL', timestamp: 1737029389000 }, // Jan 15, 2025 + { + type: 'TRANSACTION', + transaction: { + ...mockTransactions[1].transaction, + id: 'tx2', + timestamp: 1737029389000, + }, + conflictType: 'None', + }, + ] + + server.use( + http.get(`${GATEWAY_URL}/v1/chains/:chainId/safes/:safeAddress/transactions/history`, () => { + return HttpResponse.json({ + next: null, + previous: null, + results: multiDateTransactions, + }) + }), + ) + + render(<TxHistoryContainer />) + + // Wait for transactions to load + await waitFor( + () => { + const transfers = screen.getAllByText('Received') + expect(transfers).toHaveLength(2) + }, + { timeout: 3000 }, + ) + + // Should render the SectionList which handles section headers + expect(screen.getByTestId('tx-history-list')).toBeTruthy() + }, 10000) + + it('handles multiple rapid interactions gracefully', async () => { + render(<TxHistoryContainer />) + + // Wait for initial transactions to load + await waitFor( + () => { + expect(screen.getByText('Received')).toBeTruthy() + }, + { timeout: 3000 }, + ) + + const list = screen.getByTestId('tx-history-list') + + // Trigger multiple rapid interactions + await act(async () => { + fireEvent(list, 'onRefresh') + fireEvent(list, 'onEndReached') + fireEvent(list, 'onRefresh') + }) + + // Should handle gracefully without errors + await waitFor( + () => { + expect(screen.getByText('Received')).toBeTruthy() + }, + { timeout: 3000 }, + ) + + // List should still be functional + expect(screen.getByTestId('tx-history-list')).toBeTruthy() + }, 10000) +}) diff --git a/apps/mobile/src/features/TxHistory/TxHistory.container.tsx b/apps/mobile/src/features/TxHistory/TxHistory.container.tsx index 1adb86a82d..342916ee18 100644 --- a/apps/mobile/src/features/TxHistory/TxHistory.container.tsx +++ b/apps/mobile/src/features/TxHistory/TxHistory.container.tsx @@ -1,38 +1,61 @@ -import React, { useEffect, useState } from 'react' -import { useSelector } from 'react-redux' -import { safelyDecodeURIComponent } from 'expo-router/build/fork/getStateFromPath-forks' +import React from 'react' -import { useGetTxsHistoryQuery } from '@safe-global/store/gateway' +import { useGetTxsHistoryInfiniteQuery } from '@safe-global/store/gateway' import type { TransactionItemPage } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { TxHistoryList } from '@/src/features/TxHistory/components/TxHistoryList' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import Logger from '@/src/utils/logger' export function TxHistoryContainer() { - const [pageUrl, setPageUrl] = useState<string>() - const [list, setList] = useState<TransactionItemPage['results']>([]) - const activeSafe = useSelector(selectActiveSafe) - const { data, refetch, isFetching, isUninitialized } = useGetTxsHistoryQuery({ - chainId: activeSafe.chainId, - safeAddress: activeSafe.address, - cursor: pageUrl && safelyDecodeURIComponent(pageUrl?.split('cursor=')[1]), - }) - - useEffect(() => { - if (!data?.results) { - return + const activeSafe = useDefinedActiveSafe() + const [isRefreshing, setIsRefreshing] = React.useState(false) + + // Using the infinite query hook + const { currentData, fetchNextPage, hasNextPage, isFetching, isLoading, isUninitialized, refetch } = + useGetTxsHistoryInfiniteQuery({ + chainId: activeSafe.chainId, + safeAddress: activeSafe.address, + }) + + // Flatten all pages into a single transactions array + const transactions = React.useMemo(() => { + if (!currentData?.pages) { + return [] } - setList((prev) => [...prev, ...data.results]) - }, [data]) + // Combine results from all pages + return currentData.pages.flatMap((page: TransactionItemPage) => page.results || []) + }, [currentData?.pages]) const onEndReached = () => { - if (!data?.next) { - return + if (hasNextPage && !isFetching) { + fetchNextPage() } - - setPageUrl(data.next) - refetch() } - return <TxHistoryList transactions={list} onEndReached={onEndReached} isLoading={isFetching || isUninitialized} /> + // Handle pull-to-refresh - reset the data and fetch from the beginning + const onRefresh = React.useCallback(async () => { + setIsRefreshing(true) + try { + // Refetch will reset the data and start fresh with page 1 + await refetch() + } catch (error) { + Logger.error('Error refreshing transaction history:', error) + } finally { + setIsRefreshing(false) + } + }, [refetch]) + + // Combine loading states, but don't show loader when refreshing + const isLoadingState = (isFetching && !isRefreshing) || isLoading || isUninitialized + + return ( + <TxHistoryList + transactions={transactions} + onEndReached={onEndReached} + isLoading={isLoadingState} + onRefresh={onRefresh} + refreshing={isRefreshing} + /> + ) } diff --git a/apps/mobile/src/features/TxHistory/components/TransactionHeader/index.tsx b/apps/mobile/src/features/TxHistory/components/TransactionHeader/index.tsx new file mode 100644 index 0000000000..57ef61a9ed --- /dev/null +++ b/apps/mobile/src/features/TxHistory/components/TransactionHeader/index.tsx @@ -0,0 +1,13 @@ +import { H2, View } from 'tamagui' + +const TransactionHeader = ({ title = 'Transactions' }: { title?: string }) => { + return ( + <View> + <H2 fontWeight={600} alignSelf="flex-start" width="100%" textAlign="left"> + {title} + </H2> + </View> + ) +} + +export default TransactionHeader diff --git a/apps/mobile/src/features/TxHistory/components/TxHistoryList/TxHistoryList.tsx b/apps/mobile/src/features/TxHistory/components/TxHistoryList/TxHistoryList.tsx index 9bbb500dde..0fe42b4d33 100644 --- a/apps/mobile/src/features/TxHistory/components/TxHistoryList/TxHistoryList.tsx +++ b/apps/mobile/src/features/TxHistory/components/TxHistoryList/TxHistoryList.tsx @@ -1,36 +1,109 @@ -import { Spinner } from 'tamagui' import React, { useMemo } from 'react' -import { SectionList } from 'react-native' - +import { Tabs } from 'react-native-collapsible-tab-view' +import { View, useTheme } from 'tamagui' import { SafeListItem } from '@/src/components/SafeListItem' import { TransactionItem } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' import { getTxHash, GroupedTxsWithTitle, groupTxsByDate } from '@/src/features/TxHistory/utils' import { HistoryTransactionItems } from '@safe-global/store/gateway/types' import { renderItem } from '@/src/features/TxHistory/utils' +import { TxHistorySkeleton, TxHistorySkeletonItem } from '../TxHistorySkeleton' +import { RefreshControl } from 'react-native' +import { CircleSnail } from 'react-native-progress' interface TxHistoryList { transactions?: HistoryTransactionItems[] onEndReached: (info: { distanceFromEnd: number }) => void isLoading?: boolean + refreshing?: boolean + onRefresh?: () => void } -export function TxHistoryList({ transactions, onEndReached, isLoading }: TxHistoryList) { +export function TxHistoryList({ transactions, onEndReached, isLoading, refreshing, onRefresh }: TxHistoryList) { + const theme = useTheme() + const groupedList: GroupedTxsWithTitle<TransactionItem>[] = useMemo(() => { return groupTxsByDate(transactions || []) }, [transactions]) + const hasTransactions = transactions && transactions.length > 0 + const isInitialLoading = isLoading && !hasTransactions && !refreshing + + // ListEmptyComponent for initial loading state + const renderEmptyComponent = useMemo(() => { + if (isInitialLoading) { + return ( + <View + flex={1} + alignItems="flex-start" + justifyContent="flex-start" + paddingTop="$4" + testID="tx-history-initial-loader" + > + <TxHistorySkeleton /> + </View> + ) + } + return null + }, [isInitialLoading]) + + // ListFooterComponent for pagination loading (bottom loading) + const renderFooterComponent = useMemo(() => { + if (isLoading && hasTransactions) { + return ( + <View testID="tx-history-pagination-loader" marginTop="$4"> + <TxHistorySkeletonItem /> + </View> + ) + } + return null + }, [isLoading, hasTransactions]) + return ( - <SectionList - testID="tx-history-list" - stickySectionHeadersEnabled - contentInsetAdjustmentBehavior="automatic" - sections={groupedList} - keyExtractor={(item, index) => (Array.isArray(item) ? getTxHash(item[0]) + index : getTxHash(item) + index)} - renderItem={renderItem} - onEndReached={onEndReached} - contentContainerStyle={{ paddingHorizontal: 16 }} - ListFooterComponent={isLoading ? <Spinner size="small" color="$color" /> : undefined} - renderSectionHeader={({ section: { title } }) => <SafeListItem.Header title={title} />} - /> + <View position="relative" flex={1}> + {!!refreshing && ( + <View + position="absolute" + top={64} + alignSelf="center" + zIndex={1000} + backgroundColor="$background" + borderRadius={20} + padding="$2" + testID="tx-history-progress-indicator" + > + <CircleSnail size={24} color={theme.color.get()} thickness={2} duration={600} spinDuration={1500} /> + </View> + )} + + <Tabs.SectionList + testID="tx-history-list" + stickySectionHeadersEnabled + contentInsetAdjustmentBehavior="automatic" + sections={groupedList} + keyExtractor={(item, index) => (Array.isArray(item) ? getTxHash(item[0]) + index : getTxHash(item) + index)} + renderItem={renderItem} + onEndReached={onEndReached} + onEndReachedThreshold={0.1} + refreshControl={ + <RefreshControl + refreshing={!!refreshing} + onRefresh={onRefresh} + tintColor="transparent" // Hide default spinner + colors={['transparent']} // Hide default spinner on Android + progressBackgroundColor="transparent" + style={{ backgroundColor: 'transparent' }} + /> + } + style={{ marginTop: -16 }} // Compensate for SafeTab container marginTop + contentContainerStyle={{ + paddingHorizontal: 16, + paddingTop: 8, + marginTop: 16, + }} + ListEmptyComponent={renderEmptyComponent} + ListFooterComponent={renderFooterComponent} + renderSectionHeader={({ section: { title } }) => <SafeListItem.Header title={title} />} + /> + </View> ) } diff --git a/apps/mobile/src/features/TxHistory/components/TxHistorySkeleton/TxHistorySkeleton.tsx b/apps/mobile/src/features/TxHistory/components/TxHistorySkeleton/TxHistorySkeleton.tsx new file mode 100644 index 0000000000..ff5c1c200f --- /dev/null +++ b/apps/mobile/src/features/TxHistory/components/TxHistorySkeleton/TxHistorySkeleton.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { View } from 'tamagui' +import { useColorScheme } from 'react-native' +import { Skeleton } from 'moti/skeleton' +import { Container } from '@/src/components/Container' +import random from 'lodash/random' + +interface TxHistorySkeletonProps { + count?: number +} + +export const TxHistorySkeletonItem = () => { + const colorScheme = useColorScheme() || undefined + + // Memoize random widths to prevent re-renders and maintain consistent skeleton appearance + const widths = React.useMemo( + () => ({ + transactionType: random(60, 100), + transactionLabel: random(60, 180), + rightSide: random(60, 100), + }), + [], + ) + + return ( + <Container spaced paddingVertical="$5" bordered={false}> + <View flexDirection="row" width="100%" alignItems="center" justifyContent="space-between"> + <View flexDirection="row" maxWidth="55%" alignItems="center" gap="$3"> + {/* Left icon skeleton */} + <Skeleton colorMode={colorScheme} radius="round" height={32} width={32} /> + + <View flex={1} gap="$2"> + {/* Transaction type skeleton */} + <Skeleton colorMode={colorScheme} height={10} width={widths.transactionType} /> + + {/* Transaction label skeleton */} + <Skeleton colorMode={colorScheme} height={18} width={widths.transactionLabel} /> + </View> + </View> + + {/* Right side skeleton */} + <View alignItems="flex-end" gap="$2"> + <Skeleton colorMode={colorScheme} height={16} width={widths.rightSide} /> + </View> + </View> + </Container> + ) +} + +export const TxHistorySkeleton = ({ count = 6 }: TxHistorySkeletonProps) => { + const colorScheme = useColorScheme() || undefined + + return ( + <Skeleton.Group show={true}> + <View gap="$4"> + {/* Date header skeleton */} + <View> + <Skeleton colorMode={colorScheme} height={20} width={100} /> + </View> + + {/* Transaction items skeleton */} + {Array.from({ length: count }).map((_, index) => ( + <TxHistorySkeletonItem key={index} /> + ))} + </View> + </Skeleton.Group> + ) +} diff --git a/apps/mobile/src/features/TxHistory/components/TxHistorySkeleton/index.tsx b/apps/mobile/src/features/TxHistory/components/TxHistorySkeleton/index.tsx new file mode 100644 index 0000000000..6d9786fd6c --- /dev/null +++ b/apps/mobile/src/features/TxHistory/components/TxHistorySkeleton/index.tsx @@ -0,0 +1 @@ +export { TxHistorySkeleton, TxHistorySkeletonItem } from './TxHistorySkeleton' diff --git a/apps/mobile/src/features/TxHistory/index.tsx b/apps/mobile/src/features/TxHistory/index.tsx index be8a9b5471..e136d042e1 100644 --- a/apps/mobile/src/features/TxHistory/index.tsx +++ b/apps/mobile/src/features/TxHistory/index.tsx @@ -1,2 +1,4 @@ import { TxHistoryContainer } from './TxHistory.container' + export { TxHistoryContainer } +export { TxHistorySkeleton, TxHistorySkeletonItem } from './components/TxHistorySkeleton' diff --git a/apps/mobile/src/hooks/__tests__/useNotificationHandler.test.ts b/apps/mobile/src/hooks/__tests__/useNotificationHandler.test.ts new file mode 100644 index 0000000000..4d34fd1dbc --- /dev/null +++ b/apps/mobile/src/hooks/__tests__/useNotificationHandler.test.ts @@ -0,0 +1,328 @@ +/** + * Unit tests for useNotificationHandler hook + */ +import { renderHook } from '@testing-library/react-native' +import { EventType, EventDetail } from '@notifee/react-native' +import { useNotificationHandler } from '../useNotificationHandler' +import NotificationsService from '@/src/services/notifications/NotificationService' +import Logger from '@/src/utils/logger' + +// Define types for test events +interface TestNotificationEvent { + type: EventType + detail: EventDetail | undefined +} + +// Mock dependencies +jest.mock('@/src/services/notifications/NotificationService', () => ({ + onForegroundEvent: jest.fn(), + handleNotificationPress: jest.fn(), + incrementBadgeCount: jest.fn(), +})) + +jest.mock('@/src/utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), +})) + +// Mock Notifee EventType +jest.mock('@notifee/react-native', () => ({ + EventType: { + PRESS: 'press', + DELIVERED: 'delivered', + DISMISSED: 'dismissed', + }, +})) + +const mockNotificationsService = jest.mocked(NotificationsService) +const mockLogger = jest.mocked(Logger) + +describe('useNotificationHandler', () => { + const mockUnsubscribe = jest.fn() + let mockEventHandler: ((event: TestNotificationEvent) => Promise<void>) | undefined + + beforeEach(() => { + jest.clearAllMocks() + + // Mock onForegroundEvent to capture the event handler + mockNotificationsService.onForegroundEvent.mockImplementation((handler) => { + mockEventHandler = handler as (event: TestNotificationEvent) => Promise<void> + return mockUnsubscribe + }) + + mockNotificationsService.handleNotificationPress.mockResolvedValue() + mockNotificationsService.incrementBadgeCount.mockResolvedValue() + }) + + describe('hook initialization', () => { + it('should set up foreground event listener on mount', () => { + renderHook(() => useNotificationHandler()) + + expect(mockNotificationsService.onForegroundEvent).toHaveBeenCalledWith(expect.any(Function)) + expect(mockNotificationsService.onForegroundEvent).toHaveBeenCalledTimes(1) + }) + + it('should return cleanup function that calls unsubscribe', () => { + const { unmount } = renderHook(() => useNotificationHandler()) + + unmount() + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1) + }) + }) + + describe('event handling', () => { + it('should handle PRESS event correctly', async () => { + renderHook(() => useNotificationHandler()) + + const mockEvent: TestNotificationEvent = { + type: EventType.PRESS, + detail: { + notification: { + id: 'test-notification-id', + data: { key: 'value' }, + }, + }, + } + + await mockEventHandler?.(mockEvent) + + expect(mockNotificationsService.handleNotificationPress).toHaveBeenCalledWith({ + detail: mockEvent.detail, + }) + expect(mockNotificationsService.handleNotificationPress).toHaveBeenCalledTimes(1) + }) + + it('should handle DELIVERED event correctly', async () => { + renderHook(() => useNotificationHandler()) + + const mockEvent: TestNotificationEvent = { + type: EventType.DELIVERED, + detail: { + notification: { + id: 'test-notification-id', + }, + }, + } + + await mockEventHandler?.(mockEvent) + + expect(mockNotificationsService.incrementBadgeCount).toHaveBeenCalledWith(1) + expect(mockNotificationsService.incrementBadgeCount).toHaveBeenCalledTimes(1) + }) + + it('should handle DISMISSED event correctly', async () => { + renderHook(() => useNotificationHandler()) + + const mockEvent: TestNotificationEvent = { + type: EventType.DISMISSED, + detail: { + notification: { + id: 'test-notification-id', + }, + }, + } + + await mockEventHandler?.(mockEvent) + + expect(mockLogger.info).toHaveBeenCalledWith('User dismissed notification:', 'test-notification-id') + expect(mockLogger.info).toHaveBeenCalledTimes(1) + }) + + it('should handle DISMISSED event with missing notification id', async () => { + renderHook(() => useNotificationHandler()) + + const mockEvent: TestNotificationEvent = { + type: EventType.DISMISSED, + detail: { + notification: undefined, + }, + } + + await mockEventHandler?.(mockEvent) + + expect(mockLogger.info).toHaveBeenCalledWith('User dismissed notification:', undefined) + expect(mockLogger.info).toHaveBeenCalledTimes(1) + }) + + it('should handle unknown event types gracefully', async () => { + renderHook(() => useNotificationHandler()) + + const mockEvent = { + type: 'UNKNOWN_EVENT_TYPE' as unknown as EventType, + detail: { + notification: { + id: 'test-notification-id', + }, + }, + } as TestNotificationEvent + + await mockEventHandler?.(mockEvent) + + // Should not call any notification service methods for unknown events + expect(mockNotificationsService.handleNotificationPress).not.toHaveBeenCalled() + expect(mockNotificationsService.incrementBadgeCount).not.toHaveBeenCalled() + expect(mockLogger.info).not.toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + it('should handle errors in PRESS event processing', async () => { + renderHook(() => useNotificationHandler()) + + const mockError = new Error('Press handling failed') + mockNotificationsService.handleNotificationPress.mockRejectedValue(mockError) + + const mockEvent: TestNotificationEvent = { + type: EventType.PRESS, + detail: { + notification: { + id: 'test-notification-id', + }, + }, + } + + await mockEventHandler?.(mockEvent) + + expect(mockLogger.error).toHaveBeenCalledWith( + 'useNotificationHandler: Error handling foreground notification event', + mockError, + ) + expect(mockLogger.error).toHaveBeenCalledTimes(1) + }) + + it('should handle errors in DELIVERED event processing', async () => { + renderHook(() => useNotificationHandler()) + + const mockError = new Error('Badge increment failed') + mockNotificationsService.incrementBadgeCount.mockRejectedValue(mockError) + + const mockEvent: TestNotificationEvent = { + type: EventType.DELIVERED, + detail: { + notification: { + id: 'test-notification-id', + }, + }, + } + + await mockEventHandler?.(mockEvent) + + expect(mockLogger.error).toHaveBeenCalledWith( + 'useNotificationHandler: Error handling foreground notification event', + mockError, + ) + expect(mockLogger.error).toHaveBeenCalledTimes(1) + }) + + it('should handle errors in DISMISSED event processing', async () => { + renderHook(() => useNotificationHandler()) + + // Mock Logger.info to throw an error + mockLogger.info.mockImplementation(() => { + throw new Error('Logging failed') + }) + + const mockEvent: TestNotificationEvent = { + type: EventType.DISMISSED, + detail: { + notification: { + id: 'test-notification-id', + }, + }, + } + + await mockEventHandler?.(mockEvent) + + expect(mockLogger.error).toHaveBeenCalledWith( + 'useNotificationHandler: Error handling foreground notification event', + expect.any(Error), + ) + expect(mockLogger.error).toHaveBeenCalledTimes(1) + }) + }) + + describe('hook lifecycle', () => { + it('should only set up event listener once', () => { + const { rerender } = renderHook(() => useNotificationHandler()) + + expect(mockNotificationsService.onForegroundEvent).toHaveBeenCalledTimes(1) + + // Rerender the hook + rerender({}) + + // Should still only be called once due to empty dependency array + expect(mockNotificationsService.onForegroundEvent).toHaveBeenCalledTimes(1) + }) + + it('should clean up event listener on unmount', () => { + const { unmount } = renderHook(() => useNotificationHandler()) + + expect(mockUnsubscribe).not.toHaveBeenCalled() + + unmount() + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1) + }) + }) + + describe('edge cases', () => { + it('should handle event with undefined detail', async () => { + renderHook(() => useNotificationHandler()) + + const mockEvent: TestNotificationEvent = { + type: EventType.PRESS, + detail: undefined, + } + + await mockEventHandler?.(mockEvent) + + expect(mockNotificationsService.handleNotificationPress).toHaveBeenCalledWith({ + detail: undefined, + }) + }) + + it('should handle event with missing notification in detail', async () => { + renderHook(() => useNotificationHandler()) + + const mockEvent: TestNotificationEvent = { + type: EventType.DISMISSED, + detail: { + notification: undefined, + }, + } + + await mockEventHandler?.(mockEvent) + + expect(mockLogger.info).toHaveBeenCalledWith('User dismissed notification:', undefined) + }) + + it('should handle multiple rapid events', async () => { + renderHook(() => useNotificationHandler()) + + const events: TestNotificationEvent[] = [ + { + type: EventType.DELIVERED, + detail: { notification: { id: 'notification-1' } }, + }, + { + type: EventType.PRESS, + detail: { notification: { id: 'notification-2' } }, + }, + { + type: EventType.DISMISSED, + detail: { notification: { id: 'notification-3' } }, + }, + ] + + // Process all events + for (const event of events) { + await mockEventHandler?.(event) + } + + expect(mockNotificationsService.incrementBadgeCount).toHaveBeenCalledTimes(1) + expect(mockNotificationsService.handleNotificationPress).toHaveBeenCalledTimes(1) + expect(mockLogger.info).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/apps/mobile/src/hooks/__tests__/useScreenProtection.test.ts b/apps/mobile/src/hooks/__tests__/useScreenProtection.test.ts new file mode 100644 index 0000000000..f62c543590 --- /dev/null +++ b/apps/mobile/src/hooks/__tests__/useScreenProtection.test.ts @@ -0,0 +1,81 @@ +import { renderHook } from '@testing-library/react-native' +import { useScreenProtection } from '../useScreenProtection' + +// Mock dependencies +jest.mock('expo-router', () => ({ + useFocusEffect: jest.fn(), +})) + +jest.mock('react-native-capture-protection', () => ({ + CaptureProtection: { + prevent: jest.fn(), + allow: jest.fn(), + }, +})) + +const mockUseFocusEffect = jest.requireMock('expo-router').useFocusEffect +const mockCaptureProtection = jest.requireMock('react-native-capture-protection').CaptureProtection + +describe('useScreenProtection', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should call useFocusEffect with correct parameters', () => { + renderHook(() => useScreenProtection()) + + expect(mockUseFocusEffect).toHaveBeenCalledTimes(1) + expect(mockUseFocusEffect).toHaveBeenCalledWith(expect.any(Function)) + }) + + it('should prevent screen capture when focused', () => { + renderHook(() => useScreenProtection()) + + // Get the focus effect callback + const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0] + + // Execute the focus effect callback + focusEffectCallback() + + expect(mockCaptureProtection.prevent).toHaveBeenCalledTimes(1) + expect(mockCaptureProtection.prevent).toHaveBeenCalledWith({ + screenshot: true, + record: true, + appSwitcher: true, + }) + }) + + it('should allow screen capture when cleanup is called', () => { + renderHook(() => useScreenProtection()) + + // Get the focus effect callback + const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0] + + // Execute the focus effect callback and get cleanup function + const cleanup = focusEffectCallback() + + // Execute cleanup + cleanup() + + expect(mockCaptureProtection.allow).toHaveBeenCalledTimes(1) + }) + + it('should use custom options when provided', () => { + const customOptions = { + screenshot: false, + record: true, + appSwitcher: false, + } + + renderHook(() => useScreenProtection(customOptions)) + + // Get the focus effect callback + const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0] + + // Execute the focus effect callback + focusEffectCallback() + + expect(mockCaptureProtection.prevent).toHaveBeenCalledTimes(1) + expect(mockCaptureProtection.prevent).toHaveBeenCalledWith(customOptions) + }) +}) diff --git a/apps/mobile/src/hooks/coreSDK/safeCoreSDK.ts b/apps/mobile/src/hooks/coreSDK/safeCoreSDK.ts new file mode 100644 index 0000000000..3bebe6bee1 --- /dev/null +++ b/apps/mobile/src/hooks/coreSDK/safeCoreSDK.ts @@ -0,0 +1,59 @@ +import chains from '@safe-global/utils/config/chains' +import { getSafeSingletonDeployments, getSafeL2SingletonDeployments } from '@safe-global/safe-deployments' +import ExternalStore from '@safe-global/utils/services/ExternalStore' +import { Gnosis_safe__factory } from '@safe-global/utils/types/contracts' +import Safe from '@safe-global/protocol-kit' +import { isLegacyVersion } from '@safe-global/utils/services/contracts/utils' +import { isValidMasterCopy } from '@safe-global/utils/services/contracts/safeContracts' +import type { SafeCoreSDKProps } from '@safe-global/utils/hooks/coreSDK/types' +import { isInDeployments } from '@safe-global/utils/hooks/coreSDK/utils' + +// Safe Core SDK +export const initSafeSDK = async ({ + provider, + chainId, + address, + version, + implementationVersionState, + implementation, +}: SafeCoreSDKProps): Promise<Safe | undefined> => { + const providerNetwork = (await provider.getNetwork()).chainId + if (providerNetwork !== BigInt(chainId)) { + return + } + + const safeVersion = version ?? (await Gnosis_safe__factory.connect(address, provider).VERSION()) + let isL1SafeSingleton = chainId === chains.eth + + // If it is an official deployment we should still initiate the safeSDK + if (!isValidMasterCopy(implementationVersionState)) { + const masterCopy = implementation + + const safeL1Deployment = getSafeSingletonDeployments({ network: chainId, version: safeVersion }) + const safeL2Deployment = getSafeL2SingletonDeployments({ network: chainId, version: safeVersion }) + + isL1SafeSingleton = isInDeployments(masterCopy, safeL1Deployment?.networkAddresses[chainId]) + const isL2SafeMasterCopy = isInDeployments(masterCopy, safeL2Deployment?.networkAddresses[chainId]) + + // Unknown deployment, which we do not want to support + if (!isL1SafeSingleton && !isL2SafeMasterCopy) { + return Promise.resolve(undefined) + } + } + // Legacy Safe contracts + if (isLegacyVersion(safeVersion)) { + isL1SafeSingleton = true + } + + return Safe.init({ + provider: provider._getConnection().url, + safeAddress: address, + isL1SafeSingleton, + }) +} + +export const { + getStore: getSafeSDK, + setStore: setSafeSDK, + useStore: useSafeSDK, +} = new ExternalStore<Safe | undefined>() diff --git a/apps/mobile/src/hooks/coreSDK/useInitSafeCoreSDK.ts b/apps/mobile/src/hooks/coreSDK/useInitSafeCoreSDK.ts new file mode 100644 index 0000000000..2d12880262 --- /dev/null +++ b/apps/mobile/src/hooks/coreSDK/useInitSafeCoreSDK.ts @@ -0,0 +1,42 @@ +import { useEffect } from 'react' +import useSafeInfo from '@/src/hooks/useSafeInfo' +import { initSafeSDK, setSafeSDK } from '@/src/hooks/coreSDK/safeCoreSDK' +import { useWeb3ReadOnly } from '@/src/hooks/wallets/web3' +import { asError } from '@safe-global/utils/services/exceptions/utils' +import Logger from '@/src/utils/logger' + +export const useInitSafeCoreSDK = () => { + const { safe, safeLoaded } = useSafeInfo() + const web3ReadOnly = useWeb3ReadOnly() + + useEffect(() => { + if (!safeLoaded || !web3ReadOnly) { + // If we don't reset the SDK, a previous Safe could remain in the store + setSafeSDK(undefined) + return + } + + // Read-only for now + initSafeSDK({ + provider: web3ReadOnly, + chainId: safe.chainId, + address: safe.address.value, + version: safe.version, + implementationVersionState: safe.implementationVersionState, + implementation: safe.implementation.value, + }) + .then(setSafeSDK) + .catch((_e) => { + const e = asError(_e) + Logger.error('error init', e) + }) + }, [ + safe?.address?.value, + safe?.chainId, + safe?.implementation?.value, + safe?.implementationVersionState, + safe?.version, + safeLoaded, + web3ReadOnly, + ]) +} diff --git a/apps/mobile/src/hooks/services/useSafeOverviewService.ts b/apps/mobile/src/hooks/services/useSafeOverviewService.ts new file mode 100644 index 0000000000..b425e5a22f --- /dev/null +++ b/apps/mobile/src/hooks/services/useSafeOverviewService.ts @@ -0,0 +1,30 @@ +import { SafeOverviewResult } from '@safe-global/store/gateway/types' +import { useAppSelector } from '@/src/store/hooks' +import { selectAllChainsIds } from '@/src/store/chains' +import { useMemo } from 'react' +import { makeSafeId } from '@/src/utils/formatters' +import { useSafesGetOverviewForManyQuery } from '@safe-global/store/gateway/safes' +import { selectCurrency } from '@/src/store/settingsSlice' + +export const useSafeOverviewService = (safeAddress?: string) => { + const chainIds = useAppSelector(selectAllChainsIds) + const currency = useAppSelector(selectCurrency) + const safes = useMemo( + () => (safeAddress ? chainIds.map((chainId: string) => makeSafeId(chainId, safeAddress)) : []), + [chainIds, safeAddress], + ) + + const { data } = useSafesGetOverviewForManyQuery<SafeOverviewResult>( + { + safes, + currency, + trusted: true, + excludeSpam: true, + }, + { + skip: !safeAddress, + }, + ) + + return data +} diff --git a/apps/mobile/src/hooks/useAnalytics.ts b/apps/mobile/src/hooks/useAnalytics.ts new file mode 100644 index 0000000000..a980c20806 --- /dev/null +++ b/apps/mobile/src/hooks/useAnalytics.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react' +import { useLocalSearchParams } from 'expo-router' +import { useAppSelector } from '@/src/store/hooks' +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { setChainId, setSafeAddress } from '@/src/services/analytics' + +export const useAnalytics = () => { + const activeSafe = useAppSelector(selectActiveSafe) + const params = useLocalSearchParams<{ + safeAddress?: string + chainId?: string + }>() + + // Determine which safe to use - route params override activeSafe + const currentSafeAddress = params.safeAddress || activeSafe?.address + const currentChainId = params.chainId || activeSafe?.chainId + + // Set chain ID when it changes + useEffect(() => { + if (currentChainId) { + setChainId(currentChainId) + } + }, [currentChainId]) + + // Set safe address when it changes + useEffect(() => { + if (currentSafeAddress) { + setSafeAddress(currentSafeAddress) + } + }, [currentSafeAddress]) + + return { + safeAddress: currentSafeAddress, + chainId: currentChainId, + isUsingRouteParams: !!(params.safeAddress || params.chainId), + } +} diff --git a/apps/mobile/src/hooks/useBalances.ts b/apps/mobile/src/hooks/useBalances.ts new file mode 100644 index 0000000000..74285802d8 --- /dev/null +++ b/apps/mobile/src/hooks/useBalances.ts @@ -0,0 +1,31 @@ +import { skipToken } from '@reduxjs/toolkit/query' +import { useBalancesGetBalancesV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/balances' +import { useSelector } from 'react-redux' +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { selectCurrency } from '@/src/store/settingsSlice' +import { POLLING_INTERVAL } from '@/src/config/constants' + +export const useBalances = (poll = false, pollingInterval = POLLING_INTERVAL) => { + const activeSafe = useSelector(selectActiveSafe) + const currency = useSelector(selectCurrency) + + const { data, error, isLoading } = useBalancesGetBalancesV1Query( + !activeSafe + ? skipToken + : { + chainId: activeSafe.chainId, + fiatCode: currency.toUpperCase(), + safeAddress: activeSafe.address, + trusted: true, + }, + { + pollingInterval: poll ? pollingInterval : undefined, + }, + ) + + return { + balances: data, + loading: isLoading, + error, + } +} diff --git a/apps/mobile/src/hooks/useBiometrics.ts b/apps/mobile/src/hooks/useBiometrics.ts new file mode 100644 index 0000000000..8514197ba0 --- /dev/null +++ b/apps/mobile/src/hooks/useBiometrics.ts @@ -0,0 +1,213 @@ +import { useState, useCallback, useLayoutEffect } from 'react' +import * as Keychain from 'react-native-keychain' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { + setBiometricsEnabled, + setBiometricsSupported, + setBiometricsType, + setUserAttempts, +} from '@/src/store/biometricsSlice' +import { Platform, Linking } from 'react-native' +import Logger from '@/src/utils/logger' +import { RootState } from '../store' + +const BIOMETRICS_KEY = 'SAFE_WALLET_BIOMETRICS' + +export function useBiometrics() { + const dispatch = useAppDispatch() + const [isLoading, setIsLoading] = useState(false) + + // This hasInteracted ref is used to prevent the biometrics from being enabled/disabled + // even when the app is in background then comes back to foreground + // with biometrics enabled and the app settings was disabled + // and the user has not interacted with config + const isEnabled = useAppSelector((state: RootState) => state.biometrics.isEnabled) + const biometricsType = useAppSelector((state: RootState) => state.biometrics.type) + const userAttempts = useAppSelector((state: RootState) => state.biometrics.userAttempts) + + const openBiometricSettings = () => { + if (Platform.OS === 'ios') { + Linking.openURL('app-settings:') + } else { + Linking.openSettings() + } + } + + const checkBiometricsSupport = useCallback(async () => { + try { + const supportedBiometrics = await Keychain.getSupportedBiometryType() + + if (supportedBiometrics) { + let type: 'FACE_ID' | 'TOUCH_ID' | 'FINGERPRINT' | 'NONE' = 'NONE' + + switch (supportedBiometrics) { + case Keychain.BIOMETRY_TYPE.FACE_ID: + type = 'FACE_ID' + break + case Keychain.BIOMETRY_TYPE.TOUCH_ID: + type = 'TOUCH_ID' + break + case Keychain.BIOMETRY_TYPE.FINGERPRINT: + type = 'FINGERPRINT' + break + } + + dispatch(setBiometricsType(type)) + dispatch(setBiometricsSupported(true)) + return true + } + + return false + } catch (error) { + Logger.error('Error checking biometrics support:', error) + return false + } + }, []) + + const checkBiometricsOSSettingsStatus = useCallback(async () => { + try { + // This checks if biometrics is available at system level + const result = await Keychain.getSupportedBiometryType() + // If biometrics is not set up at OS level, this will return null + // If Face ID is available, it returns 'FaceID' + // If Touch ID is available, it returns 'TouchID' + return { + biometricsEnabled: result !== null, + biometryType: result, // 'FaceID', 'TouchID', or null + } + } catch (error) { + Logger.error('Error checking biometrics:', error) + return { + biometricsEnabled: false, + biometryType: null, + } + } + }, []) + + const disableBiometrics = useCallback(async () => { + setIsLoading(true) + try { + await Keychain.resetGenericPassword() + dispatch(setBiometricsEnabled(false)) + } catch (error) { + Logger.error('Error disabling biometrics:', error) + } finally { + setIsLoading(false) + } + }, []) + + const enableBiometrics = useCallback( + async (fromInteraction?: boolean) => { + setIsLoading(true) + + try { + const isSupported = await checkBiometricsSupport() + const { biometricsEnabled: isEnabledAtOSLevel } = await checkBiometricsOSSettingsStatus() + + if (!isSupported || !isEnabledAtOSLevel) { + dispatch(setBiometricsEnabled(false)) + + if (fromInteraction) { + return openBiometricSettings() + } + } + + try { + // Wrap the biometrics operations in a nested try-catch to allow for user cancellation + const setGenericPasswordResult = await Keychain.setGenericPassword(BIOMETRICS_KEY, 'biometrics-enabled', { + accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY, + accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED, + }) + + if (setGenericPasswordResult) { + const getGenericPasswordResult = await Keychain.getGenericPassword({ + accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY, + }) + + if (getGenericPasswordResult) { + dispatch(setBiometricsEnabled(true)) + dispatch(setUserAttempts(0)) + return true + } + } + + // If we get here, something went wrong with setting or getting the password + throw new Error('Failed to verify biometrics setup') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (biometricsError: any) { + // Handle user cancellation specifically + if ( + biometricsError.code === '-128' || // User pressed Cancel + biometricsError.code === 'AuthenticationFailed' || + biometricsError.message.includes('cancel') || + biometricsError.message.includes('user name or passphrase') + ) { + Keychain.resetGenericPassword().then(() => { + dispatch(setUserAttempts(userAttempts + 1)) + }) + dispatch(setBiometricsEnabled(false)) + return false + } + // Re-throw other errors + throw biometricsError + } + } catch (error) { + Logger.error('Unexpected error in biometrics setup:', error) + await Keychain.resetGenericPassword() + dispatch(setBiometricsEnabled(false)) + return false + } finally { + setIsLoading(false) + } + }, + [checkBiometricsSupport, userAttempts], + ) + + const toggleBiometrics = useCallback( + async (newValue: boolean, fromInteraction: boolean) => { + return newValue ? enableBiometrics(fromInteraction) : disableBiometrics() + }, + [enableBiometrics, disableBiometrics], + ) + + const toggleBiometricsFromUser = useCallback( + async (newValue: boolean) => { + return toggleBiometrics(newValue, true) + }, + [toggleBiometrics], + ) + + const getBiometricsUIInfo = useCallback(() => { + switch (biometricsType) { + case 'FACE_ID': + return { label: 'Enable biometrics', icon: 'face-id' } + case 'TOUCH_ID': + return { label: 'Enable biometrics', icon: 'fingerprint' } + case 'FINGERPRINT': + return { label: 'Enable biometrics', icon: 'fingerprint' } + default: + return { label: 'Enable biometrics', icon: 'face-id' } + } + }, [biometricsType]) + + useLayoutEffect(() => { + const checkBiometrics = async () => { + const { biometricsEnabled: isEnabledAtOSLevel } = await checkBiometricsOSSettingsStatus() + + if (!isEnabledAtOSLevel) { + disableBiometrics() + } + } + checkBiometrics() + }, []) + + return { + toggleBiometrics: toggleBiometricsFromUser, + openBiometricSettings, + isBiometricsEnabled: isEnabled, + biometricsType, + isLoading, + getBiometricsUIInfo, + checkBiometricsOSSettingsStatus, + } +} diff --git a/apps/mobile/src/hooks/useCopyAndDispatchToast/index.ts b/apps/mobile/src/hooks/useCopyAndDispatchToast/index.ts index 1d3f365a76..402392e472 100644 --- a/apps/mobile/src/hooks/useCopyAndDispatchToast/index.ts +++ b/apps/mobile/src/hooks/useCopyAndDispatchToast/index.ts @@ -1,13 +1,25 @@ import { useToastController } from '@tamagui/toast' import Clipboard from '@react-native-clipboard/clipboard' +import { usePathname } from 'expo-router' +import { trackEvent } from '@/src/services/analytics/firebaseAnalytics' +import { createAddressCopyEvent } from '@/src/services/analytics/events/copy' -export const useCopyAndDispatchToast = () => { +export const useCopyAndDispatchToast = (text = 'Address copied.') => { const toast = useToastController() + const pathname = usePathname() + return (value: string) => { Clipboard.setString(value) - toast.show('Address copied.', { + toast.show(text, { native: false, duration: 2000, }) + + try { + const event = createAddressCopyEvent(pathname) + trackEvent(event) + } catch (error) { + console.error('Error tracking address copy event:', error) + } } } diff --git a/apps/mobile/src/hooks/useCurrencies.ts b/apps/mobile/src/hooks/useCurrencies.ts new file mode 100644 index 0000000000..5996c1784a --- /dev/null +++ b/apps/mobile/src/hooks/useCurrencies.ts @@ -0,0 +1,10 @@ +import type { FiatCurrencies } from '@safe-global/store/gateway/types' +import { useBalancesGetSupportedFiatCodesV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/balances' + +const useCurrencies = (): FiatCurrencies | undefined => { + const { data } = useBalancesGetSupportedFiatCodesV1Query() + + return data +} + +export default useCurrencies diff --git a/apps/mobile/src/hooks/useDelegate.test.ts b/apps/mobile/src/hooks/useDelegate.test.ts new file mode 100644 index 0000000000..38604b9874 --- /dev/null +++ b/apps/mobile/src/hooks/useDelegate.test.ts @@ -0,0 +1,229 @@ +import { renderHook, act } from '@/src/tests/test-utils' +import { useDelegate } from './useDelegate' +import { selectAllChains } from '@/src/store/chains' + +const TEST_PRIVATE_KEY = '0xdd503e13625fa99fdea1e1dfb180dd3de94ee4d16c858bb04128b46225f92f84' +// The address corresponding to the test private key +const OWNER_ADDRESS = '0x82E92d643B9B4e767Bd95a85C5e83D248Cb40548' +// Test safe address +const TEST_SAFE_ADDRESS = '0x1234567890123456789012345678901234567890' + +const mockDispatch = jest.fn() +const mockUseAppSelector = jest.fn() +const mockStorePrivateKey = jest.fn() +const mockRegisterDelegate = jest.fn() + +// Mock ethers Wallet +jest.mock('ethers', () => { + return { + Wallet: class { + address = OWNER_ADDRESS + privateKey = TEST_PRIVATE_KEY + + static createRandom() { + return { + address: '0xDelegateAddress123', + privateKey: '0xDelegatePrivateKey123', + } + } + + signTypedData() { + return 'mockedSignature' + } + }, + verifyMessage: () => 'mockedVerification', + } +}) + +// Explicitly mock siwe to avoid the verifyMessage dependency +jest.mock('siwe', () => ({ + SiweMessage: class { + constructor(props: { + address: string + chainId: number + domain: string + statement: string + nonce: string + uri: string + version: string + issuedAt: string + }) { + Object.assign(this, props) + } + + prepareMessage() { + return 'mockedSiweMessage' + } + }, +})) + +jest.mock('@/src/store/hooks', () => ({ + useAppDispatch: () => mockDispatch, + useAppSelector: (selector: unknown) => mockUseAppSelector(selector), +})) + +jest.mock('@/src/store/chains', () => ({ + selectAllChains: jest.fn(), +})) + +// Import the real addDelegate, no need to mock it +jest.mock('@safe-global/store/gateway/AUTO_GENERATED/delegates', () => ({ + cgwApi: { + useDelegatesPostDelegateV2Mutation: () => [mockRegisterDelegate], + }, +})) + +jest.mock('./useSign/useSign', () => ({ + useSign: () => ({ + storePrivateKey: mockStorePrivateKey, + }), +})) + +jest.mock('@/src/utils/logger', () => ({ + __esModule: true, + default: { + error: jest.fn(), + }, +})) + +describe('useDelegate', () => { + beforeEach(() => { + jest.clearAllMocks() + + // Mock chains data + mockUseAppSelector.mockImplementation((selector: unknown) => { + if (selector === selectAllChains) { + return [ + { chainId: '1', name: 'Ethereum' }, + { chainId: '137', name: 'Polygon' }, + ] + } + return null + }) + + // Mock successful key storage + mockStorePrivateKey.mockResolvedValue(true) + + // Mock successful delegate registration + mockRegisterDelegate.mockResolvedValue({ data: 'success' }) + + // Mock setTimeout to execute immediately in tests + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should create a delegate successfully', async () => { + const { result } = renderHook(() => useDelegate()) + + // Store the initial state before calling the function + const initialState = { ...result.current } + expect(initialState.isLoading).toBe(false) + expect(initialState.error).toBeNull() + + // Call the createDelegate function + let delegateResult = { success: false } as { + success: boolean + delegateAddress?: string + error?: string + } + + await act(async () => { + delegateResult = await result.current.createDelegate(TEST_PRIVATE_KEY) + }) + + // We need to manually trigger the async operations since we can't wait for them + // Run all pending promises + await act(async () => { + jest.runAllTimers() + }) + + // Check the result of the operation + expect(delegateResult).toBeDefined() + expect(delegateResult.success).toBe(true) + expect(delegateResult.delegateAddress).toBeTruthy() + expect(delegateResult.error).toBeUndefined() + + // Verify the private key was stored in the keychain + expect(mockStorePrivateKey).toHaveBeenCalledWith(expect.stringContaining('delegate_'), expect.any(String), { + requireAuthentication: false, + }) + + // Verify the delegate was registered on all chains + expect(mockRegisterDelegate).toHaveBeenCalledTimes(2) // Once for each chain + + // Verify the delegate was added to the Redux store + expect(mockDispatch).toHaveBeenCalled() + expect(mockDispatch.mock.calls.length).toBeGreaterThan(0) + + // Check that the hook's state was updated correctly + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('should handle error when private key storage fails', async () => { + // Mock failed key storage + mockStorePrivateKey.mockResolvedValue(false) + + const { result } = renderHook(() => useDelegate()) + + let delegateResult = { success: false } as { + success: boolean + delegateAddress?: string + error?: string + } + + await act(async () => { + delegateResult = await result.current.createDelegate(TEST_PRIVATE_KEY) + }) + + // Check the result of the operation + expect(delegateResult.success).toBe(false) + expect(delegateResult.delegateAddress).toBeUndefined() + expect(delegateResult.error).toBe('Failed to securely store delegate key') + + // Check that delegate registration was not attempted + expect(mockRegisterDelegate).not.toHaveBeenCalled() + + // Check that the hook's state was updated correctly + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBe('Failed to securely store delegate key') + }) + + it('should create a delegate for a specific safe address', async () => { + const { result } = renderHook(() => useDelegate()) + + let delegateResult = { success: false } as { + success: boolean + delegateAddress?: string + error?: string + } + + await act(async () => { + delegateResult = await result.current.createDelegate(TEST_PRIVATE_KEY, TEST_SAFE_ADDRESS) + }) + + // We need to manually trigger the async operations + await act(async () => { + jest.runAllTimers() + }) + + // Check the result of the operation + expect(delegateResult.success).toBe(true) + expect(delegateResult.delegateAddress).toBeTruthy() + + // Verify the delegate was registered with the safe address + expect(mockRegisterDelegate).toHaveBeenCalledWith( + expect.objectContaining({ + createDelegateDto: expect.objectContaining({ + safe: TEST_SAFE_ADDRESS, + }), + }), + ) + + // Just verify that dispatch was called - we'll trust that the real addDelegate implementation works + expect(mockDispatch.mock.calls[0][0].payload.delegateInfo.safe).toBe(TEST_SAFE_ADDRESS) + }) +}) diff --git a/apps/mobile/src/hooks/useDelegate.ts b/apps/mobile/src/hooks/useDelegate.ts new file mode 100644 index 0000000000..efcab9e387 --- /dev/null +++ b/apps/mobile/src/hooks/useDelegate.ts @@ -0,0 +1,139 @@ +import { useCallback, useState } from 'react' +import { Wallet } from 'ethers' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { useSign } from './useSign/useSign' +import { selectAllChains } from '@/src/store/chains' +import { addDelegate } from '@/src/store/delegatesSlice' +import { cgwApi } from '@safe-global/store/gateway/AUTO_GENERATED/delegates' +import Logger from '@/src/utils/logger' +import { getDelegateTypedData } from '@safe-global/utils/services/delegates' +import { getDelegateKeyId } from '@/src/utils/delegate' + +interface UseDelegateProps { + createDelegate: ( + ownerPrivateKey: string, + safe?: string | null, + ) => Promise<{ + success: boolean + delegateAddress?: string + error?: string + }> + isLoading: boolean + error: string | null +} + +export const useDelegate = (): UseDelegateProps => { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState<string | null>(null) + const dispatch = useAppDispatch() + const { storePrivateKey } = useSign() + + // Get all available chains + const allChains = useAppSelector(selectAllChains) + + // Access API endpoints + const [registerDelegate] = cgwApi.useDelegatesPostDelegateV2Mutation() + + const createDelegate = useCallback( + async (ownerPrivateKey: string, safe: string | null = null) => { + try { + setIsLoading(true) + setError(null) + + // Create the owner wallet from the provided private key + const ownerWallet = new Wallet(ownerPrivateKey) + const ownerAddress = ownerWallet.address + + // Create a random delegate wallet + const delegateWallet = Wallet.createRandom() + if (!delegateWallet) { + setIsLoading(false) + const errorMsg = 'Failed to create delegate wallet' + setError(errorMsg) + return { success: false, error: errorMsg } + } + + // Store delegate private key in keychain with default protection (no biometrics) + const delegateKeyId = getDelegateKeyId(ownerAddress, delegateWallet.address) + const storeSuccess = await storePrivateKey(delegateKeyId, delegateWallet.privateKey, { + requireAuthentication: false, + }) + + if (!storeSuccess) { + setIsLoading(false) + const errorMsg = 'Failed to securely store delegate key' + setError(errorMsg) + return { success: false, error: errorMsg } + } + + // Register on all chains and wait for completion + const registrationPromises = allChains.map(async (chain, index) => { + try { + // Add a delay to avoid 429 rate limiting, staggered by index + if (index > 0) { + await new Promise((resolve) => setTimeout(resolve, 300 * index)) + } + + // Generate typed data for this chain + const typedData = getDelegateTypedData(chain.chainId, delegateWallet.address) + + // Sign the message with the owner's wallet + const signature = await ownerWallet.signTypedData(typedData.domain, typedData.types, typedData.message) + + // Register delegate on the backend + await registerDelegate({ + chainId: chain.chainId, + createDelegateDto: { + safe, + delegate: delegateWallet.address, + delegator: ownerAddress, + signature, + label: 'Mobile App Delegate', + }, + }) + + return true + } catch (error) { + Logger.error(`Failed to register delegate for chain ${chain.chainId}`, error) + return false + } + }) + + // We are not awaiting this as we don't want to block the user from using the app + Promise.all(registrationPromises) + + // Add to redux store once after all chains are processed + dispatch( + addDelegate({ + ownerAddress, + delegateAddress: delegateWallet.address, + delegateInfo: { + safe, + delegate: delegateWallet.address, + delegator: ownerAddress, + label: 'Mobile App Delegate', + }, + }), + ) + + setIsLoading(false) + return { success: true, delegateAddress: delegateWallet.address } + } catch (error) { + Logger.error('Delegate creation failed', error) + setIsLoading(false) + const errorMsg = error instanceof Error ? error.message : String(error) + setError(errorMsg) + return { success: false, error: errorMsg } + } + }, + [allChains, dispatch, storePrivateKey, registerDelegate], + ) + + return { + createDelegate, + isLoading, + error, + } +} + +export default useDelegate diff --git a/apps/mobile/src/hooks/useDelegateCleanup.ts b/apps/mobile/src/hooks/useDelegateCleanup.ts new file mode 100644 index 0000000000..8e3f34026d --- /dev/null +++ b/apps/mobile/src/hooks/useDelegateCleanup.ts @@ -0,0 +1,108 @@ +import { useCallback, useState, useMemo } from 'react' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { selectAllChains } from '@/src/store/chains' +import { selectDelegates } from '@/src/store/delegatesSlice' +import { cgwApi } from '@safe-global/store/gateway/AUTO_GENERATED/delegates' +import { useNotificationCleanup } from '@/src/hooks/useNotificationCleanup' +import { type Address } from '@/src/types/address' +import { + DelegateCleanupService, + DelegateCleanupPhase, + DelegateCleanupProgress, + DelegateCleanupError, + DelegateCleanupErrorType, +} from '@/src/services/delegate-cleanup' +import { StandardErrorResult, ErrorType } from '@/src/utils/errors' + +// Re-export types for backward compatibility +export type { DelegateCleanupError, DelegateCleanupProgress } from '@/src/services/delegate-cleanup' +export { DelegateCleanupPhase, DelegateCleanupErrorType } from '@/src/services/delegate-cleanup' + +// Helper function to map standard error types to cleanup error types +const mapErrorTypeToCleanupErrorType = (errorType?: ErrorType): DelegateCleanupErrorType => { + switch (errorType) { + case ErrorType.VALIDATION_ERROR: + return DelegateCleanupErrorType.INVALID_PARAMETERS + case ErrorType.CLEANUP_ERROR: + return DelegateCleanupErrorType.ORCHESTRATION_FAILED + case ErrorType.NETWORK_ERROR: + return DelegateCleanupErrorType.BACKEND_REMOVAL_FAILED + case ErrorType.SYSTEM_ERROR: + default: + return DelegateCleanupErrorType.ORCHESTRATION_FAILED + } +} + +interface UseDelegateCleanupProps { + removeAllDelegatesForOwner: ( + ownerAddress: Address, + ownerPrivateKey: string, + ) => Promise<StandardErrorResult<{ processedCount: number }>> + isLoading: boolean + error: DelegateCleanupError | null + progress: DelegateCleanupProgress +} + +export const useDelegateCleanup = (): UseDelegateCleanupProps => { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState<DelegateCleanupError | null>(null) + const [progress, setProgress] = useState<DelegateCleanupProgress>({ + phase: DelegateCleanupPhase.IDLE, + message: 'Ready to clean up delegates', + }) + const dispatch = useAppDispatch() + + const allChains = useAppSelector(selectAllChains) + const allDelegates = useAppSelector(selectDelegates) + + const { cleanupNotificationsForDelegate } = useNotificationCleanup() + + const [deleteDelegate] = cgwApi.useDelegatesDeleteDelegateV2Mutation() + + const cleanupService = useMemo(() => { + return new DelegateCleanupService({ + allChains, + allDelegates, + cleanupNotificationsForDelegate, + deleteDelegate, + dispatch, + onProgress: (progress) => { + setProgress(progress) + }, + }) + }, [allChains, allDelegates, cleanupNotificationsForDelegate, deleteDelegate, dispatch]) + + const removeAllDelegatesForOwner = useCallback( + async (ownerAddress: Address, ownerPrivateKey: string) => { + try { + setIsLoading(true) + setError(null) + + const result = await cleanupService.removeAllDelegatesForOwner(ownerAddress, ownerPrivateKey) + + if (!result.success) { + const cleanupError: DelegateCleanupError = { + type: mapErrorTypeToCleanupErrorType(result.error?.type), + message: result.error?.message || 'Unknown error', + details: result.error?.details, + } + setError(cleanupError) + } + + return result + } finally { + setIsLoading(false) + } + }, + [cleanupService], + ) + + return { + removeAllDelegatesForOwner, + isLoading, + error, + progress, + } +} + +export default useDelegateCleanup diff --git a/apps/mobile/src/hooks/useDelegateCleanup/index.ts b/apps/mobile/src/hooks/useDelegateCleanup/index.ts new file mode 100644 index 0000000000..9c56149efa --- /dev/null +++ b/apps/mobile/src/hooks/useDelegateCleanup/index.ts @@ -0,0 +1 @@ +export * from './utils' diff --git a/apps/mobile/src/hooks/useDelegateCleanup/utils.test.ts b/apps/mobile/src/hooks/useDelegateCleanup/utils.test.ts new file mode 100644 index 0000000000..3d2581ac5b --- /dev/null +++ b/apps/mobile/src/hooks/useDelegateCleanup/utils.test.ts @@ -0,0 +1,256 @@ +import { cleanupDelegateNotifications, removeDelegatesFromBackend, cleanupDelegateKeychain } from './utils' +import { Wallet } from 'ethers' +import { keyStorageService } from '@/src/services/key-storage' +import { getDelegateKeyId } from '@/src/utils/delegate' +import { type Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { type Address } from '@/src/types/address' +import Logger from '@/src/utils/logger' + +// Mock dependencies +jest.mock('@/src/services/key-storage') +jest.mock('@/src/utils/delegate') +jest.mock('@/src/utils/logger') +jest.mock('ethers') + +const mockKeyStorageService = keyStorageService as jest.Mocked<typeof keyStorageService> +const mockGetDelegateKeyId = getDelegateKeyId as jest.MockedFunction<typeof getDelegateKeyId> +const mockLogger = Logger as jest.Mocked<typeof Logger> + +describe('useDelegateCleanup utils', () => { + const mockOwnerAddress = '0x123456789abcdef' as Address + const mockDelegateAddress1 = '0xabcdef123456789' as Address + const mockDelegateAddress2 = '0xfedcba987654321' as Address + const mockDelegateAddresses = [mockDelegateAddress1, mockDelegateAddress2] + const mockChains: Chain[] = [ + { + chainId: '1', + chainName: 'Ethereum', + description: 'Ethereum Mainnet', + l2: false, + isTestnet: false, + zk: false, + nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18, logoUri: '' }, + transactionService: 'https://safe-transaction-mainnet.safe.global', + blockExplorerUriTemplate: { address: '', txHash: '', api: '' }, + ensRegistryAddress: '', + recommendedMasterCopyVersion: '', + disabledWallets: [], + features: [], + gasPrice: [], + publicRpcUri: { authentication: 'NO_AUTHENTICATION', value: '' }, + rpcUri: { authentication: 'NO_AUTHENTICATION', value: '' }, + safeAppsRpcUri: { authentication: 'NO_AUTHENTICATION', value: '' }, + shortName: 'eth', + theme: { textColor: '', backgroundColor: '' }, + } as unknown as Chain, + { + chainId: '137', + chainName: 'Polygon', + description: 'Polygon Mainnet', + l2: true, + isTestnet: false, + zk: false, + nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18, logoUri: '' }, + transactionService: 'https://safe-transaction-polygon.safe.global', + blockExplorerUriTemplate: { address: '', txHash: '', api: '' }, + ensRegistryAddress: '', + recommendedMasterCopyVersion: '', + disabledWallets: [], + features: [], + gasPrice: [], + publicRpcUri: { authentication: 'NO_AUTHENTICATION', value: '' }, + rpcUri: { authentication: 'NO_AUTHENTICATION', value: '' }, + safeAppsRpcUri: { authentication: 'NO_AUTHENTICATION', value: '' }, + shortName: 'matic', + theme: { textColor: '', backgroundColor: '' }, + } as unknown as Chain, + ] + beforeEach(() => { + jest.clearAllMocks() + mockLogger.info = jest.fn() + mockLogger.warn = jest.fn() + mockLogger.error = jest.fn() + }) + + describe('cleanupDelegateNotifications', () => { + const mockCleanupNotificationsForDelegate = jest.fn() + + it('should return success when no delegates provided', async () => { + const result = await cleanupDelegateNotifications(mockOwnerAddress, [], mockCleanupNotificationsForDelegate) + + expect(result.success).toBe(true) + expect(mockCleanupNotificationsForDelegate).not.toHaveBeenCalled() + }) + + it('should successfully cleanup notifications for all delegates', async () => { + mockCleanupNotificationsForDelegate.mockResolvedValue({ success: true }) + + const result = await cleanupDelegateNotifications( + mockOwnerAddress, + mockDelegateAddresses, + mockCleanupNotificationsForDelegate, + ) + + expect(result.success).toBe(true) + expect(mockCleanupNotificationsForDelegate).toHaveBeenCalledTimes(2) + expect(mockCleanupNotificationsForDelegate).toHaveBeenCalledWith(mockOwnerAddress, mockDelegateAddress1) + expect(mockCleanupNotificationsForDelegate).toHaveBeenCalledWith(mockOwnerAddress, mockDelegateAddress2) + }) + + it('should handle notification cleanup failures', async () => { + mockCleanupNotificationsForDelegate + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false, error: { message: 'Network error' } }) + + const result = await cleanupDelegateNotifications( + mockOwnerAddress, + mockDelegateAddresses, + mockCleanupNotificationsForDelegate, + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Network error') + expect(result.failedDelegates).toContain(mockDelegateAddress2) + }) + + it('should handle exceptions during notification cleanup', async () => { + mockCleanupNotificationsForDelegate.mockRejectedValue(new Error('API error')) + + const result = await cleanupDelegateNotifications( + mockOwnerAddress, + mockDelegateAddresses, + mockCleanupNotificationsForDelegate, + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('API error') + expect(result.failedDelegates).toEqual(mockDelegateAddresses) + }) + }) + + describe('removeDelegatesFromBackend', () => { + const mockOwnerWallet = new Wallet('0x123') + const mockDeleteDelegate = jest.fn() + const mockSignTypedData = jest.fn() + + beforeEach(() => { + mockOwnerWallet.signTypedData = mockSignTypedData + mockSignTypedData.mockResolvedValue('0xsignature') + mockDeleteDelegate.mockResolvedValue({}) + }) + + it('should return success when no delegates provided', async () => { + const result = await removeDelegatesFromBackend( + mockOwnerAddress, + [], + mockOwnerWallet, + mockChains, + mockDeleteDelegate, + ) + + expect(result.success).toBe(true) + expect(mockDeleteDelegate).not.toHaveBeenCalled() + }) + + it('should successfully remove delegates from all chains', async () => { + const resultPromise = removeDelegatesFromBackend( + mockOwnerAddress, + mockDelegateAddresses, + mockOwnerWallet, + mockChains, + mockDeleteDelegate, + ) + + // Fast-forward through all timers (delays between delegates and chains) + await jest.advanceTimersByTimeAsync(10000) + + const result = await resultPromise + + expect(result.success).toBe(true) + // 2 delegates * 2 chains = 4 calls + expect(mockDeleteDelegate).toHaveBeenCalledTimes(4) + }) + + it('should handle API failures and retry', async () => { + mockDeleteDelegate.mockRejectedValueOnce(new Error('429 rate limit')).mockResolvedValue({}) + + const resultPromise = removeDelegatesFromBackend( + mockOwnerAddress, + [mockDelegateAddress1], + mockOwnerWallet, + [mockChains[0]], + mockDeleteDelegate, + ) + + // Fast-forward through all timers (retry delays and delegation delays) + await jest.advanceTimersByTimeAsync(10000) + + const result = await resultPromise + + expect(result.success).toBe(true) + expect(mockDeleteDelegate).toHaveBeenCalledTimes(2) // First call fails, second succeeds + }) + + it('should handle persistent API failures', async () => { + mockDeleteDelegate.mockReset().mockRejectedValue(new Error('Persistent error')) + + const resultPromise = removeDelegatesFromBackend( + mockOwnerAddress, + [mockDelegateAddress1], + mockOwnerWallet, + [mockChains[0]], + mockDeleteDelegate, + ) + + // Fast-forward through all timers (retry delays and delegation delays) + await jest.advanceTimersByTimeAsync(10000) + + const result = await resultPromise + + expect(result.success).toBe(false) + expect(result.error).toContain('Failed to remove 1 out of 1 delegates') + expect(result.failedDelegates).toContain(mockDelegateAddress1) + }) + }) + + describe('cleanupDelegateKeychain', () => { + beforeEach(() => { + mockGetDelegateKeyId.mockReturnValue('mock-key-id') + mockKeyStorageService.removePrivateKey.mockResolvedValue() + }) + + it('should return success when no delegates provided', async () => { + const result = await cleanupDelegateKeychain(mockOwnerAddress, []) + + expect(result.success).toBe(true) + expect(mockKeyStorageService.removePrivateKey).not.toHaveBeenCalled() + }) + + it('should successfully remove all delegate keys from keychain', async () => { + const result = await cleanupDelegateKeychain(mockOwnerAddress, mockDelegateAddresses) + + expect(result.success).toBe(true) + expect(mockKeyStorageService.removePrivateKey).toHaveBeenCalledTimes(2) + expect(mockGetDelegateKeyId).toHaveBeenCalledWith(mockOwnerAddress, mockDelegateAddress1) + expect(mockGetDelegateKeyId).toHaveBeenCalledWith(mockOwnerAddress, mockDelegateAddress2) + }) + + it('should handle keychain removal failures gracefully', async () => { + mockKeyStorageService.removePrivateKey.mockResolvedValueOnce().mockRejectedValueOnce(new Error('Keychain error')) + + const result = await cleanupDelegateKeychain(mockOwnerAddress, mockDelegateAddresses) + + expect(result.success).toBe(true) // Should still succeed as keychain cleanup is not critical + expect(result.failedDelegates).toContain(mockDelegateAddress2) + }) + + it('should handle unexpected errors during keychain cleanup', async () => { + mockKeyStorageService.removePrivateKey.mockRejectedValue(new Error('Unexpected error')) + + const result = await cleanupDelegateKeychain(mockOwnerAddress, mockDelegateAddresses) + + expect(result.success).toBe(true) // Should still succeed as keychain cleanup is not critical + expect(result.failedDelegates).toEqual(mockDelegateAddresses) + }) + }) +}) diff --git a/apps/mobile/src/hooks/useDelegateCleanup/utils.ts b/apps/mobile/src/hooks/useDelegateCleanup/utils.ts new file mode 100644 index 0000000000..2f4f67e356 --- /dev/null +++ b/apps/mobile/src/hooks/useDelegateCleanup/utils.ts @@ -0,0 +1,255 @@ +import Logger from '@/src/utils/logger' +import { type Address } from '@/src/types/address' +import { Wallet } from 'ethers' +import { getDelegateTypedData } from '@safe-global/utils/services/delegates' +import { type Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { type DelegatesDeleteDelegateV2ApiArg } from '@safe-global/store/gateway/AUTO_GENERATED/delegates' +import { keyStorageService } from '@/src/services/key-storage' +import { getDelegateKeyId } from '@/src/utils/delegate' +import { withGeneralRetry } from '@/src/utils/retry' + +// Types for cleanup results +interface CleanupResult { + success: boolean + error?: { + message: string + code?: string + } +} + +interface NotificationCleanupResult { + success: boolean + error?: string + failedDelegates?: Address[] +} + +interface DelegateRemovalResult { + success: boolean + error?: string + failedDelegates?: Address[] +} + +interface KeychainCleanupResult { + success: boolean + error?: string + failedDelegates?: Address[] +} + +/** + * Cleans up notifications for all delegates of a given owner + * This is a critical step that must succeed before proceeding with delegate removal + * + * If we don't manage to unsubscribe the user, but just proceed and delete the key + * the user is going to receive push notifications for this safe, which can get quite annoying + */ +export const cleanupDelegateNotifications = async ( + ownerAddress: Address, + delegateAddresses: Address[], + cleanupNotificationsForDelegate: (ownerAddress: Address, delegateAddress: Address) => Promise<CleanupResult>, +): Promise<NotificationCleanupResult> => { + if (!delegateAddresses || delegateAddresses.length === 0) { + return { success: true } + } + + try { + const notificationCleanupResults = await Promise.allSettled( + delegateAddresses.map(async (delegateAddress) => { + try { + const result = await cleanupNotificationsForDelegate(ownerAddress, delegateAddress) + if (!result.success && result.error) { + throw new Error(`Notification cleanup failed for ${delegateAddress}: ${result.error.message}`) + } + return result + } catch (error) { + Logger.error(`Failed to cleanup notifications for delegate ${delegateAddress}`, error) + throw error + } + }), + ) + + // Check if any notification cleanup failed with blocking errors + const failedCleanups = notificationCleanupResults + .filter((result) => result.status === 'rejected') + .map((result) => result.reason) + + if (failedCleanups.length > 0) { + const failedDelegates = delegateAddresses.filter( + (_, index) => notificationCleanupResults[index].status === 'rejected', + ) + + const errorMsg = `Cannot delete private key: ${failedCleanups.join(', ')}. Please check your internet connection and try again.` + + return { + success: false, + error: errorMsg, + failedDelegates, + } + } + + return { success: true } + } catch (error) { + Logger.error('Delegate notification cleanup failed', error) + const errorMsg = error instanceof Error ? error.message : String(error) + return { + success: false, + error: errorMsg, + failedDelegates: delegateAddresses, + } + } +} + +export const removeDelegatesFromBackend = async ( + ownerAddress: Address, + delegateAddresses: Address[], + ownerWallet: Wallet, + allChains: Chain[], + deleteDelegate: (params: DelegatesDeleteDelegateV2ApiArg) => Promise<unknown>, +): Promise<DelegateRemovalResult> => { + if (!delegateAddresses || delegateAddresses.length === 0) { + return { success: true } + } + + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + + try { + const removalPromises = delegateAddresses.map(async (delegateAddress, delegateIndex) => { + try { + // Stagger delegate processing to avoid overwhelming the backend + if (delegateIndex > 0) { + await sleep(500 * delegateIndex) + } + + const chainRemovalPromises = allChains.map(async (chain, chainIndex) => { + try { + const baseDelay = 300 * chainIndex + 100 * delegateIndex + if (baseDelay > 0) { + await sleep(baseDelay) + } + + const result = await withGeneralRetry(async () => { + const typedData = getDelegateTypedData(chain.chainId, delegateAddress) + + const signature = await ownerWallet.signTypedData(typedData.domain, typedData.types, typedData.message) + + await deleteDelegate({ + chainId: chain.chainId, + delegateAddress, + deleteDelegateV2Dto: { + delegator: ownerAddress, + signature, + }, + }) + + return { success: true, chainId: chain.chainId } + }, 3) + + return result + } catch (error) { + Logger.error(`Failed to remove delegate from chain ${chain.chainId} after retries`, error) + return { success: false, chainId: chain.chainId, error } + } + }) + + const chainResults = await Promise.all(chainRemovalPromises) + + const failedChains = chainResults.filter((result) => !result.success) + const successfulChains = chainResults.filter((result) => result.success).length + + if (failedChains.length > 0) { + Logger.warn(`Some chains failed for delegate ${delegateAddress}`, { + delegateAddress, + failedChains: failedChains.map((r) => r.chainId), + totalChains: allChains.length, + }) + } + + Logger.info(`Delegate ${delegateAddress} removed from ${successfulChains}/${allChains.length} chains`) + + // If all chains failed, consider the delegate removal failed + if (successfulChains === 0) { + return { success: false, delegateAddress, error: new Error('Failed to remove delegate from all chains') } + } + + return { success: true, delegateAddress } + } catch (error) { + Logger.error(`Failed to remove delegate ${delegateAddress}`, error) + return { success: false, delegateAddress, error } + } + }) + + const delegateResults = await Promise.all(removalPromises) + + const failedDelegates = delegateResults + .filter((result) => !result.success) + .map((result) => result.delegateAddress) + .filter(Boolean) as Address[] + + if (failedDelegates.length > 0) { + Logger.warn(`Some delegates failed to be removed from backend`, { + failedDelegates, + totalDelegates: delegateAddresses.length, + }) + return { + success: false, + error: `Failed to remove ${failedDelegates.length} out of ${delegateAddresses.length} delegates from backend`, + failedDelegates, + } + } + + Logger.info(`Successfully removed all ${delegateAddresses.length} delegates from backend`) + return { success: true } + } catch (error) { + Logger.error('Delegate backend removal failed', error) + const errorMsg = error instanceof Error ? error.message : String(error) + return { + success: false, + error: errorMsg, + failedDelegates: delegateAddresses, + } + } +} + +export const cleanupDelegateKeychain = async ( + ownerAddress: Address, + delegateAddresses: Address[], +): Promise<KeychainCleanupResult> => { + if (!delegateAddresses || delegateAddresses.length === 0) { + return { success: true } + } + + try { + const keychainCleanupPromises = delegateAddresses.map(async (delegateAddress) => { + try { + const delegateKeyId = getDelegateKeyId(ownerAddress, delegateAddress) + await keyStorageService.removePrivateKey(delegateKeyId, { requireAuthentication: false }) + return { success: true, delegateAddress } + } catch (error) { + Logger.warn(`Failed to remove delegate key from keychain: ${delegateAddress}`, error) + return { success: false, delegateAddress, error } + } + }) + + const keychainResults = await Promise.all(keychainCleanupPromises) + + const failedDelegates = keychainResults + .filter((result) => !result.success) + .map((result) => result.delegateAddress) + .filter(Boolean) as Address[] + + if (failedDelegates.length > 0) { + Logger.warn(`Some delegate keys failed to be removed from keychain`, failedDelegates) + // Note: We don't fail the entire process for keychain cleanup failures + // as delegate remove on the backend is not critical for the user experience + } + + return { success: true, failedDelegates } + } catch (error) { + Logger.error('Delegate keychain cleanup failed', error) + const errorMsg = error instanceof Error ? error.message : String(error) + return { + success: true, // Still return success as delegate removal on the backend is not critical + error: errorMsg, + failedDelegates: delegateAddresses, + } + } +} diff --git a/apps/mobile/src/hooks/useHasFeature.ts b/apps/mobile/src/hooks/useHasFeature.ts new file mode 100644 index 0000000000..e92ea4cbc6 --- /dev/null +++ b/apps/mobile/src/hooks/useHasFeature.ts @@ -0,0 +1,8 @@ +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' +import { useAppSelector } from '@/src/store/hooks' +import { selectActiveChain } from '@/src/store/chains' + +export const useHasFeature = (feature: FEATURES): boolean | undefined => { + const chain = useAppSelector(selectActiveChain) + return chain ? hasFeature(chain, feature) : undefined +} diff --git a/apps/mobile/src/hooks/useInfiniteScroll/useInfiniteScroll.ts b/apps/mobile/src/hooks/useInfiniteScroll/useInfiniteScroll.ts index fc2290b3e1..fec6985daf 100644 --- a/apps/mobile/src/hooks/useInfiniteScroll/useInfiniteScroll.ts +++ b/apps/mobile/src/hooks/useInfiniteScroll/useInfiniteScroll.ts @@ -2,7 +2,7 @@ import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { useCallback, useEffect, useState } from 'react' import { useSelector } from 'react-redux' -type TUseInfiniteScrollData<J> = { results: J[]; next?: string | null } +type TUseInfiniteScrollData<J> = { results: J[]; previous?: string | null; next?: string | null } type TUseInfiniteScrollConfig<T, J> = { refetch: () => void @@ -12,10 +12,10 @@ type TUseInfiniteScrollConfig<T, J> = { export const useInfiniteScroll = <T, J>({ refetch, setPageUrl, data }: TUseInfiniteScrollConfig<T, J>) => { const activeSafe = useSelector(selectActiveSafe) - const [list, setList] = useState<J[]>([]) + const [list, setList] = useState<T & TUseInfiniteScrollData<J>>() useEffect(() => { - setList([]) + setList(undefined) }, [activeSafe]) useEffect(() => { @@ -23,7 +23,16 @@ export const useInfiniteScroll = <T, J>({ refetch, setPageUrl, data }: TUseInfin return } - setList((prev) => (prev ? [...prev, ...data.results] : data.results)) + setList((prev) => { + if (prev?.previous === data.previous) { + return data + } + + return { + ...data, + results: [...(prev?.results || []), ...data.results], + } + }) }, [data]) const onEndReached = useCallback(() => { diff --git a/apps/mobile/src/hooks/useInitWeb3.ts b/apps/mobile/src/hooks/useInitWeb3.ts new file mode 100644 index 0000000000..70449993c2 --- /dev/null +++ b/apps/mobile/src/hooks/useInitWeb3.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react' +import { createWeb3ReadOnly, setWeb3ReadOnly } from '@/src/hooks/wallets/web3' +import { selectRpc } from '@/src/store/settingsSlice' +import { useAppSelector } from '@/src/store/hooks' +import { selectActiveChain } from '@/src/store/chains' + +export const useInitWeb3 = () => { + const chain = useAppSelector(selectActiveChain) + const customRpc = useAppSelector(selectRpc) + const customRpcUrl = chain ? customRpc?.[chain.chainId] : undefined + + useEffect(() => { + if (!chain) { + setWeb3ReadOnly(undefined) + return + } + const web3ReadOnly = createWeb3ReadOnly(chain, customRpcUrl) + setWeb3ReadOnly(web3ReadOnly) + }, [chain, customRpcUrl]) +} diff --git a/apps/mobile/src/hooks/useMakeSafesWithChainId/useMakeSafesWithChainId.ts b/apps/mobile/src/hooks/useMakeSafesWithChainId/useMakeSafesWithChainId.ts new file mode 100644 index 0000000000..d564e82a45 --- /dev/null +++ b/apps/mobile/src/hooks/useMakeSafesWithChainId/useMakeSafesWithChainId.ts @@ -0,0 +1,13 @@ +import { useAppSelector } from '@/src/store/hooks' +import { selectAllChainsIds } from '@/src/store/chains' +import { useMemo } from 'react' +import { makeSafeId } from '@/src/utils/formatters' + +export function useMakeSafesWithChainId(safeAddress: string) { + const chainIds = useAppSelector(selectAllChainsIds) + const safes = useMemo( + () => chainIds.map((chainId: string) => makeSafeId(chainId, safeAddress)).join(','), + [chainIds, safeAddress], + ) + return safes +} diff --git a/apps/mobile/src/hooks/useNotificationCleanup.ts b/apps/mobile/src/hooks/useNotificationCleanup.ts new file mode 100644 index 0000000000..e75373f56f --- /dev/null +++ b/apps/mobile/src/hooks/useNotificationCleanup.ts @@ -0,0 +1,99 @@ +import { useCallback, useState } from 'react' +import { useAppSelector } from '@/src/store/hooks' +import { selectAllChains } from '@/src/store/chains' +import { selectAllSafes } from '@/src/store/safesSlice' +import { selectDelegates } from '@/src/store/delegatesSlice' +import { type Address } from '@/src/types/address' +import { + classifyNotificationError, + getAffectedSafes, + hasOtherDelegates, + type NotificationCleanupError, +} from '@/src/utils/notifications/cleanup' +import { cleanupSafeNotifications } from '@/src/services/notifications/operations' +import Logger from '@/src/utils/logger' + +interface NotificationCleanupResult { + success: boolean + error?: NotificationCleanupError +} + +interface UseNotificationCleanupProps { + cleanupNotificationsForDelegate: ( + ownerAddress: Address, + delegateAddress: Address, + ) => Promise<NotificationCleanupResult> + isLoading: boolean + error: string | null +} + +export const useNotificationCleanup = (): UseNotificationCleanupProps => { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState<string | null>(null) + + const allChains = useAppSelector(selectAllChains) + const allSafes = useAppSelector(selectAllSafes) + const safeSubscriptions = useAppSelector((state) => state.safeSubscriptions) + const delegates = useAppSelector(selectDelegates) + + const cleanupNotificationsForDelegate = useCallback( + async (ownerAddress: Address, delegateAddress: Address): Promise<NotificationCleanupResult> => { + try { + setIsLoading(true) + setError(null) + + const affectedSafes = getAffectedSafes(ownerAddress, allSafes, allChains, safeSubscriptions) + + if (affectedSafes.length === 0) { + Logger.info('No safes with notification subscriptions found for delegate cleanup') + setIsLoading(false) + return { success: true } + } + + const cleanupPromises = affectedSafes.map(async (safe) => { + try { + const hasOthers = hasOtherDelegates(safe.address as Address, delegateAddress, { + safes: allSafes, + delegates, + }) + + await cleanupSafeNotifications(safe.address, safe.chainIds, delegateAddress, hasOthers) + + return { success: true } + } catch (error) { + Logger.error(`Failed to cleanup notifications for safe ${safe.address}`, error) + throw error + } + }) + + await Promise.all(cleanupPromises) + + setIsLoading(false) + return { success: true } + } catch (error) { + Logger.error('Notification cleanup failed', error) + setIsLoading(false) + + const classifiedError = classifyNotificationError(error) + + if (classifiedError.type === 'safe') { + Logger.info(`Safe error during notification cleanup: ${classifiedError.message}`, error) + return { success: true } // Don't block deletion for safe errors + } else { + const errorMsg = classifiedError.message + setError(errorMsg) + return { success: false, error: classifiedError } + } + } + }, + [allChains, allSafes, safeSubscriptions, delegates], + ) + + return { + cleanupNotificationsForDelegate, + isLoading, + error, + } +} + +export default useNotificationCleanup diff --git a/apps/mobile/src/hooks/useNotificationGTWPermissions.test.ts b/apps/mobile/src/hooks/useNotificationGTWPermissions.test.ts new file mode 100644 index 0000000000..e9e88957e3 --- /dev/null +++ b/apps/mobile/src/hooks/useNotificationGTWPermissions.test.ts @@ -0,0 +1,71 @@ +import { renderHook, RootState } from '@/src/tests/test-utils' +import { useNotificationGTWPermissions } from './useNotificationGTWPermissions' +import { selectSigners } from '@/src/store/signersSlice' + +const mockUseAppSelector = jest.fn() + +const mockedSafeInfo = { + address: { value: '0x123' as `0x${string}`, name: 'Test Safe' }, + threshold: 1, + owners: [{ value: '0x456' as `0x${string}` }], + fiatTotal: '1000', + chainId: '1', + queued: 0, +} +const mockState = { + safes: { + [mockedSafeInfo.address.value]: { + [mockedSafeInfo.chainId]: { + ...mockedSafeInfo, + }, + }, + }, + signers: { + [mockedSafeInfo.owners[0].value]: { + address: mockedSafeInfo.owners[0].value, + name: 'Test Safe', + }, + }, + settings: { + themePreference: 'auto', + }, + notifications: { + isAppNotificationsEnabled: true, + isDeviceNotificationsEnabled: true, + }, + activeSafe: { + address: mockedSafeInfo.address.value, + chainId: mockedSafeInfo.chainId, + }, +} as unknown as RootState +describe('useNotificationGTWPermissions', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('returns the correct account type for an owner', () => { + const { result } = renderHook(() => useNotificationGTWPermissions('0x123', '1'), mockState) + const { ownerFound, accountType } = result.current.getAccountType() + expect(ownerFound).toEqual({ value: '0x456' }) + expect(accountType).toBe('OWNER') + }) + + it('returns the correct account type for a regular user', () => { + mockUseAppSelector.mockImplementation((selector: unknown) => { + if (selector === selectSigners) { + return {} // No signers + } + return { + SafeInfo: { + ...mockedSafeInfo, + owners: [{ value: '0x789' }], // Owner that isn't a signer + }, + } + }) + + const { result } = renderHook(() => useNotificationGTWPermissions('0x123')) + const { ownerFound, accountType } = result.current.getAccountType() + expect(ownerFound).toBeNull() + expect(accountType).toBe('REGULAR') + }) +}) diff --git a/apps/mobile/src/hooks/useNotificationGTWPermissions.ts b/apps/mobile/src/hooks/useNotificationGTWPermissions.ts new file mode 100644 index 0000000000..a21b97984b --- /dev/null +++ b/apps/mobile/src/hooks/useNotificationGTWPermissions.ts @@ -0,0 +1,18 @@ +import { useCallback } from 'react' +import { useAppSelector } from '../store/hooks' +import { RootState } from '../store' +import { selectSigners } from '../store/signersSlice' +import { selectSafeInfo } from '../store/safesSlice' +import { getAccountType } from '@/src/utils/notifications/accountType' + +export function useNotificationGTWPermissions(safeAddress: string, chainId?: string) { + const appSigners = useAppSelector(selectSigners) + + const activeSafeInfo = useAppSelector((state: RootState) => selectSafeInfo(state, safeAddress as `0x${string}`)) + + const safe = chainId ? activeSafeInfo?.[chainId] : undefined + + const getAccountTypeFn = useCallback(() => getAccountType(safe, appSigners), [safe, appSigners]) + + return { getAccountType: getAccountTypeFn } +} diff --git a/apps/mobile/src/hooks/useNotificationHandler.ts b/apps/mobile/src/hooks/useNotificationHandler.ts new file mode 100644 index 0000000000..cc284ab644 --- /dev/null +++ b/apps/mobile/src/hooks/useNotificationHandler.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react' +import { EventType } from '@notifee/react-native' +import NotificationsService from '@/src/services/notifications/NotificationService' +import Logger from '@/src/utils/logger' + +/** + * Hook to handle notification events when the app is in the foreground + */ +export const useNotificationHandler = () => { + useEffect(() => { + // Set up foreground event listener + const unsubscribe = NotificationsService.onForegroundEvent(async ({ type, detail }) => { + try { + if (type === EventType.PRESS) { + await NotificationsService.handleNotificationPress({ detail }) + } else if (type === EventType.DELIVERED) { + await NotificationsService.incrementBadgeCount(1) + } else if (type === EventType.DISMISSED) { + Logger.info('User dismissed notification:', detail.notification?.id) + } + } catch (error) { + Logger.error('useNotificationHandler: Error handling foreground notification event', error) + } + }) + + // Cleanup + return () => { + unsubscribe() + } + }, []) +} diff --git a/apps/mobile/src/hooks/useNotificationManager.test.ts b/apps/mobile/src/hooks/useNotificationManager.test.ts new file mode 100644 index 0000000000..c11968673f --- /dev/null +++ b/apps/mobile/src/hooks/useNotificationManager.test.ts @@ -0,0 +1,71 @@ +import { renderHook, act, RootState } from '@/src/tests/test-utils' +import { useNotificationManager } from './useNotificationManager' +import NotificationsService from '@/src/services/notifications/NotificationService' +const mockRegisterForNotifications = jest.fn() +const mockUnregisterForNotifications = jest.fn() +jest.mock('@/src/services/notifications/NotificationService', () => ({ + isDeviceNotificationEnabled: jest.fn(), + getAllPermissions: jest.fn(), + requestPushNotificationsPermission: jest.fn(), +})) +jest.mock('@/src/hooks/useRegisterForNotifications', () => ({ + __esModule: true, + default: () => ({ + registerForNotifications: mockRegisterForNotifications, + unregisterForNotifications: mockUnregisterForNotifications, + isLoading: false, + error: null, + }), +})) +const mockedSafeInfo = { + address: { value: '0x123' as `0x${string}`, name: 'Test Safe' }, + threshold: 1, + owners: [{ value: '0x456' as `0x${string}` }], + fiatTotal: '1000', + chainId: '1', + queued: 0, +} +const mockState = { + safes: { + [mockedSafeInfo.address.value]: { + [mockedSafeInfo.chainId]: { + ...mockedSafeInfo, + }, + }, + }, + signers: { + [mockedSafeInfo.owners[0].value]: { + address: mockedSafeInfo.owners[0].value, + name: 'Test Safe', + }, + }, + settings: { + themePreference: 'auto', + }, + notifications: { + isAppNotificationsEnabled: true, + isDeviceNotificationsEnabled: true, + }, + activeSafe: { + address: mockedSafeInfo.address.value, + chainId: mockedSafeInfo.chainId, + }, +} as unknown as RootState +describe('useNotificationManager', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns the correct notification status', () => { + const { result } = renderHook(() => useNotificationManager(), mockState) + expect(result.current.isAppNotificationEnabled).toBe(true) + }) + it('handles errors when enabling notifications', async () => { + jest.mocked(NotificationsService.isDeviceNotificationEnabled).mockRejectedValueOnce(new Error('Test error')) + const { result } = renderHook(() => useNotificationManager()) + await act(async () => { + const success = await result.current.enableNotification() + expect(success).toBe(false) + }) + }) +}) diff --git a/apps/mobile/src/hooks/useNotificationManager.ts b/apps/mobile/src/hooks/useNotificationManager.ts new file mode 100644 index 0000000000..9e99409098 --- /dev/null +++ b/apps/mobile/src/hooks/useNotificationManager.ts @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useRef } from 'react' +import { AppState, Platform } from 'react-native' +import NotificationsService from '@/src/services/notifications/NotificationService' +import useRegisterForNotifications from '@/src/hooks/useRegisterForNotifications' +import Logger from '@/src/utils/logger' +import { useAppDispatch, useAppSelector } from '../store/hooks' +import { + selectAppNotificationStatus, + selectPromptAttempts, + toggleDeviceNotifications, + updatePromptAttempts, +} from '../store/notificationsSlice' +import { selectActiveSafe } from '../store/activeSafeSlice' +import { selectSafeSubscriptionStatus } from '../store/safeSubscriptionsSlice' + +export const useNotificationManager = () => { + const dispatch = useAppDispatch() + const promptAttempts = useAppSelector(selectPromptAttempts) + const isAppNotificationEnabled = useAppSelector(selectAppNotificationStatus) + const activeSafe = useAppSelector(selectActiveSafe) + const isSubscribed = useAppSelector((state) => + activeSafe ? selectSafeSubscriptionStatus(state, activeSafe.address, activeSafe.chainId) : false, + ) + const isAndroid = Platform.OS === 'android' + const promptThreshold = isAndroid ? 3 : 2 + const { registerForNotifications, unregisterForNotifications, updatePermissionsForNotifications, isLoading } = + useRegisterForNotifications() + + const appState = useRef(AppState.currentState) + + // Using a ref instead of state to ensure the value persists across app background/foreground cycles + const pendingPermissionRequestRef = useRef(false) + + const requestAndRegister = useCallback( + async (updateNotificationSettings = true, openSettingsOnDenied = false) => { + const { permission } = await NotificationsService.getAllPermissions() + + if (permission === 'granted') { + const { loading, error } = await registerForNotifications(updateNotificationSettings) + + pendingPermissionRequestRef.current = false + + if (!loading && !error) { + dispatch(toggleDeviceNotifications(true)) + return true + } + } else if (openSettingsOnDenied) { + pendingPermissionRequestRef.current = true + await NotificationsService.getAllPermissions(true) + } + + return false + }, + [dispatch, registerForNotifications], + ) + + const enableNotification = useCallback(async () => { + try { + Logger.info('enableNotification :: STARTED', { promptAttempts }) + // Check if device notifications are enabled + const deviceNotificationStatus = await NotificationsService.isDeviceNotificationEnabled() + + if (deviceNotificationStatus) { + const { loading, error } = await registerForNotifications() + + if (!loading && !error) { + dispatch(toggleDeviceNotifications(true)) + return true + } + return false + } else if (promptAttempts < promptThreshold) { + dispatch(updatePromptAttempts(promptAttempts + 1)) + return await requestAndRegister() + } else { + pendingPermissionRequestRef.current = true + await NotificationsService.getAllPermissions(true) + } + } catch (error) { + pendingPermissionRequestRef.current = false + Logger.error('Error enabling push notifications', error) + return false + } + }, [dispatch, registerForNotifications, promptAttempts, requestAndRegister, promptThreshold]) + + const disableNotification = useCallback(async () => { + try { + const { loading, error } = await unregisterForNotifications() + if (!loading && !error) { + return true + } + return false + } catch (error) { + Logger.error('Error disabling push notifications', error) + return false + } + }, [unregisterForNotifications]) + + const toggleNotificationState = useCallback(async () => { + if (!activeSafe) { + return + } + try { + const deviceNotificationStatus = await NotificationsService.isDeviceNotificationEnabled() + + if (!isSubscribed) { + if (!deviceNotificationStatus) { + const success = await requestAndRegister(false, true) + if (success) { + return true + } + // Don't clear the flag here if not granted immediately + } else { + await registerForNotifications(false) + } + } else { + await unregisterForNotifications(false) + } + } catch (error) { + pendingPermissionRequestRef.current = false + Logger.error('Error toggling notifications', error) + } + }, [isSubscribed, registerForNotifications, unregisterForNotifications, dispatch, activeSafe, requestAndRegister]) + + const updateNotificationPermissions = useCallback(async () => { + try { + const { loading, error } = await updatePermissionsForNotifications() + + if (!loading && !error) { + return true + } + } catch (error) { + Logger.error('Error updating push notifications permissions', error) + return false + } + }, [updatePermissionsForNotifications]) + + useEffect(() => { + const subscription = AppState.addEventListener('change', async (nextAppState) => { + if (appState.current.match(/inactive|background/) && nextAppState === 'active') { + // App has come to the foreground + const deviceNotificationStatus = await NotificationsService.isDeviceNotificationEnabled() + + // CASE 1: App notifications enabled but device notifications disabled + // Action: Disable app notifications to keep in sync + if (!deviceNotificationStatus && isAppNotificationEnabled) { + await disableNotification() + } + + // CASE 2: Device notifications enabled but app notifications disabled + // Action: Only enable app notifications if we were waiting for the user to return from settings + else if (deviceNotificationStatus && !isAppNotificationEnabled && pendingPermissionRequestRef.current) { + await registerForNotifications() + // Clear the pending flag after handling + pendingPermissionRequestRef.current = false + } + } + + appState.current = nextAppState + }) + + return () => { + subscription.remove() + } + }, [isAppNotificationEnabled, registerForNotifications, disableNotification]) + + return { + isAppNotificationEnabled, + enableNotification, + disableNotification, + toggleNotificationState, + updateNotificationPermissions, + isLoading, + } +} diff --git a/apps/mobile/src/hooks/useNotificationPayload.test.ts b/apps/mobile/src/hooks/useNotificationPayload.test.ts new file mode 100644 index 0000000000..62c085ec2d --- /dev/null +++ b/apps/mobile/src/hooks/useNotificationPayload.test.ts @@ -0,0 +1,82 @@ +import { act, renderHook } from '@/src/tests/test-utils' +import { useNotificationPayload } from './useNotificationPayload' +import { useSiwe } from '@/src/hooks/useSiwe' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' +import { Wallet } from 'ethers' +import { RootState } from '@/src/store' + +// Keep these mocks as they're not part of the store +jest.mock('@/src/hooks/useSiwe') +jest.mock('@/src/store/hooks/activeSafe') +jest.mock('@/src/utils/logger') + +describe('useNotificationPayload', () => { + const mockCreateSiweMessage = jest.fn() + const mockUseSiwe = useSiwe as jest.Mock + const mockUseDefinedActiveSafe = useDefinedActiveSafe as jest.Mock + + // Create a proper initial store state for testing + const initialStoreState: Partial<RootState> = { + notifications: { + isAppNotificationsEnabled: true, + isDeviceNotificationsEnabled: false, + fcmToken: null, + remoteMessages: [], + promptAttempts: 0, + lastTimePromptAttempted: null, + }, + settings: { + themePreference: 'light', + onboardingVersionSeen: '1.0.0', + currency: 'usd', + env: { + rpc: {}, + tenderly: { + url: '', + accessToken: '', + }, + }, + }, + safes: {}, + } + + beforeEach(() => { + mockUseSiwe.mockReturnValue({ createSiweMessage: mockCreateSiweMessage }) + mockUseDefinedActiveSafe.mockReturnValue({ address: 'mockAddress', chainId: '1' }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('throws an error if signer is missing', async () => { + const { result } = renderHook(() => useNotificationPayload(), initialStoreState) + + await act(async () => { + await expect( + result.current.getNotificationRegisterPayload({ + signer: null as unknown as Wallet, + chainId: '1', + }), + ).rejects.toThrow('registerForNotifications: Signer account not found') + }) + }) + + it('returns the correct payload', async () => { + const mockSigner = Wallet.createRandom() + mockCreateSiweMessage.mockReturnValue('mockSiweMessage') + + const { result } = renderHook(() => useNotificationPayload(), initialStoreState) + let payload + await act(async () => { + payload = await result.current.getNotificationRegisterPayload({ + signer: mockSigner, + chainId: '1', + }) + }) + expect(payload).toEqual({ + siweMessage: 'mockSiweMessage', + }) + expect(mockCreateSiweMessage).toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/src/hooks/useNotificationPayload.ts b/apps/mobile/src/hooks/useNotificationPayload.ts new file mode 100644 index 0000000000..5727a410df --- /dev/null +++ b/apps/mobile/src/hooks/useNotificationPayload.ts @@ -0,0 +1,42 @@ +import { ERROR_MSG } from '@/src/store/constants' +import { useLazyAuthGetNonceV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/auth' +import { useCallback } from 'react' +import { useSiwe } from '@/src/hooks/useSiwe' + +import Logger from '@/src/utils/logger' +import { HDNodeWallet, Wallet } from 'ethers' + +export function useNotificationPayload() { + const [getNonce] = useLazyAuthGetNonceV1Query() + const { createSiweMessage } = useSiwe() + + const getNotificationRegisterPayload = useCallback( + async ({ signer, chainId }: { signer: Wallet | HDNodeWallet; chainId: string }) => { + const { data: nonceData } = await getNonce() + if (!nonceData) { + Logger.error('registerForNotifications: Failed to get nonce') + throw new Error(ERROR_MSG) + } + + if (!signer) { + throw new Error('registerForNotifications: Signer account not found') + } + + const siweMessage = createSiweMessage({ + address: signer.address, + chainId: Number(chainId), + nonce: nonceData.nonce, + statement: 'Safe Wallet wants you to sign in with your Ethereum account', + }) + + return { + siweMessage, + } + }, + [getNonce], + ) + + return { + getNotificationRegisterPayload, + } +} diff --git a/apps/mobile/src/hooks/useNotifications.ts b/apps/mobile/src/hooks/useNotifications.ts deleted file mode 100644 index 0ea029e9b8..0000000000 --- a/apps/mobile/src/hooks/useNotifications.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useCallback } from 'react' -import FCMService from '@/src/services/notifications/FCMService' -import { useAppSelector, useAppDispatch } from '@/src/store/hooks' -import { - selectAppNotificationStatus, - selectFCMToken, - selectPromptAttempts, - selectRemoteMessages, - updatePromptAttempts, -} from '@/src/store/notificationsSlice' -import NotificationsService from '@/src/services/notifications/NotificationService' -import { FirebaseMessagingTypes } from '@react-native-firebase/messaging' -import Logger from '@/src/utils/logger' - -interface NotificationsProps { - isAppNotificationEnabled: boolean - fcmToken: string | null - remoteMessages: FirebaseMessagingTypes.RemoteMessage[] - enableNotifications: () => void - promptAttempts: number -} - -const useNotifications = (): NotificationsProps => { - const dispatch = useAppDispatch() - const isAppNotificationEnabled = useAppSelector(selectAppNotificationStatus) - const fcmToken = useAppSelector(selectFCMToken) - const remoteMessages = useAppSelector(selectRemoteMessages) - const promptAttempts = useAppSelector(selectPromptAttempts) - - const enableNotifications = useCallback(() => { - const checkNotifications = async () => { - const isDeviceNotificationEnabled = await NotificationsService.isDeviceNotificationEnabled() - if (!isDeviceNotificationEnabled) { - dispatch(updatePromptAttempts(1)) - - const { permission } = await NotificationsService.getAllPermissions() - - if (permission !== 'authorized') { - return - } - } - - try { - // Firebase Cloud Messaging - await FCMService.registerAppWithFCM() - await FCMService.saveFCMToken() - FCMService.listenForMessagesBackground() - } catch (error) { - Logger.error('FCM Registration or Token Save failed', error) - return - } - - return () => { - FCMService.listenForMessagesForeground()() - } - } - - checkNotifications() - }, [isAppNotificationEnabled]) - - return { enableNotifications, promptAttempts, isAppNotificationEnabled, fcmToken, remoteMessages } -} - -export default useNotifications diff --git a/apps/mobile/src/hooks/usePendingTxs/index.ts b/apps/mobile/src/hooks/usePendingTxs/index.ts index 16ac8c0d78..06119aeed4 100644 --- a/apps/mobile/src/hooks/usePendingTxs/index.ts +++ b/apps/mobile/src/hooks/usePendingTxs/index.ts @@ -1,6 +1,5 @@ import { useGetPendingTxsQuery } from '@safe-global/store/gateway' import { useMemo, useState } from 'react' -import { useSelector } from 'react-redux' import { ConflictHeaderQueuedItem, LabelQueuedItem, @@ -8,12 +7,12 @@ import { TransactionQueuedItem, } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' import { groupPendingTxs } from '@/src/features/PendingTx/utils' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { safelyDecodeURIComponent } from 'expo-router/build/fork/getStateFromPath-forks' import { useInfiniteScroll } from '../useInfiniteScroll' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' const usePendingTxs = () => { - const activeSafe = useSelector(selectActiveSafe) + const activeSafe = useDefinedActiveSafe() const [pageUrl, setPageUrl] = useState<string>() const { data, isLoading, isFetching, refetch, isUninitialized } = useGetPendingTxsQuery( @@ -24,8 +23,10 @@ const usePendingTxs = () => { }, { skip: !activeSafe.chainId, + pollingInterval: 10000, }, ) + const { list, onEndReached: fetchMoreTx } = useInfiniteScroll< QueuedItemPage, ConflictHeaderQueuedItem | LabelQueuedItem | TransactionQueuedItem @@ -35,7 +36,7 @@ const usePendingTxs = () => { data, }) - const pendingTxs = useMemo(() => groupPendingTxs(list || []), [list]) + const pendingTxs = useMemo(() => groupPendingTxs(list?.results || []), [list]) return { hasMore: Boolean(data?.next), diff --git a/apps/mobile/src/hooks/usePreventLeaveScreen/index.ts b/apps/mobile/src/hooks/usePreventLeaveScreen/index.ts new file mode 100644 index 0000000000..85eefabf22 --- /dev/null +++ b/apps/mobile/src/hooks/usePreventLeaveScreen/index.ts @@ -0,0 +1,21 @@ +import { usePreventRemove } from '@react-navigation/native' +import { Alert } from 'react-native' +import { useNavigation } from 'expo-router' + +export const usePreventLeaveScreen = (preventRemove: boolean) => { + const navigation = useNavigation() + usePreventRemove(preventRemove, ({ data }) => { + Alert.alert('Discard changes?', 'You have unsaved changes. Discard them and leave the screen?', [ + { + text: "Don't leave", + style: 'cancel', + onPress: () => null, + }, + { + text: 'Discard', + style: 'destructive', + onPress: () => navigation.dispatch(data.action), + }, + ]) + }) +} diff --git a/apps/mobile/src/hooks/useRegisterForNotifications.ts b/apps/mobile/src/hooks/useRegisterForNotifications.ts new file mode 100644 index 0000000000..babacdb520 --- /dev/null +++ b/apps/mobile/src/hooks/useRegisterForNotifications.ts @@ -0,0 +1,143 @@ +import { useCallback, useState } from 'react' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { + toggleAppNotifications, + updateLastTimePromptAttempted, + updatePromptAttempts, +} from '@/src/store/notificationsSlice' +import Logger from '@/src/utils/logger' +import { ERROR_MSG } from '../store/constants' +import { selectActiveSafe } from '../store/activeSafeSlice' +import '@safe-global/store/gateway/AUTO_GENERATED/notifications' + +import { registerSafe, unregisterSafe } from '@/src/services/notifications/registration' +import { store } from '@/src/store' +import { selectAllChainsIds } from '../store/chains' +import FCMService from '@/src/services/notifications/FCMService' +import NotificationService from '@/src/services/notifications/NotificationService' +import { notificationChannels, withTimeout } from '@/src/utils/notifications' + +export type RegisterForNotificationsProps = { + loading: boolean + error: string | null +} + +interface NotificationsProps { + registerForNotifications: (updateNotificationSettings?: boolean) => Promise<RegisterForNotificationsProps> + unregisterForNotifications: (updateNotificationSettings?: boolean) => Promise<RegisterForNotificationsProps> + updatePermissionsForNotifications: () => Promise<RegisterForNotificationsProps> + isLoading: boolean + error: string | null +} + +const useRegisterForNotifications = (): NotificationsProps => { + const [loading, setLoading] = useState(false) + const [error, setError] = useState<string | null>(null) + + const dispatch = useAppDispatch() + const activeSafe = useAppSelector(selectActiveSafe) + const allChainIds = useAppSelector(selectAllChainsIds) + + const registerForNotifications = useCallback( + async (updateNotificationSettings = true) => { + try { + setLoading(true) + setError(null) + + // For the initial opt-in, we perform global FCM setup and let the middleware handle all safe registrations + if (updateNotificationSettings) { + // Initialize FCM and create notification channels (global setup) + await FCMService.initNotification() + await withTimeout(NotificationService.createChannel(notificationChannels[0]), 5000) + + // Dispatch the global toggle - this will trigger the middleware to register all safes + dispatch(toggleAppNotifications(true)) + dispatch(updatePromptAttempts(0)) + dispatch(updateLastTimePromptAttempted(0)) + } else { + // For individual safe registration (used by toggle notification state), register only the active safe + if (!activeSafe) { + setLoading(false) + setError(ERROR_MSG) + return { loading, error } + } + + await registerSafe(store, activeSafe.address, allChainIds) + } + + setLoading(false) + setError(null) + } catch (err) { + Logger.error('FCM Registration failed', err) + setLoading(false) + setError((err as Error).toString()) + } + return { loading, error } + }, + [activeSafe, dispatch, allChainIds], + ) + + const unregisterForNotifications = useCallback( + async (updateNotificationSettings = true) => { + try { + setLoading(true) + setError(null) + + if (!activeSafe) { + setLoading(false) + setError(ERROR_MSG) + return { loading, error } + } + + await unregisterSafe(store, activeSafe.address, allChainIds) + + if (updateNotificationSettings) { + dispatch(toggleAppNotifications(false)) + dispatch(updatePromptAttempts(0)) + dispatch(updateLastTimePromptAttempted(0)) + } + setLoading(false) + setError(null) + } catch (err) { + Logger.error('FCM Unregistration failed', err) + setLoading(false) + setError((err as Error).toString()) + } + return { loading, error } + }, + [activeSafe, dispatch], + ) + + const updatePermissionsForNotifications = useCallback(async () => { + try { + setLoading(true) + setError(null) + + if (!activeSafe) { + setLoading(false) + setError(ERROR_MSG) + return { loading, error } + } + + await registerSafe(store, activeSafe.address, [activeSafe.chainId]) + + setLoading(false) + setError(null) + } catch (err) { + Logger.error('Notification permission update failed', err) + setLoading(false) + setError((err as Error).toString()) + } + return { loading, error } + }, [activeSafe]) + + return { + registerForNotifications, + unregisterForNotifications, + updatePermissionsForNotifications, + isLoading: loading, + error, + } +} + +export default useRegisterForNotifications diff --git a/apps/mobile/src/hooks/useSafeInfo.ts b/apps/mobile/src/hooks/useSafeInfo.ts new file mode 100644 index 0000000000..b710d5e8d1 --- /dev/null +++ b/apps/mobile/src/hooks/useSafeInfo.ts @@ -0,0 +1,33 @@ +import { useGetSafeQuery } from '@safe-global/store/gateway' +import { useAppSelector } from '@/src/store/hooks' +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { defaultSafeInfo } from '@safe-global/store/slices/SafeInfo/utils' + +export const useSafeInfo = () => { + const activeSafe = useAppSelector(selectActiveSafe) + + const { + data = defaultSafeInfo, + error, + isLoading, + isSuccess, + } = useGetSafeQuery( + { + chainId: activeSafe?.chainId ?? '', + safeAddress: activeSafe?.address ?? '', + }, + { + skip: !activeSafe, + }, + ) + + return { + safe: data, + safeAddress: activeSafe?.address, + safeLoaded: isSuccess, + safeError: error, + safeLoading: isLoading, + } +} + +export default useSafeInfo diff --git a/apps/mobile/src/hooks/useScreenProtection.ts b/apps/mobile/src/hooks/useScreenProtection.ts new file mode 100644 index 0000000000..419f7becf8 --- /dev/null +++ b/apps/mobile/src/hooks/useScreenProtection.ts @@ -0,0 +1,33 @@ +import { useFocusEffect } from 'expo-router' +import { useCallback } from 'react' +import { CaptureProtection } from 'react-native-capture-protection' + +export interface ScreenProtectionOptions { + screenshot?: boolean + record?: boolean + appSwitcher?: boolean +} + +/** + * Custom hook to enable screen protection when the screen is focused + * and disable it when the screen is unfocused. + * + * @param options - Configuration options for what to protect against + */ +export const useScreenProtection = ( + options: ScreenProtectionOptions = { + screenshot: true, + record: true, + appSwitcher: true, + }, +) => { + useFocusEffect( + useCallback(() => { + CaptureProtection.prevent(options) + + return () => { + CaptureProtection.allow() + } + }, [options]), + ) +} diff --git a/apps/mobile/src/hooks/useScreenTracking.ts b/apps/mobile/src/hooks/useScreenTracking.ts new file mode 100644 index 0000000000..84279478aa --- /dev/null +++ b/apps/mobile/src/hooks/useScreenTracking.ts @@ -0,0 +1,15 @@ +import { useEffect } from 'react' +import { usePathname, useGlobalSearchParams } from 'expo-router' +import { trackScreenView } from '@/src/services/analytics' + +export const useScreenTracking = () => { + const pathname = usePathname() + const params = useGlobalSearchParams() + + useEffect(() => { + const logScreenView = async () => { + await trackScreenView(pathname, pathname) + } + logScreenView() + }, [pathname, params]) +} diff --git a/apps/mobile/src/hooks/useSign/index.ts b/apps/mobile/src/hooks/useSign/index.ts index 26d610020f..29e17d7b23 100644 --- a/apps/mobile/src/hooks/useSign/index.ts +++ b/apps/mobile/src/hooks/useSign/index.ts @@ -1 +1 @@ -export { useSign } from './useSign' +export { storePrivateKey, getPrivateKey, createMnemonicAccount } from './useSign' diff --git a/apps/mobile/src/hooks/useSign/useSign.test.ts b/apps/mobile/src/hooks/useSign/useSign.test.ts index 342f5bea81..be4ed3dce7 100644 --- a/apps/mobile/src/hooks/useSign/useSign.test.ts +++ b/apps/mobile/src/hooks/useSign/useSign.test.ts @@ -1,65 +1,167 @@ import { act, renderHook } from '@/src/tests/test-utils' -import { asymmetricKey, keychainGenericPassword, useSign } from './useSign' +import { useSign, storePrivateKey, getPrivateKey, createMnemonicAccount } from './useSign' import { HDNodeWallet, Wallet } from 'ethers' -import * as Keychain from 'react-native-keychain' -import DeviceCrypto from 'react-native-device-crypto' +import { keyStorageService, walletService } from '@/src/services/key-storage' + +jest.mock('@/src/services/key-storage', () => ({ + keyStorageService: { + storePrivateKey: jest.fn(), + getPrivateKey: jest.fn(), + }, + walletService: { + createMnemonicAccount: jest.fn(), + }, + PrivateKeyStorageOptions: {}, +})) describe('useSign', () => { - it('should store the private key given a private key', async () => { - const { result } = renderHook(() => useSign()) - const { privateKey } = Wallet.createRandom() - const spy = jest.spyOn(Keychain, 'setGenericPassword') - const asymmetricKeySpy = jest.spyOn(DeviceCrypto, 'getOrCreateAsymmetricKey') - const encryptSpy = jest.spyOn(DeviceCrypto, 'encrypt') - - await act(async () => { - await result.current.storePrivateKey(privateKey) + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Direct function exports', () => { + it('call the keyStorageService to store the private key', async () => { + const { privateKey } = Wallet.createRandom() + const userId = 'userId' + const options = { requireAuthentication: true } + + await storePrivateKey(userId, privateKey, options) + + expect(keyStorageService.storePrivateKey).toHaveBeenCalledWith(userId, privateKey, options) }) - expect(asymmetricKeySpy).toHaveBeenCalledWith(asymmetricKey, { accessLevel: 2, invalidateOnNewBiometry: true }) - expect(encryptSpy).toHaveBeenCalledWith(asymmetricKey, privateKey, { - biometryTitle: 'Authenticate', - biometrySubTitle: 'Saving key', - biometryDescription: 'Please authenticate yourself', + it('call the keyStorageService to get the private key', async () => { + const userId = 'userId' + const options = { requireAuthentication: true } + const mockPrivateKey = '0x123456' + + jest.mocked(keyStorageService.getPrivateKey).mockResolvedValueOnce(mockPrivateKey) + + const result = await getPrivateKey(userId, options) + + expect(keyStorageService.getPrivateKey).toHaveBeenCalledWith(userId, options) + expect(result).toBe(mockPrivateKey) + }) + + it('call the walletService to create a mnemonic account', async () => { + const { mnemonic, privateKey } = Wallet.createRandom() + const mockWallet = { privateKey } as HDNodeWallet + + jest.mocked(walletService.createMnemonicAccount).mockResolvedValueOnce(mockWallet) + + const wallet = await createMnemonicAccount(mnemonic?.phrase as string) + + expect(walletService.createMnemonicAccount).toHaveBeenCalledWith(mnemonic?.phrase) + expect(wallet).toBe(mockWallet) }) - expect(spy).toHaveBeenCalledWith( - keychainGenericPassword, - JSON.stringify({ encryptyedPassword: 'encryptedText', iv: `${privateKey}000` }), - ) }) - it('should decrypt and get the stored private key after it is encrypted', async () => { - const { result } = renderHook(() => useSign()) - const { privateKey } = Wallet.createRandom() - const spy = jest.spyOn(Keychain, 'setGenericPassword') - let returnedKey = null + describe('useSign hook', () => { + it('returns loading state and handle successful key storage', async () => { + const { privateKey } = Wallet.createRandom() + const userId = 'userId' + const options = { requireAuthentication: true } + + jest.mocked(keyStorageService.storePrivateKey).mockImplementation(async () => { + return Promise.resolve() + }) + + const { result } = renderHook(() => useSign()) + + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBe(null) + + let success + await act(async () => { + success = await result.current.storePrivateKey(userId, privateKey, options) + }) - // To generate the iv and wait till the hook re-renders - await act(async () => { - await result.current.storePrivateKey(privateKey) + expect(keyStorageService.storePrivateKey).toHaveBeenCalledWith(userId, privateKey, options) + expect(success).toBe(true) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBe(null) }) - await act(async () => { - returnedKey = await result.current.getPrivateKey() + it('handles errors when storing private key', async () => { + const { privateKey } = Wallet.createRandom() + const userId = 'userId' + const options = { requireAuthentication: true } + const errorMessage = 'Storage error' + + jest.mocked(keyStorageService.storePrivateKey).mockImplementation(async () => { + throw new Error(errorMessage) + }) + + const { result } = renderHook(() => useSign()) + + let success + await act(async () => { + success = await result.current.storePrivateKey(userId, privateKey, options) + }) + + expect(keyStorageService.storePrivateKey).toHaveBeenCalledWith(userId, privateKey, options) + expect(success).toBe(false) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBe(errorMessage) }) - expect(spy).toHaveBeenCalledWith( - 'safeuser', - JSON.stringify({ encryptyedPassword: 'encryptedText', iv: `${privateKey}000` }), - ) - expect(returnedKey).toBe(privateKey) + it('handles successful private key retrieval', async () => { + const userId = 'userId' + const options = { requireAuthentication: true } + const mockPrivateKey = '0x123456' + + jest.mocked(keyStorageService.getPrivateKey).mockResolvedValueOnce(mockPrivateKey) + + const { result } = renderHook(() => useSign()) + + let returnedKey + await act(async () => { + returnedKey = await result.current.getPrivateKey(userId, options) + }) + + expect(keyStorageService.getPrivateKey).toHaveBeenCalledWith(userId, options) + expect(returnedKey).toBe(mockPrivateKey) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBe(null) + }) + + it('handles successful mnemonic account creation', async () => { + const { mnemonic, privateKey } = Wallet.createRandom() + const mockWallet = { privateKey } as HDNodeWallet + + jest.mocked(walletService.createMnemonicAccount).mockResolvedValueOnce(mockWallet) + + const { result } = renderHook(() => useSign()) + + let wallet + await act(async () => { + wallet = await result.current.createMnemonicAccount(mnemonic?.phrase as string) + }) + + expect(walletService.createMnemonicAccount).toHaveBeenCalledWith(mnemonic?.phrase) + expect(wallet).toBe(mockWallet) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBe(null) + }) }) - it('should import a wallet when given a mnemonic phrase', async () => { - const { result } = renderHook(() => useSign()) - const { mnemonic, privateKey } = Wallet.createRandom() + describe('KeyStorageService integration', () => { + it('stores and retrieves a private key', async () => { + const mockStoreImpl = jest.spyOn(keyStorageService, 'storePrivateKey') + const mockGetImpl = jest.spyOn(keyStorageService, 'getPrivateKey') + + mockStoreImpl.mockResolvedValue(undefined) + mockGetImpl.mockResolvedValue('decryptedKey') + + const userId = 'testUser' + const privateKey = 'privateKey123' - // To generate the iv and wait till the hook re-renders - await act(async () => { - const wallet = await result.current.createMnemonicAccount(mnemonic?.phrase as string) + await keyStorageService.storePrivateKey(userId, privateKey, { requireAuthentication: true }) + const retrievedKey = await keyStorageService.getPrivateKey(userId, { requireAuthentication: true }) - expect(wallet).toBeInstanceOf(HDNodeWallet) - expect(wallet?.privateKey).toBe(privateKey) + expect(mockStoreImpl).toHaveBeenCalledWith(userId, privateKey, { requireAuthentication: true }) + expect(mockGetImpl).toHaveBeenCalledWith(userId, { requireAuthentication: true }) + expect(retrievedKey).toBe('decryptedKey') }) }) }) diff --git a/apps/mobile/src/hooks/useSign/useSign.ts b/apps/mobile/src/hooks/useSign/useSign.ts index a6030bf6d9..78ec6b5340 100644 --- a/apps/mobile/src/hooks/useSign/useSign.ts +++ b/apps/mobile/src/hooks/useSign/useSign.ts @@ -1,76 +1,81 @@ -import DeviceCrypto from 'react-native-device-crypto' -import * as Keychain from 'react-native-keychain' -import DeviceInfo from 'react-native-device-info' -import { Wallet } from 'ethers' +import { useState, useCallback } from 'react' +import { HDNodeWallet } from 'ethers' +import { keyStorageService, walletService, PrivateKeyStorageOptions } from '@/src/services/key-storage' +import Logger from '@/src/utils/logger' -export const asymmetricKey = 'safe' -export const keychainGenericPassword = 'safeuser' +export const useSign = () => { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState<string | null>(null) -export function useSign() { - // TODO: move it to a global context or reduce - const storePrivateKey = async (privateKey: string) => { - try { - const isEmulator = await DeviceInfo.isEmulator() - - await DeviceCrypto.getOrCreateAsymmetricKey(asymmetricKey, { - accessLevel: isEmulator ? 1 : 2, - invalidateOnNewBiometry: true, - }) - - const encryptyedPrivateKey = await DeviceCrypto.encrypt(asymmetricKey, privateKey, { - biometryTitle: 'Authenticate', - biometrySubTitle: 'Saving key', - biometryDescription: 'Please authenticate yourself', - }) - - await Keychain.setGenericPassword( - keychainGenericPassword, - JSON.stringify({ - encryptyedPassword: encryptyedPrivateKey.encryptedText, - iv: encryptyedPrivateKey.iv, - }), - ) - } catch (err) { - console.log(err) - } - } - - const getPrivateKey = async () => { - try { - const user = await Keychain.getGenericPassword() - - if (!user) { - throw 'user password not found' + const storePrivateKey = useCallback( + async ( + userId: string, + privateKey: string, + options: PrivateKeyStorageOptions = { requireAuthentication: true }, + ): Promise<boolean> => { + setIsLoading(true) + setError(null) + try { + await keyStorageService.storePrivateKey(userId, privateKey, options) + return true + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to store private key' + setError(errorMessage) + Logger.error('storePrivateKey', { userId, error: errorMessage }) + return false + } finally { + setIsLoading(false) } + }, + [], + ) - const { encryptyedPassword, iv } = JSON.parse(user.password) - const decryptedKey = await DeviceCrypto.decrypt(asymmetricKey, encryptyedPassword, iv, { - biometryTitle: 'Authenticate', - biometrySubTitle: 'Signing', - biometryDescription: 'Authenticate yourself to sign the text', - }) - - return decryptedKey - } catch (err) { - console.log(err) - } - } - - const createMnemonicAccount = async (mnemonic: string) => { - try { - if (!mnemonic) { - return + const getPrivateKey = useCallback( + async ( + userId: string, + options: PrivateKeyStorageOptions = { requireAuthentication: true }, + ): Promise<string | undefined> => { + setIsLoading(true) + setError(null) + try { + return await keyStorageService.getPrivateKey(userId, options) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to get private key' + setError(errorMessage) + Logger.error('getPrivateKey', { userId, error: errorMessage }) + return undefined + } finally { + setIsLoading(false) } + }, + [], + ) - return Wallet.fromPhrase(mnemonic) + const createMnemonicAccount = useCallback(async (mnemonic: string): Promise<HDNodeWallet | undefined> => { + setIsLoading(true) + setError(null) + try { + return await walletService.createMnemonicAccount(mnemonic) } catch (err) { - console.log(err) + const errorMessage = err instanceof Error ? err.message : 'Failed to create mnemonic account' + setError(errorMessage) + Logger.error('createMnemonicAccount', { error: errorMessage }) + return undefined + } finally { + setIsLoading(false) } - } + }, []) return { storePrivateKey, getPrivateKey, createMnemonicAccount, + isLoading, + error, } } + +// For backward compatibility, also export the functions directly (for now) +export const storePrivateKey = keyStorageService.storePrivateKey.bind(keyStorageService) +export const getPrivateKey = keyStorageService.getPrivateKey.bind(keyStorageService) +export const createMnemonicAccount = walletService.createMnemonicAccount.bind(walletService) diff --git a/apps/mobile/src/hooks/useSiwe.ts b/apps/mobile/src/hooks/useSiwe.ts new file mode 100644 index 0000000000..e085030e00 --- /dev/null +++ b/apps/mobile/src/hooks/useSiwe.ts @@ -0,0 +1,36 @@ +import { useCallback } from 'react' +import { SiweMessage } from 'siwe' +import { HDNodeWallet, Wallet } from 'ethers' + +interface SiweMessageProps { + address: string + chainId: number + nonce: string + statement: string +} + +export function useSiwe() { + const createSiweMessage = useCallback(({ address, chainId, nonce, statement }: SiweMessageProps) => { + const message = new SiweMessage({ + address, + chainId, + domain: 'global.safe.mobileapp', + statement, + nonce, + uri: 'https://safe.global', + version: '1', + issuedAt: new Date().toISOString(), + }) + return message.prepareMessage() + }, []) + + const signMessage = useCallback(async ({ signer, message }: { signer: Wallet | HDNodeWallet; message: string }) => { + const signature = await signer.signMessage(message) + return signature + }, []) + + return { + createSiweMessage, + signMessage, + } +} diff --git a/apps/mobile/src/hooks/useTokenDetails/index.ts b/apps/mobile/src/hooks/useTokenDetails/index.ts new file mode 100644 index 0000000000..90dea20dfc --- /dev/null +++ b/apps/mobile/src/hooks/useTokenDetails/index.ts @@ -0,0 +1 @@ +export { useTokenDetails } from './useTokenDetails' diff --git a/apps/mobile/src/hooks/useTokenDetails/useTokenDetails.ts b/apps/mobile/src/hooks/useTokenDetails/useTokenDetails.ts new file mode 100644 index 0000000000..459f17b144 --- /dev/null +++ b/apps/mobile/src/hooks/useTokenDetails/useTokenDetails.ts @@ -0,0 +1,55 @@ +import { selectActiveChainCurrency } from '@/src/store/chains' +import { useAppSelector } from '@/src/store/hooks' +import { isERC20Transfer, isERC721Transfer, isNativeTokenTransfer } from '@/src/utils/transaction-guards' +import { TransferTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ellipsis } from '@safe-global/utils/utils/formatters' + +interface tokenDetails { + value: string + decimals?: number + tokenSymbol?: string + name: string + logoUri?: string +} + +export const useTokenDetails = (txInfo: TransferTransactionInfo): tokenDetails => { + const transfer = txInfo.transferInfo + const unnamedToken = 'Unnamed token' + const nativeCurrency = useAppSelector(selectActiveChainCurrency) + + if (isNativeTokenTransfer(transfer) && nativeCurrency) { + return { + value: transfer.value || '0', + // take it from the native currency slice + decimals: nativeCurrency.decimals, + tokenSymbol: nativeCurrency.symbol, + name: nativeCurrency.name, + logoUri: nativeCurrency.logoUri, + } + } + + if (isERC20Transfer(transfer)) { + return { + value: transfer.value || '0', + decimals: transfer.decimals || undefined, + logoUri: transfer.logoUri || undefined, + tokenSymbol: ellipsis((transfer.tokenSymbol || 'Unknown Token').trim(), 6), + name: transfer.tokenName || unnamedToken, + } + } + + if (isERC721Transfer(transfer)) { + return { + name: transfer.tokenName || unnamedToken, + tokenSymbol: ellipsis(`${transfer.tokenSymbol || 'Unknown NFT'} #${transfer.tokenId}`, 8), + value: '1', + decimals: 0, + logoUri: transfer?.logoUri || undefined, + } + } + + return { + name: unnamedToken, + value: '', + } +} diff --git a/apps/mobile/src/hooks/useTransactionType/index.tsx b/apps/mobile/src/hooks/useTransactionType/index.tsx index f7d39b775f..71459e2d30 100644 --- a/apps/mobile/src/hooks/useTransactionType/index.tsx +++ b/apps/mobile/src/hooks/useTransactionType/index.tsx @@ -29,7 +29,7 @@ const getTxTo = ({ txInfo }: Pick<Transaction, 'txInfo'>): AddressInfo | undefin } } -interface TxType { +export interface TxType { text: string icon?: string | React.ReactElement image: string | React.ReactElement @@ -96,11 +96,25 @@ export const getTransactionType = (tx: Transaction): TxType => { text: 'TWAP order', } } + case 'SwapAndBridge': { + return { + image: <SafeFontIcon name={'transaction-swap'} />, + icon: <SafeFontIcon name={'transaction-swap'} />, + text: 'Bridge transaction', + } + } + case 'Swap': { + return { + image: <SafeFontIcon name={'transaction-swap'} />, + icon: <SafeFontIcon name={'transaction-swap'} />, + text: 'LiFi swap', + } + } case TransactionInfoType.CUSTOM: { if (isMultiSendTxInfo(tx.txInfo) && !tx.safeAppInfo) { return { image: <SafeFontIcon name={'safe'} />, - icon: <SafeFontIcon name={'transaction-Batch'} />, + icon: <SafeFontIcon name={'transaction-batch'} />, text: 'Batch', } } diff --git a/apps/mobile/src/hooks/useValidLogoUri.ts b/apps/mobile/src/hooks/useValidLogoUri.ts new file mode 100644 index 0000000000..94a2537f4a --- /dev/null +++ b/apps/mobile/src/hooks/useValidLogoUri.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react' +import { Image } from 'react-native' + +const useValidLogoUri = (logoUri?: string | null) => { + const [validUri, setValidUri] = useState<string | null>(null) + + useEffect(() => { + if (!logoUri) { + return + } + + Image.getSize( + logoUri, + () => setValidUri(logoUri), + () => setValidUri(null), + ) + }, [logoUri]) + + return validUri +} + +export default useValidLogoUri diff --git a/apps/mobile/src/hooks/wallets/web3.ts b/apps/mobile/src/hooks/wallets/web3.ts new file mode 100644 index 0000000000..e7fae1ae38 --- /dev/null +++ b/apps/mobile/src/hooks/wallets/web3.ts @@ -0,0 +1,75 @@ +import type { Chain, RpcUri } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { RPC_AUTHENTICATION } from '@safe-global/store/gateway/types' +import { INFURA_TOKEN, SAFE_APPS_INFURA_TOKEN } from '@safe-global/utils/config/constants' +import { JsonRpcProvider, BrowserProvider, type Eip1193Provider } from 'ethers' +import ExternalStore from '@safe-global/utils/services/ExternalStore' + +/** + * Infura and other RPC providers limit the max amount included in a batch RPC call. + * Ethers uses 100 by default which is too high for i.e. Infura. + * + * Some networks like Scroll only support a batch size of 3. + */ +const BATCH_MAX_COUNT = 3 + +// RPC helpers +const formatRpcServiceUrl = ({ authentication, value }: RpcUri, token: string): string => { + const needsToken = authentication === RPC_AUTHENTICATION.API_KEY_PATH + + if (needsToken && !token) { + console.warn('Infura token not set in .env') + return '' + } + + return needsToken ? `${value}${token}` : value +} + +export const getRpcServiceUrl = (rpcUri: RpcUri): string => { + return formatRpcServiceUrl(rpcUri, INFURA_TOKEN) +} + +export const createWeb3ReadOnly = (chain: Chain, customRpc?: string): JsonRpcProvider | undefined => { + const url = customRpc || getRpcServiceUrl(chain.rpcUri) + if (!url) { + return + } + return new JsonRpcProvider(url, Number(chain.chainId), { + staticNetwork: true, + batchMaxCount: BATCH_MAX_COUNT, + }) +} + +export const createWeb3 = (walletProvider: Eip1193Provider): BrowserProvider => { + return new BrowserProvider(walletProvider) +} + +export const createSafeAppsWeb3Provider = (chain: Chain, customRpc?: string): JsonRpcProvider | undefined => { + const url = customRpc || formatRpcServiceUrl(chain.rpcUri, SAFE_APPS_INFURA_TOKEN) + if (!url) { + return + } + return new JsonRpcProvider(url, undefined, { + staticNetwork: true, + batchMaxCount: BATCH_MAX_COUNT, + }) +} + +export const { setStore: setWeb3, useStore: useWeb3 } = new ExternalStore<BrowserProvider>() + +export const { + getStore: getWeb3ReadOnly, + setStore: setWeb3ReadOnly, + useStore: useWeb3ReadOnly, +} = new ExternalStore<JsonRpcProvider>() + +export const getUserNonce = async (userAddress: string): Promise<number> => { + const web3 = getWeb3ReadOnly() + if (!web3) { + return -1 + } + try { + return await web3.getTransactionCount(userAddress, 'pending') + } catch (error) { + return Promise.reject(error) + } +} diff --git a/apps/mobile/src/navigation/NavigationGuardHOC.tsx b/apps/mobile/src/navigation/NavigationGuardHOC.tsx new file mode 100644 index 0000000000..06ee0ec98a --- /dev/null +++ b/apps/mobile/src/navigation/NavigationGuardHOC.tsx @@ -0,0 +1,71 @@ +import { useRouter, useSegments } from 'expo-router' +import React, { useEffect, useState } from 'react' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { selectSettings } from '@/src/store/settingsSlice' +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { selectAppNotificationStatus, updatePromptAttempts, selectPromptAttempts } from '@/src/store/notificationsSlice' +import { ONBOARDING_VERSION } from '@/src/config/constants' +import { useBiometrics } from '../hooks/useBiometrics' +let navigated = false + +function useInitialNavigationScreen() { + const onboardingVersionSeen = useAppSelector((state) => selectSettings(state, 'onboardingVersionSeen')) + const isAppNotificationEnabled = useAppSelector(selectAppNotificationStatus) + const activeSafe = useAppSelector(selectActiveSafe) + const promptAttempts = useAppSelector(selectPromptAttempts) + const dispatch = useAppDispatch() + const router = useRouter() + const segments = useSegments() + + /* + * If the user has not enabled notifications and has not been prompted to enable them, + * show him the opt-in screen, but only if he is in a navigator that has (tabs) as the first screen + * */ + const [hasShownNotifications, setHasShownNotifications] = useState(false) + const shouldShowOptIn = !isAppNotificationEnabled && !promptAttempts && segments[0] === '(tabs)' + + useEffect(() => { + if (shouldShowOptIn && !hasShownNotifications) { + dispatch(updatePromptAttempts(1)) + setHasShownNotifications(true) + setTimeout(() => { + router.navigate('/notifications-opt-in') + }, 500) + } + }, [shouldShowOptIn, hasShownNotifications, dispatch]) + + React.useEffect(() => { + // We will navigate only on startup. Any other navigation should not happen here + if (navigated) { + return + } + + // We first check whether the user has seen the current version of the onboarding + if (onboardingVersionSeen !== ONBOARDING_VERSION) { + router.replace('/onboarding') + } else { + // If the user has seen the onboarding, we check if they have an active safe + // and redirect him to it + if (activeSafe) { + router.replace('/(tabs)') + } else { + // if the user doesn't have an active safe what he most probably did is to close + // the app on the onboarding screen and started it again. In this case, we show him + // again the onboarding, but also on top of it open the "get started" screen + router.replace('/onboarding') + // It makes it a bit nicer if we wait a bit before navigating to the get started screen + setTimeout(() => { + router.push('/get-started') + }, 500) + } + } + + navigated = true + }, [onboardingVersionSeen, activeSafe]) +} + +export function NavigationGuardHOC({ children }: { children: React.ReactNode }) { + useInitialNavigationScreen() + useBiometrics() + return children +} diff --git a/apps/mobile/src/navigation/hooks/utils.tsx b/apps/mobile/src/navigation/hooks/utils.tsx new file mode 100644 index 0000000000..4bdf9d709d --- /dev/null +++ b/apps/mobile/src/navigation/hooks/utils.tsx @@ -0,0 +1,36 @@ +import { HeaderBackButton } from '@react-navigation/elements' +import { type NativeStackHeaderLeftProps } from '@react-navigation/native-stack' +import { View } from 'tamagui' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' + +export const getDefaultScreenOptions = (goBack: () => void) => { + return { + headerBackButtonDisplayMode: 'minimal' as const, + headerShadowVisible: false, + headerLeft: (props: NativeStackHeaderLeftProps) => { + return ( + <HeaderBackButton + {...props} + style={{ marginLeft: -8 }} + testID={'go-back'} + onPress={goBack} + backImage={() => { + return ( + <View + backgroundColor={'$backgroundSkeleton'} + alignItems={'center'} + justifyContent={'center'} + borderRadius={16} + height={32} + width={32} + > + <SafeFontIcon name={'arrow-left'} size={16} color={'$color'} /> + </View> + ) + }} + displayMode={'minimal'} + /> + ) + }, + } +} diff --git a/apps/mobile/src/navigation/useScrollableHeader.tsx b/apps/mobile/src/navigation/useScrollableHeader.tsx index 9df2b05468..b5153afcda 100644 --- a/apps/mobile/src/navigation/useScrollableHeader.tsx +++ b/apps/mobile/src/navigation/useScrollableHeader.tsx @@ -1,12 +1,13 @@ // useScrollableHeader.ts import { useEffect } from 'react' import { NativeSyntheticEvent, NativeScrollEvent } from 'react-native' -import { useNavigation } from 'expo-router' +import { useNavigation } from '@react-navigation/native' import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated' interface UseScrollableHeaderProps { children: React.ReactNode scrollYThreshold?: number // Default threshold for opacity change + alwaysVisible?: boolean } /** @@ -19,15 +20,20 @@ interface UseScrollableHeaderProps { * @param children * @param scrollYThreshold */ -export const useScrollableHeader = ({ children, scrollYThreshold = 37 }: UseScrollableHeaderProps) => { +export const useScrollableHeader = ({ children, alwaysVisible, scrollYThreshold = 37 }: UseScrollableHeaderProps) => { const navigation = useNavigation() - const opacity = useSharedValue(0) + const opacity = useSharedValue(alwaysVisible ? 1 : 0) // Update navigation header title dynamically useEffect(() => { navigation.setOptions({ headerTitle: () => ( - <Animated.View style={[{ flexDirection: 'row', alignItems: 'center' }, animatedHeaderStyle]}> + <Animated.View + style={[ + { flex: 1, justifyContent: 'center', flexDirection: 'row', alignItems: 'center' }, + animatedHeaderStyle, + ]} + > {children} </Animated.View> ), @@ -41,7 +47,7 @@ export const useScrollableHeader = ({ children, scrollYThreshold = 37 }: UseScro // Scroll event handler for updating opacity const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => { const scrollY = event.nativeEvent.contentOffset.y - opacity.value = scrollY > scrollYThreshold ? 1 : 0 + opacity.value = scrollY > scrollYThreshold ? 1 : alwaysVisible ? 1 : 0 } return { diff --git a/apps/mobile/src/platform/__tests__/fetch.test.ts b/apps/mobile/src/platform/__tests__/fetch.test.ts new file mode 100644 index 0000000000..23c1276126 --- /dev/null +++ b/apps/mobile/src/platform/__tests__/fetch.test.ts @@ -0,0 +1,331 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Platform } from 'react-native' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import * as Application from 'expo-application' + +// Mock the required dependencies +jest.mock('react-native', () => ({ + Platform: { + OS: 'ios', + }, +})) + +jest.mock('expo-application', () => ({ + nativeApplicationVersion: '1.0.0', + nativeBuildVersion: '100', +})) + +describe('fetch global override', () => { + // Store the original fetch implementation + const originalFetch = global.fetch + + // Restore original fetch after tests + afterAll(() => { + global.fetch = originalFetch + }) + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks() + + // Reset the fetch override before each test by re-importing + jest.resetModules() + require('../fetch') + }) + + it('should add User-Agent and Origin headers for domain URL', async () => { + // Setup a mock implementation that captures the Request for inspection + let capturedRequest: unknown = null + + const mockFetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (typeof input === 'string') { + capturedRequest = new Request(input, init) + } else if (input instanceof URL) { + capturedRequest = new Request(input.toString(), init) + } else { + capturedRequest = input + } + return Promise.resolve(new Response()) + }) + + global.fetch = mockFetch + + // Re-import to override fetch again with our mock + jest.resetModules() + require('../fetch') + + // Perform the fetch + const url = 'https://example.com/api' + await global.fetch(url) + + // Check that fetch was called + expect(mockFetch).toHaveBeenCalled() + + // Verify headers + expect(capturedRequest).not.toBeNull() + if (capturedRequest) { + const req = capturedRequest as Request + expect(req.headers.get('User-Agent')).toBe('SafeMobile/iOS/1.0.0/100') + expect(req.headers.get('Origin')).toBe('https://app.safe.global') + } + }) + + it('should not add Origin header for localhost URL', async () => { + // Setup a mock implementation that captures the Request for inspection + let capturedRequest: unknown = null + + const mockFetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (typeof input === 'string') { + capturedRequest = new Request(input, init) + } else if (input instanceof URL) { + capturedRequest = new Request(input.toString(), init) + } else { + capturedRequest = input + } + return Promise.resolve(new Response()) + }) + + global.fetch = mockFetch + + // Re-import to override fetch again with our mock + jest.resetModules() + require('../fetch') + + // Perform the fetch + const url = 'http://localhost:8081/symbolicate' + await global.fetch(url) + + // Check that fetch was called + expect(mockFetch).toHaveBeenCalled() + + // Verify headers + expect(capturedRequest).not.toBeNull() + if (capturedRequest) { + const req = capturedRequest as Request + expect(req.headers.get('User-Agent')).toBe('SafeMobile/iOS/1.0.0/100') + expect(req.headers.get('Origin')).toBeFalsy() + } + }) + + it('should not add Origin header for IP address URL', async () => { + // Setup a mock implementation that captures the Request for inspection + let capturedRequest: unknown = null + + const mockFetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (typeof input === 'string') { + capturedRequest = new Request(input, init) + } else if (input instanceof URL) { + capturedRequest = new Request(input.toString(), init) + } else { + capturedRequest = input + } + return Promise.resolve(new Response()) + }) + + global.fetch = mockFetch + + // Re-import to override fetch again with our mock + jest.resetModules() + require('../fetch') + + // Perform the fetch + const url = 'http://192.168.0.252:8081/symbolicate' + await global.fetch(url) + + // Check that fetch was called + expect(mockFetch).toHaveBeenCalled() + + // Verify headers + expect(capturedRequest).not.toBeNull() + if (capturedRequest) { + const req = capturedRequest as Request + expect(req.headers.get('User-Agent')).toBe('SafeMobile/iOS/1.0.0/100') + expect(req.headers.get('Origin')).toBeFalsy() + } + }) + + it('should merge existing headers with User-Agent and Origin', async () => { + // Setup a mock implementation that captures the Request for inspection + let capturedRequest: unknown = null + + const mockFetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (typeof input === 'string') { + capturedRequest = new Request(input, init) + } else if (input instanceof URL) { + capturedRequest = new Request(input.toString(), init) + } else { + capturedRequest = input + } + return Promise.resolve(new Response()) + }) + + global.fetch = mockFetch + + // Re-import to override fetch again with our mock + jest.resetModules() + require('../fetch') + + // Perform the fetch + const url = 'https://example.com/api' + const init = { + headers: { + 'Content-Type': 'application/json', + }, + } + + await global.fetch(url, init) + + // Check that fetch was called + expect(mockFetch).toHaveBeenCalled() + + // Verify headers + expect(capturedRequest).not.toBeNull() + if (capturedRequest) { + const req = capturedRequest as Request + expect(req.headers.get('Content-Type')).toBe('application/json') + expect(req.headers.get('User-Agent')).toBe('SafeMobile/iOS/1.0.0/100') + expect(req.headers.get('Origin')).toBe('https://app.safe.global') + } + }) + + it('should preserve existing init options and not add Origin for IP URL', async () => { + // Setup a mock implementation that captures the Request for inspection + let capturedRequest: unknown = null + + const mockFetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (typeof input === 'string') { + capturedRequest = new Request(input, init) + } else if (input instanceof URL) { + capturedRequest = new Request(input.toString(), init) + } else { + capturedRequest = input + } + return Promise.resolve(new Response()) + }) + + global.fetch = mockFetch + + // Re-import to override fetch again with our mock + jest.resetModules() + require('../fetch') + + // Perform the fetch + const url = 'http://192.168.1.1:8081/api' + const init = { + method: 'POST', + body: JSON.stringify({ test: true }), + mode: 'cors' as RequestMode, + headers: { + 'Content-Type': 'application/json', + }, + } + + await global.fetch(url, init) + + // Check that fetch was called + expect(mockFetch).toHaveBeenCalled() + + // Verify request details + expect(capturedRequest).not.toBeNull() + if (capturedRequest) { + const req = capturedRequest as Request + expect(req.method).toBe('POST') + expect(req.headers.get('Content-Type')).toBe('application/json') + expect(req.headers.get('User-Agent')).toBe('SafeMobile/iOS/1.0.0/100') + expect(req.headers.get('Origin')).toBeFalsy() + } + }) + + it('should use Android in User-Agent when on Android', async () => { + // Mock Platform.OS as Android before importing fetch + jest.resetModules() + + // Mock the modules with Android OS + jest.doMock('react-native', () => ({ + Platform: { + OS: 'android', + }, + })) + + // Setup a mock implementation that captures the Request for inspection + let capturedRequest: unknown = null + + const mockFetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (typeof input === 'string') { + capturedRequest = new Request(input, init) + } else if (input instanceof URL) { + capturedRequest = new Request(input.toString(), init) + } else { + capturedRequest = input + } + return Promise.resolve(new Response()) + }) + + global.fetch = mockFetch + + // Now import fetch with the mocked Platform + require('../fetch') + + // Make a fetch call + const url = 'https://example.com/api' + await global.fetch(url) + + // Check that fetch was called + expect(mockFetch).toHaveBeenCalled() + + // Verify headers + expect(capturedRequest).not.toBeNull() + if (capturedRequest) { + const req = capturedRequest as Request + expect(req.headers.get('User-Agent')).toBe('SafeMobile/Android/1.0.0/100') + expect(req.headers.get('Origin')).toBe('https://app.safe.global') + } + + // Reset modules and mock for subsequent tests + jest.resetModules() + jest.dontMock('react-native') + }) + + it('should correctly handle Headers instances in init', async () => { + // Setup a mock implementation that captures the Request for inspection + let capturedRequest: unknown = null + + const mockFetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (typeof input === 'string') { + capturedRequest = new Request(input, init) + } else if (input instanceof URL) { + capturedRequest = new Request(input.toString(), init) + } else { + capturedRequest = input + } + return Promise.resolve(new Response()) + }) + + global.fetch = mockFetch + + // Re-import to override fetch again with our mock + jest.resetModules() + require('../fetch') + + // Perform the fetch + const url = 'https://example.com/api' + const headers = new Headers() + headers.append('Content-Type', 'application/json') + + const init = { headers } + + await global.fetch(url, init) + + // Check that fetch was called + expect(mockFetch).toHaveBeenCalled() + + // Verify headers + expect(capturedRequest).not.toBeNull() + if (capturedRequest) { + const req = capturedRequest as Request + expect(req.headers.get('Content-Type')).toBe('application/json') + expect(req.headers.get('User-Agent')).toBe('SafeMobile/iOS/1.0.0/100') + expect(req.headers.get('Origin')).toBe('https://app.safe.global') + } + }) +}) diff --git a/apps/mobile/src/platform/crypto-shims.ts b/apps/mobile/src/platform/crypto-shims.ts new file mode 100644 index 0000000000..35daba3d42 --- /dev/null +++ b/apps/mobile/src/platform/crypto-shims.ts @@ -0,0 +1,33 @@ +import { install } from 'react-native-quick-crypto' +install() + +/** + * Override crypto functions in ethers.js to use react-native-quick-crypto + * https://docs.ethers.org/v6/cookbook/react-native/ + */ +import { ethers } from 'ethers' + +import crypto from 'react-native-quick-crypto' + +ethers.randomBytes.register((length) => { + return new Uint8Array(crypto.randomBytes(length)) +}) + +ethers.computeHmac.register((algo, key, data) => { + return crypto.createHmac(algo, key).update(data).digest() +}) + +// @ts-ignore +ethers.pbkdf2.register((passwd, salt, iter, keylen, algo) => { + return crypto.pbkdf2Sync(passwd, salt, iter, keylen, algo) +}) + +ethers.sha256.register((data) => { + // @ts-ignore + return crypto.createHash('sha256').update(data).digest() +}) + +ethers.sha512.register((data) => { + // @ts-ignore + return crypto.createHash('sha512').update(data).digest() +}) diff --git a/apps/mobile/src/platform/fetch.ts b/apps/mobile/src/platform/fetch.ts new file mode 100644 index 0000000000..cc6c4571b9 --- /dev/null +++ b/apps/mobile/src/platform/fetch.ts @@ -0,0 +1,74 @@ +import { Platform } from 'react-native' +import * as Application from 'expo-application' +import { isIpOrLocalhostUrl } from '@/src/utils/url' + +const originalFetch = global.fetch +/** + * Override the global fetch function to add User-Agent and Origin headers. + * @param url - The URL to fetch + * @param init - The request init object + * @returns The response from the fetch + */ +global.fetch = (url: RequestInfo | URL, init?: RequestInit | undefined) => { + const userAgent = `SafeMobile/${Platform.OS === 'ios' ? 'iOS' : 'Android'}/${Application.nativeApplicationVersion}/${ + Application.nativeBuildVersion + }` + const origin = 'https://app.safe.global' + if (url instanceof Request && !init) { + // If url is a Request object and no init is provided, modify its headers directly + const headers = new Headers(url.headers) + headers.set('User-Agent', userAgent) + + // Check hostname for Request objects + const isIpOrLocalhost = isIpOrLocalhostUrl(url.url) + if (!isIpOrLocalhost) { + headers.set('Origin', origin) + } + + return originalFetch(new Request(url, { headers })) + } + + const options: RequestInit = init ? { ...init } : {} + + // Properly handle headers from Request object + if (url instanceof Request) { + const requestHeaders = new Headers(url.headers) + if (!options.headers) { + options.headers = requestHeaders + } else if (options.headers instanceof Headers) { + // Merge headers if options.headers is already a Headers object + requestHeaders.forEach((value, key) => { + ;(options.headers as Headers).append(key, value) + }) + } else { + // Convert to Headers object for proper merging + const newHeaders = new Headers(options.headers) + requestHeaders.forEach((value, key) => { + newHeaders.append(key, value) + }) + options.headers = newHeaders + } + } + + // If headers don't exist yet, create them + if (!options.headers) { + options.headers = new Headers() + } + + // Convert to Headers object if it's not already + if (!(options.headers instanceof Headers)) { + options.headers = new Headers(options.headers) + } + + // Add custom headers + const headers = options.headers as Headers + headers.set('User-Agent', userAgent) + + // Only add Origin header for actual domain requests, not for IP addresses or localhost + const isIpOrLocalhost = isIpOrLocalhostUrl(url instanceof URL ? url.toString() : url.toString()) + if (!isIpOrLocalhost) { + headers.set('Origin', origin) + } + + return originalFetch(url, options) +} diff --git a/apps/mobile/src/platform/intl-polyfills.ts b/apps/mobile/src/platform/intl-polyfills.ts new file mode 100644 index 0000000000..5158e8d577 --- /dev/null +++ b/apps/mobile/src/platform/intl-polyfills.ts @@ -0,0 +1,26 @@ +/** + * Hermes's implementation of Intl is not up to date and is missing some features. We rely mainly on `notation: "compact"` + * to properly format currency values, but it is not supported in Hermes. + * https://github.com/facebook/hermes/blob/5911e8180796d3ccb2669237ca441da717ad00b2/doc/IntlAPIs.md#limited-ios-property-support + * + * We need to polyfill some Intl APIs to make it work, and it seems that formatjs is doing the trick for us + */ + +// Don't remove -force from these because detection is VERY slow on low-end Android. +// https://github.com/formatjs/formatjs/issues/4463#issuecomment-2176070577 + +// https://github.com/formatjs/formatjs/blob/main/packages/intl-getcanonicallocales/polyfill-force.ts +import '@formatjs/intl-getcanonicallocales/polyfill-force' +// https://github.com/formatjs/formatjs/blob/main/packages/intl-locale/polyfill-force.ts +import '@formatjs/intl-locale/polyfill-force' +// https://github.com/formatjs/formatjs/blob/main/packages/intl-pluralrules/polyfill-force.ts +import '@formatjs/intl-pluralrules/polyfill-force' +// https://github.com/formatjs/formatjs/blob/main/packages/intl-numberformat/polyfill-force.ts +import '@formatjs/intl-numberformat/polyfill-force' + +// https://github.com/formatjs/formatjs/blob/main/packages/intl-displaynames/polyfill-force.ts +import '@formatjs/intl-displaynames/polyfill-force' + +import '@formatjs/intl-pluralrules/locale-data/en' +import '@formatjs/intl-numberformat/locale-data/en' +import '@formatjs/intl-displaynames/locale-data/en' diff --git a/apps/mobile/src/platform/security.ts b/apps/mobile/src/platform/security.ts new file mode 100644 index 0000000000..45fa84ce68 --- /dev/null +++ b/apps/mobile/src/platform/security.ts @@ -0,0 +1,71 @@ +import Constants from 'expo-constants' +import { NativeEventEmitterActions, TalsecConfig } from 'freerasp-react-native' +import { Alert } from 'react-native' +import { SECURITY_CERTIFICATE_HASH_BASE64, SECURITY_WATCHER_MAIL, SECURITY_RASP_ENABLED } from '../config/constants' + +// @ts-expect-error +const { android, ios } = Constants.expoConfig + +// app configuration +const config: TalsecConfig = { + androidConfig: { + packageName: android.package as string, + certificateHashes: [SECURITY_CERTIFICATE_HASH_BASE64 as string], + supportedAlternativeStores: [], + }, + iosConfig: { + appBundleId: ios.bundleIdentifier as string, + appTeamId: ios.appleTeamId as string, + }, + watcherMail: SECURITY_WATCHER_MAIL as string, + isProd: SECURITY_RASP_ENABLED as boolean, +} + +// Security threat messages +const SECURITY_MESSAGES = { + privilegedAccess: + 'Your device appears to be jailbroken or rooted. For security reasons, this app cannot run on compromised devices.', + hooks: + 'Runtime manipulation or hooking framework detected (e.g., Frida). This app cannot run with active hooks for security reasons.', + appIntegrity: 'App tampering detected! The application has been modified and cannot be trusted.', + unofficialStore: + 'Unofficial installation detected! This app was not installed from an official app store and cannot be trusted.', +} as const + +// Generic security alert handler +const handleSecurityThreat = (threatType: keyof typeof SECURITY_MESSAGES, killApp = true) => { + console.log(`${threatType} detected - security threat identified`) + + Alert.alert( + 'Security Alert', + SECURITY_MESSAGES[threatType], + [ + { + text: 'OK', + onPress: () => { + if (killApp) { + throw new Error(`App terminated due to ${threatType} detection`) + } + }, + }, + ], + { cancelable: false }, + ) +} + +// reactions for detected threats +const actions: NativeEventEmitterActions = { + // https://docs.talsec.app/freerasp/wiki/threat-detection/detecting-rooted-or-jailbroken-devices + privilegedAccess: () => handleSecurityThreat('privilegedAccess'), + + // https://docs.talsec.app/freerasp/wiki/threat-detection/hook-detection + hooks: () => handleSecurityThreat('hooks'), + + // https://docs.talsec.app/freerasp/wiki/threat-detection/app-tampering-detection + appIntegrity: () => handleSecurityThreat('appIntegrity'), + + // https://docs.talsec.app/freerasp/wiki/threat-detection/detecting-unofficial-installation + unofficialStore: () => handleSecurityThreat('unofficialStore', false), +} + +export { config, actions } diff --git a/apps/mobile/src/services/analytics/constants.ts b/apps/mobile/src/services/analytics/constants.ts new file mode 100644 index 0000000000..ba6203a352 --- /dev/null +++ b/apps/mobile/src/services/analytics/constants.ts @@ -0,0 +1,50 @@ +import type { TransactionInfoType, TransferInfoType, SettingsInfoType } from './types' + +export const ANALYTICS_LABELS = { + BASE_TYPES: { + Creation: 'safe_creation', + Custom: 'custom', + Transfer: 'transfer', + SettingsChange: 'settings_change', + SwapOrder: 'swap_order', + SwapTransfer: 'swap_transfer', + TwapOrder: 'twap_order', + NativeStakingDeposit: 'native_staking_deposit', + NativeStakingValidatorsExit: 'native_staking_exit', + NativeStakingWithdraw: 'native_staking_withdraw', + VaultDeposit: 'vault_deposit', + VaultRedeem: 'vault_redeem', + SwapAndBridge: 'swap_and_bridge', + Swap: 'swap', + } as const satisfies Record<TransactionInfoType, string>, + + TRANSFER_TYPES: { + ERC20: 'transfer_token', + ERC721: 'transfer_nft', + NATIVE_COIN: 'transfer_native', + } as const satisfies Record<TransferInfoType, string>, + + SETTINGS_TYPES: { + ADD_OWNER: 'owner_add', + REMOVE_OWNER: 'owner_remove', + SWAP_OWNER: 'owner_swap', + CHANGE_THRESHOLD: 'owner_threshold_change', + DELETE_GUARD: 'guard_remove', + DISABLE_MODULE: 'module_remove', + ENABLE_MODULE: 'module_enable', + SET_FALLBACK_HANDLER: 'fallback_handler_set', + SET_GUARD: 'guard_set', + CHANGE_MASTER_COPY: 'safe_update', + } as const satisfies Record<SettingsInfoType, string>, + + ENHANCED: { + batch_transfer_token: 'batch_transfer_token', + batch: 'batch', + rejection: 'rejection', + typed_message: 'typed_message', + safeapps: 'safeapps', + walletconnect: 'walletconnect', + activate_without_tx: 'activate_without_tx', + activate_with_tx: 'activate_with_tx', + } as const, +} as const diff --git a/apps/mobile/src/services/analytics/events/__tests__/copy.test.ts b/apps/mobile/src/services/analytics/events/__tests__/copy.test.ts new file mode 100644 index 0000000000..9fc907bc97 --- /dev/null +++ b/apps/mobile/src/services/analytics/events/__tests__/copy.test.ts @@ -0,0 +1,38 @@ +import { createAddressCopyEvent } from '../copy' +import { EventType } from '../../types' + +describe('Copy Analytics Events', () => { + describe('createAddressCopyEvent', () => { + it('should create correct event structure with pathname', () => { + const pathname = '/(tabs)/index' + const event = createAddressCopyEvent(pathname) + + expect(event).toEqual({ + eventName: EventType.META, + eventCategory: 'copy', + eventAction: 'Address copied', + eventLabel: pathname, + }) + }) + + it('should handle different pathnames', () => { + const pathnames = ['/signers', '/accounts-sheet', '/confirm-transaction'] + + pathnames.forEach((pathname) => { + const event = createAddressCopyEvent(pathname) + expect(event.eventLabel).toBe(pathname) + expect(event.eventName).toBe(EventType.META) + expect(event.eventCategory).toBe('copy') + expect(event.eventAction).toBe('Address copied') + }) + }) + + it('should handle dynamic routes', () => { + const dynamicPath = '/transactions/123' + const event = createAddressCopyEvent(dynamicPath) + + expect(event.eventLabel).toBe(dynamicPath) + expect(event.eventName).toBe(EventType.META) + }) + }) +}) diff --git a/apps/mobile/src/services/analytics/events/__tests__/overview.test.ts b/apps/mobile/src/services/analytics/events/__tests__/overview.test.ts new file mode 100644 index 0000000000..6bb0faaf45 --- /dev/null +++ b/apps/mobile/src/services/analytics/events/__tests__/overview.test.ts @@ -0,0 +1,133 @@ +import { createMyAccountsScreenViewEvent, createMyAccountsEditModeEvent, createSafeReorderEvent } from '../overview' +import { EventType } from '../../types' + +describe('overview events', () => { + describe('createMyAccountsScreenViewEvent', () => { + it('should create correct event structure with safe count', () => { + const totalSafeCount = 5 + const event = createMyAccountsScreenViewEvent(totalSafeCount) + + expect(event).toEqual({ + eventName: EventType.SCREEN_VIEW, + eventCategory: 'overview', + eventAction: 'My accounts screen viewed', + eventLabel: 5, + }) + }) + + it('should handle zero safe count', () => { + const totalSafeCount = 0 + const event = createMyAccountsScreenViewEvent(totalSafeCount) + + expect(event).toEqual({ + eventName: EventType.SCREEN_VIEW, + eventCategory: 'overview', + eventAction: 'My accounts screen viewed', + eventLabel: 0, + }) + }) + + it('should handle large safe counts', () => { + const totalSafeCount = 100 + const event = createMyAccountsScreenViewEvent(totalSafeCount) + + expect(event).toEqual({ + eventName: EventType.SCREEN_VIEW, + eventCategory: 'overview', + eventAction: 'My accounts screen viewed', + eventLabel: 100, + }) + }) + }) + + describe('createMyAccountsEditModeEvent', () => { + it('should create correct event for entering edit mode', () => { + const totalSafeCount = 3 + const event = createMyAccountsEditModeEvent(true, totalSafeCount) + + expect(event).toEqual({ + eventName: EventType.META, + eventCategory: 'overview', + eventAction: 'Edit mode entered', + eventLabel: 3, + }) + }) + + it('should create correct event for exiting edit mode', () => { + const totalSafeCount = 7 + const event = createMyAccountsEditModeEvent(false, totalSafeCount) + + expect(event).toEqual({ + eventName: EventType.META, + eventCategory: 'overview', + eventAction: 'Edit mode exited', + eventLabel: 7, + }) + }) + + it('should handle zero safe count for edit mode changes', () => { + const totalSafeCount = 0 + const enterEvent = createMyAccountsEditModeEvent(true, totalSafeCount) + const exitEvent = createMyAccountsEditModeEvent(false, totalSafeCount) + + expect(enterEvent.eventLabel).toBe(0) + expect(exitEvent.eventLabel).toBe(0) + }) + + it('should handle different safe counts for edit mode tracking', () => { + const enterEvent = createMyAccountsEditModeEvent(true, 50) + const exitEvent = createMyAccountsEditModeEvent(false, 50) + + expect(enterEvent.eventAction).toBe('Edit mode entered') + expect(exitEvent.eventAction).toBe('Edit mode exited') + expect(enterEvent.eventLabel).toBe(50) + expect(exitEvent.eventLabel).toBe(50) + }) + }) + + describe('createSafeReorderEvent', () => { + it('should create correct event for safe reordering', () => { + const totalSafeCount = 5 + const event = createSafeReorderEvent(totalSafeCount) + + expect(event).toEqual({ + eventName: EventType.META, + eventCategory: 'overview', + eventAction: 'Safe reordered', + eventLabel: 5, + }) + }) + + it('should handle zero safe count for reordering', () => { + const totalSafeCount = 0 + const event = createSafeReorderEvent(totalSafeCount) + + expect(event).toEqual({ + eventName: EventType.META, + eventCategory: 'overview', + eventAction: 'Safe reordered', + eventLabel: 0, + }) + }) + + it('should handle large safe counts for reordering', () => { + const totalSafeCount = 100 + const event = createSafeReorderEvent(totalSafeCount) + + expect(event).toEqual({ + eventName: EventType.META, + eventCategory: 'overview', + eventAction: 'Safe reordered', + eventLabel: 100, + }) + }) + + it('should use META event type for reordering tracking', () => { + const event = createSafeReorderEvent(10) + + expect(event.eventName).toBe(EventType.META) + expect(event.eventCategory).toBe('overview') + expect(event.eventAction).toBe('Safe reordered') + }) + }) +}) diff --git a/apps/mobile/src/services/analytics/events/addressBook.ts b/apps/mobile/src/services/analytics/events/addressBook.ts new file mode 100644 index 0000000000..6ded72fe3d --- /dev/null +++ b/apps/mobile/src/services/analytics/events/addressBook.ts @@ -0,0 +1,29 @@ +import { EventType } from '../types' + +export const createContactAddedEvent = (totalContactCount: number) => ({ + eventName: EventType.META, + eventCategory: 'address-book', + eventAction: 'Contact added', + eventLabel: totalContactCount.toString(), +}) + +export const createContactEditedEvent = (totalContactCount: number) => ({ + eventName: EventType.META, + eventCategory: 'address-book', + eventAction: 'Contact edited', + eventLabel: totalContactCount.toString(), +}) + +export const createContactRemovedEvent = (totalContactCount: number) => ({ + eventName: EventType.META, + eventCategory: 'address-book', + eventAction: 'Contact removed', + eventLabel: totalContactCount.toString(), +}) + +export const createAddressBookScreenVisitEvent = (totalContactCount: number) => ({ + eventName: EventType.META, + eventCategory: 'address-book', + eventAction: 'Screen visited', + eventLabel: totalContactCount.toString(), +}) diff --git a/apps/mobile/src/services/analytics/events/copy.ts b/apps/mobile/src/services/analytics/events/copy.ts new file mode 100644 index 0000000000..7129c97e71 --- /dev/null +++ b/apps/mobile/src/services/analytics/events/copy.ts @@ -0,0 +1,14 @@ +import { EventType } from '../types' + +const COPY_CATEGORY = 'copy' + +/** + * Creates an analytics event for when the user copies an address + * @param screenPath - The pathname where the copy action occurred + */ +export const createAddressCopyEvent = (screenPath: string) => ({ + eventName: EventType.META, + eventCategory: COPY_CATEGORY, + eventAction: 'Address copied', + eventLabel: screenPath, +}) diff --git a/apps/mobile/src/services/analytics/events/index.ts b/apps/mobile/src/services/analytics/events/index.ts new file mode 100644 index 0000000000..c59a009760 --- /dev/null +++ b/apps/mobile/src/services/analytics/events/index.ts @@ -0,0 +1,6 @@ +export * from './transactions' +export * from './overview' +export * from './settings' +export * from './safes' +export * from './addressBook' +export * from './nativeIntent' diff --git a/apps/mobile/src/services/analytics/events/nativeIntent.ts b/apps/mobile/src/services/analytics/events/nativeIntent.ts new file mode 100644 index 0000000000..fce5bdc364 --- /dev/null +++ b/apps/mobile/src/services/analytics/events/nativeIntent.ts @@ -0,0 +1,17 @@ +import { EventType } from '../types' + +const NATIVE_INTENT_CATEGORY = 'native-intent' + +export const NATIVE_INTENT_EVENTS = { + PROTECTED_ROUTE_ATTEMPT: { + eventName: EventType.META, + eventCategory: NATIVE_INTENT_CATEGORY, + eventAction: 'Attempted access to protected route', + // eventLabel will be the route path + }, +} + +export const createProtectedRouteAttemptEvent = (path: string) => ({ + ...NATIVE_INTENT_EVENTS.PROTECTED_ROUTE_ATTEMPT, + eventLabel: path, +}) diff --git a/apps/mobile/src/services/analytics/events/overview.ts b/apps/mobile/src/services/analytics/events/overview.ts new file mode 100644 index 0000000000..a1b5a34b9a --- /dev/null +++ b/apps/mobile/src/services/analytics/events/overview.ts @@ -0,0 +1,42 @@ +import { EventType } from '../types' + +const OVERVIEW_CATEGORY = 'overview' + +export const OVERVIEW_EVENTS = { + SAFE_VIEWED: { + eventName: EventType.SAFE_OPENED, + eventCategory: 'safe', + eventAction: 'opened', + eventLabel: 'safe_viewed', + }, +} + +/** + * Creates an analytics event for when the My accounts screen is viewed + */ +export const createMyAccountsScreenViewEvent = (totalSafeCount: number) => ({ + eventName: EventType.SCREEN_VIEW, + eventCategory: OVERVIEW_CATEGORY, + eventAction: 'My accounts screen viewed', + eventLabel: totalSafeCount, +}) + +/** + * Creates an analytics event for when the user enters or exits edit mode on My accounts screen + */ +export const createMyAccountsEditModeEvent = (isEnteringEditMode: boolean, totalSafeCount: number) => ({ + eventName: EventType.META, + eventCategory: OVERVIEW_CATEGORY, + eventAction: isEnteringEditMode ? 'Edit mode entered' : 'Edit mode exited', + eventLabel: totalSafeCount, +}) + +/** + * Creates an analytics event for when the user reorders safes using drag-and-drop + */ +export const createSafeReorderEvent = (totalSafeCount: number) => ({ + eventName: EventType.META, + eventCategory: OVERVIEW_CATEGORY, + eventAction: 'Safe reordered', + eventLabel: totalSafeCount, +}) diff --git a/apps/mobile/src/services/analytics/events/safes.ts b/apps/mobile/src/services/analytics/events/safes.ts new file mode 100644 index 0000000000..4ca297be64 --- /dev/null +++ b/apps/mobile/src/services/analytics/events/safes.ts @@ -0,0 +1,23 @@ +import { AnalyticsEvent, EventType } from '../types' + +const SAFES_CATEGORY = 'safes' + +/** + * Creates an analytics event for when a safe is added to the app + */ +export const createSafeAddedEvent = (totalSafeCount: number): AnalyticsEvent => ({ + eventName: EventType.META, + eventCategory: SAFES_CATEGORY, + eventAction: 'Safe added', + eventLabel: totalSafeCount, +}) + +/** + * Creates an analytics event for when a safe is removed from the app + */ +export const createSafeRemovedEvent = (totalSafeCount: number): AnalyticsEvent => ({ + eventName: EventType.META, + eventCategory: SAFES_CATEGORY, + eventAction: 'Safe removed', + eventLabel: totalSafeCount, +}) diff --git a/apps/mobile/src/services/analytics/events/settings.ts b/apps/mobile/src/services/analytics/events/settings.ts new file mode 100644 index 0000000000..acb81f73c2 --- /dev/null +++ b/apps/mobile/src/services/analytics/events/settings.ts @@ -0,0 +1,77 @@ +import { EventType } from '../types' + +const SETTINGS_CATEGORY = 'settings' + +export const SETTINGS_EVENTS = { + APPEARANCE: { + THEME_CHANGE: { + eventName: EventType.META, + eventCategory: SETTINGS_CATEGORY, + eventAction: 'Theme preference changed', + // eventLabel will be the theme value: 'light', 'dark', or 'auto' + }, + }, + NOTIFICATIONS: { + TOGGLE: { + eventName: EventType.META, + eventCategory: SETTINGS_CATEGORY, + eventAction: 'Notifications toggled', + // eventLabel will be boolean: true/false + }, + }, + BIOMETRICS: { + TOGGLE: { + eventName: EventType.META, + eventCategory: SETTINGS_CATEGORY, + eventAction: 'Biometrics toggled', + // eventLabel will be boolean: true/false + }, + }, +} + +/** + * Helper function to create theme change event + * @param themePreference - The new theme preference: 'light' | 'dark' | 'auto' + */ +export const createThemeChangeEvent = (themePreference: string) => ({ + ...SETTINGS_EVENTS.APPEARANCE.THEME_CHANGE, + eventLabel: themePreference, +}) + +/** + * Helper function to create notification toggle event + * @param enabled - Whether notifications are enabled + */ +export const createNotificationToggleEvent = (enabled: boolean) => ({ + ...SETTINGS_EVENTS.NOTIFICATIONS.TOGGLE, + eventLabel: enabled, +}) + +/** + * Helper function to create biometrics toggle event + * @param enabled - Whether biometrics are enabled + */ +export const createBiometricsToggleEvent = (enabled: boolean) => ({ + ...SETTINGS_EVENTS.BIOMETRICS.TOGGLE, + eventLabel: enabled, +}) + +/** + * Track when user opens app settings from the settings menu + */ +export const createAppSettingsOpenEvent = () => ({ + eventName: EventType.META, + eventCategory: SETTINGS_CATEGORY, + eventAction: 'Safe settings opened', + eventLabel: 'Settings menu button pressed', +}) + +/** + * Track when user selects an action from the settings menu + */ +export const createSettingsMenuActionEvent = (action: 'rename' | 'explorer' | 'share' | 'remove') => ({ + eventName: EventType.META, + eventCategory: SETTINGS_CATEGORY, + eventAction: 'Settings menu action', + eventLabel: action, +}) diff --git a/apps/mobile/src/services/analytics/events/signers.ts b/apps/mobile/src/services/analytics/events/signers.ts new file mode 100644 index 0000000000..cda7f17651 --- /dev/null +++ b/apps/mobile/src/services/analytics/events/signers.ts @@ -0,0 +1,14 @@ +import { EventType } from '../types' + +const SIGNERS_CATEGORY = 'signers' + +/** + * Track when a new signer is added to the app + * @param totalSignerCount - The total number of signers after addition + */ +export const createSignerAddedEvent = (totalSignerCount: number) => ({ + eventName: EventType.META, + eventCategory: SIGNERS_CATEGORY, + eventAction: 'Signer added', + eventLabel: totalSignerCount.toString(), +}) diff --git a/apps/mobile/src/services/analytics/events/transactions.ts b/apps/mobile/src/services/analytics/events/transactions.ts new file mode 100644 index 0000000000..0c1d4ac978 --- /dev/null +++ b/apps/mobile/src/services/analytics/events/transactions.ts @@ -0,0 +1,42 @@ +import { EventType, type AnalyticsLabel } from '../types' + +const TRANSACTIONS_CATEGORY = 'transactions' + +export const TRANSACTIONS_EVENTS = { + CONFIRM: { + eventName: EventType.TX_CONFIRMED, + eventCategory: TRANSACTIONS_CATEGORY, + eventAction: 'Confirm transaction', + // eventLabel can be set when tracking the event using AnalyticsLabel + }, + CREATE: { + eventName: EventType.TX_CREATED, + eventCategory: TRANSACTIONS_CATEGORY, + eventAction: 'Create transaction', + // eventLabel can be set when tracking the event using AnalyticsLabel + }, + EXECUTE: { + eventName: EventType.TX_EXECUTED, + eventCategory: TRANSACTIONS_CATEGORY, + eventAction: 'Execute transaction', + // eventLabel can be set when tracking the event using AnalyticsLabel + }, +} + +// Helper function to create a transaction confirm event with a specific label +export const createTxConfirmEvent = (label: AnalyticsLabel) => ({ + ...TRANSACTIONS_EVENTS.CONFIRM, + eventLabel: label, +}) + +// Helper function to create a transaction create event with a specific label +export const createTxCreateEvent = (label: AnalyticsLabel) => ({ + ...TRANSACTIONS_EVENTS.CREATE, + eventLabel: label, +}) + +// Helper function to create a transaction execute event with a specific label +export const createTxExecuteEvent = (label: AnalyticsLabel) => ({ + ...TRANSACTIONS_EVENTS.EXECUTE, + eventLabel: label, +}) diff --git a/apps/mobile/src/services/analytics/firebaseAnalytics.ts b/apps/mobile/src/services/analytics/firebaseAnalytics.ts new file mode 100644 index 0000000000..29ce694457 --- /dev/null +++ b/apps/mobile/src/services/analytics/firebaseAnalytics.ts @@ -0,0 +1,142 @@ +/** + * Firebase Analytics Service + * + * This service handles all analytics events for the mobile app using Firebase Analytics. + * It automatically includes common parameters with every event and manages user properties. + * + */ + +import { getAnalytics } from '@react-native-firebase/analytics' +import type { AnalyticsEvent } from './types' +import { AnalyticsUserProperties } from './types' +import { nativeApplicationVersion, nativeBuildVersion } from 'expo-application' + +// Common parameters that are sent with every event +const commonEventParams = { + appVersion: `${nativeApplicationVersion}-${nativeBuildVersion}`, + chainId: '', + safeAddress: '', +} + +/** + * Set the chain ID for all subsequent events + */ +export const setChainId = (chainId: string): void => { + commonEventParams.chainId = chainId +} + +/** + * Set the safe address for all subsequent events + */ +export const setSafeAddress = (safeAddress: string): void => { + // Remove 0x prefix to match web app behavior + commonEventParams.safeAddress = safeAddress.startsWith('0x') ? safeAddress.slice(2) : safeAddress +} + +/** + * Set user properties for Firebase Analytics + */ +export const setUserProperty = async (name: AnalyticsUserProperties, value: string): Promise<void> => { + try { + await getAnalytics().setUserProperty(name, value) + + if (__DEV__) { + console.info('[Firebase Analytics] - Set user property:', name, '=', value) + } + } catch (error) { + console.error('[Firebase Analytics] - Error setting user property:', error) + } +} + +/** + * Set user ID for Firebase Analytics + */ +export const setUserId = async (userId: string): Promise<void> => { + try { + await getAnalytics().setUserId(userId) + + if (__DEV__) { + console.info('[Firebase Analytics] - Set user ID:', userId) + } + } catch (error) { + console.error('[Firebase Analytics] - Error setting user ID:', error) + } +} + +/** + * Track a custom event with common parameters + */ +export const trackEvent = async (eventData: AnalyticsEvent): Promise<void> => { + try { + const analytics = getAnalytics() + + // Prepare event parameters + const eventParams: Record<string, string | number | boolean> = { + ...commonEventParams, + eventCategory: truncateParam(eventData.eventCategory) ?? '', + eventAction: truncateParam(eventData.eventAction) ?? '', + chainId: eventData.chainId || commonEventParams.chainId, + } + + // Add event label if provided + if (eventData.eventLabel !== undefined) { + eventParams.eventLabel = truncateParam(String(eventData.eventLabel)) ?? '' + } + + // Log the event + await analytics.logEvent(eventData.eventName, eventParams) + + if (__DEV__) { + console.info('[Firebase Analytics] - Event tracked:', { + eventName: eventData.eventName, + ...eventParams, + }) + } + } catch (error) { + console.error('[Firebase Analytics] - Error tracking event:', error) + } +} + +// Helper to truncate parameter values to 100 characters +function truncateParam(value: string | undefined): string | undefined { + if (typeof value === 'string' && value.length > 100) { + return value.slice(0, 100) + } + return value +} + +/** + * Track screen views + */ +export const trackScreenView = async (screenName: string, screenClass?: string): Promise<void> => { + try { + const analytics = getAnalytics() + + await analytics.logScreenView({ + screen_name: screenName, + screen_class: screenClass || screenName, + ...commonEventParams, + }) + + if (__DEV__) { + console.info('[Firebase Analytics] - Screen view tracked:', screenName) + } + } catch (error) { + console.error('[Firebase Analytics] - Error tracking screen view:', error) + } +} + +/** + * Enable/disable analytics collection + */ +export const setAnalyticsCollectionEnabled = async (enabled: boolean): Promise<void> => { + try { + await getAnalytics().setAnalyticsCollectionEnabled(enabled) + + if (__DEV__) { + console.info('[Firebase Analytics] - Analytics collection enabled:', enabled) + } + } catch (error) { + console.error('[Firebase Analytics] - Error setting analytics collection:', error) + } +} diff --git a/apps/mobile/src/services/analytics/index.ts b/apps/mobile/src/services/analytics/index.ts new file mode 100644 index 0000000000..ece3fec7e9 --- /dev/null +++ b/apps/mobile/src/services/analytics/index.ts @@ -0,0 +1,5 @@ +export * from './events' +export * from './types' +export * from './constants' +export * from './utils' +export * from './firebaseAnalytics' diff --git a/apps/mobile/src/services/analytics/types.ts b/apps/mobile/src/services/analytics/types.ts new file mode 100644 index 0000000000..bff856bb35 --- /dev/null +++ b/apps/mobile/src/services/analytics/types.ts @@ -0,0 +1,58 @@ +import type { + Transaction, + TransferTransactionInfo, + SettingsChangeTransaction, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +export type { + Transaction, + TransferTransactionInfo, + SettingsChangeTransaction, + CustomTransactionInfo, + SwapTransferTransactionInfo, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import type { ANALYTICS_LABELS } from './constants' + +/** + * Firebase Analytics Event Types + * These event names are passed directly to Firebase Analytics + */ +export enum EventType { + SCREEN_VIEW = 'screen_view', + CLICK = 'customClick', + META = 'metadata', + SAFE_APP = 'safeApp', + SAFE_CREATED = 'safe_created', + SAFE_ACTIVATED = 'safe_activated', + SAFE_OPENED = 'safe_opened', + WALLET_CONNECTED = 'wallet_connected', + TX_CREATED = 'tx_created', + TX_CONFIRMED = 'tx_confirmed', + TX_EXECUTED = 'tx_executed', +} + +export type EventLabel = string | number | boolean | null + +export type AnalyticsEvent = { + eventName: string + eventCategory: string + eventAction: string + eventLabel?: EventLabel + chainId?: string +} + +export enum AnalyticsUserProperties { + WALLET_LABEL = 'walletLabel', + WALLET_ADDRESS = 'walletAddress', +} + +// Extract precise types from the source of truth +export type TransactionInfoType = Transaction['txInfo']['type'] +export type TransferInfoType = TransferTransactionInfo['transferInfo']['type'] +export type SettingsInfoType = SettingsChangeTransaction['settingsInfo']['type'] + +// Union of all possible analytics labels +export type AnalyticsLabel = + | (typeof ANALYTICS_LABELS.BASE_TYPES)[TransactionInfoType] + | (typeof ANALYTICS_LABELS.TRANSFER_TYPES)[TransferInfoType] + | (typeof ANALYTICS_LABELS.SETTINGS_TYPES)[SettingsInfoType] + | (typeof ANALYTICS_LABELS.ENHANCED)[keyof typeof ANALYTICS_LABELS.ENHANCED] diff --git a/apps/mobile/src/services/analytics/utils.ts b/apps/mobile/src/services/analytics/utils.ts new file mode 100644 index 0000000000..d988cd7279 --- /dev/null +++ b/apps/mobile/src/services/analytics/utils.ts @@ -0,0 +1,48 @@ +import type { + Transaction, + TransferTransactionInfo, + SettingsChangeTransaction, + CustomTransactionInfo, + SwapTransferTransactionInfo, +} from './types' +import type { AnalyticsLabel, TransactionInfoType } from './types' +import { ANALYTICS_LABELS } from './constants' + +export const getTransactionAnalyticsLabel = (txInfo: Transaction['txInfo']): AnalyticsLabel => { + const baseType = txInfo.type as TransactionInfoType + + switch (baseType) { + case 'Transfer': { + const transferTx = txInfo as TransferTransactionInfo + return ANALYTICS_LABELS.TRANSFER_TYPES[transferTx.transferInfo.type] + } + + case 'SwapTransfer': { + const swapTransferTx = txInfo as SwapTransferTransactionInfo + return ANALYTICS_LABELS.TRANSFER_TYPES[swapTransferTx.transferInfo.type] + } + + case 'SettingsChange': { + const settingsTx = txInfo as SettingsChangeTransaction + return ANALYTICS_LABELS.SETTINGS_TYPES[settingsTx.settingsInfo.type] + } + + case 'Custom': { + const customTx = txInfo as CustomTransactionInfo + + if (customTx.isCancellation) { + return ANALYTICS_LABELS.ENHANCED.rejection + } + + if (customTx.actionCount && customTx.actionCount > 1) { + return ANALYTICS_LABELS.ENHANCED.batch + } + + return ANALYTICS_LABELS.BASE_TYPES.Custom + } + + default: { + return ANALYTICS_LABELS.BASE_TYPES[baseType] + } + } +} diff --git a/apps/mobile/src/services/contracts/safeContracts.ts b/apps/mobile/src/services/contracts/safeContracts.ts new file mode 100644 index 0000000000..9d66d793f4 --- /dev/null +++ b/apps/mobile/src/services/contracts/safeContracts.ts @@ -0,0 +1,40 @@ +import { SafeProvider } from '@safe-global/protocol-kit' +import { + getMultiSendCallOnlyContractInstance, + getSafeContractInstance, +} from '@safe-global/protocol-kit/dist/src/contracts/contractInstances' +import type SafeBaseContract from '@safe-global/protocol-kit/dist/src/contracts/Safe/SafeBaseContract' +import type { SafeState as SafeInfo } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { getSafeSDK } from '@/src/hooks/coreSDK/safeCoreSDK' +import { _getValidatedGetContractProps } from '@safe-global/utils/services/contracts/safeContracts' + +const getGnosisSafeContract = async (safe: SafeInfo, safeProvider: SafeProvider) => { + return getSafeContractInstance( + _getValidatedGetContractProps(safe.version).safeVersion, + safeProvider, + safe.address.value, + ) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const getReadOnlyCurrentGnosisSafeContract = async (safe: SafeInfo): Promise<SafeBaseContract<any>> => { + const safeSDK = getSafeSDK() + if (!safeSDK) { + throw new Error('Safe SDK not found.') + } + + const safeProvider = safeSDK.getSafeProvider() + + return getGnosisSafeContract(safe, safeProvider) +} + +export const getReadOnlyMultiSendCallOnlyContract = async (safeVersion: SafeInfo['version']) => { + const safeSDK = getSafeSDK() + if (!safeSDK) { + throw new Error('Safe SDK not found.') + } + + const safeProvider = safeSDK.getSafeProvider() + + return getMultiSendCallOnlyContractInstance(_getValidatedGetContractProps(safeVersion).safeVersion, safeProvider) +} diff --git a/apps/mobile/src/services/delegate-cleanup/DelegateCleanupService.test.ts b/apps/mobile/src/services/delegate-cleanup/DelegateCleanupService.test.ts new file mode 100644 index 0000000000..d36a2ba078 --- /dev/null +++ b/apps/mobile/src/services/delegate-cleanup/DelegateCleanupService.test.ts @@ -0,0 +1,319 @@ +import { + DelegateCleanupService, + DelegateCleanupPhase, + DelegateCleanupErrorType, + type DelegateCleanupError, +} from './DelegateCleanupService' +import { + cleanupDelegateNotifications, + removeDelegatesFromBackend, + cleanupDelegateKeychain, +} from '@/src/hooks/useDelegateCleanup/utils' +import { removeDelegate } from '@/src/store/delegatesSlice' +import { Wallet } from 'ethers' +import Logger from '@/src/utils/logger' +import { type Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { type Address } from '@/src/types/address' +import { type DelegateInfo } from '@/src/store/delegatesSlice' +import { ErrorType } from '@/src/utils/errors' + +// Mock dependencies +jest.mock('@/src/hooks/useDelegateCleanup/utils') +jest.mock('@/src/store/delegatesSlice') +jest.mock('@/src/utils/logger') +jest.mock('ethers') + +const mockCleanupDelegateNotifications = cleanupDelegateNotifications as jest.MockedFunction< + typeof cleanupDelegateNotifications +> +const mockRemoveDelegatesFromBackend = removeDelegatesFromBackend as jest.MockedFunction< + typeof removeDelegatesFromBackend +> +const mockCleanupDelegateKeychain = cleanupDelegateKeychain as jest.MockedFunction<typeof cleanupDelegateKeychain> +const mockRemoveDelegate = removeDelegate as jest.MockedFunction<typeof removeDelegate> +const mockLogger = Logger as jest.Mocked<typeof Logger> + +describe('DelegateCleanupService', () => { + const mockOwnerAddress = '0x123456789abcdef' as Address + const mockOwnerPrivateKey = '0xprivatekey123456789abcdef' + const mockDelegateAddress1 = '0xabcdef123456789' as Address + const mockDelegateAddress2 = '0xfedcba987654321' as Address + const mockDelegateAddresses = [mockDelegateAddress1, mockDelegateAddress2] + + const mockChains: Chain[] = [ + { + chainId: '1', + chainName: 'Ethereum', + description: 'Ethereum Mainnet', + l2: false, + isTestnet: false, + zk: false, + nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18, logoUri: '' }, + transactionService: 'https://safe-transaction-mainnet.safe.global', + blockExplorerUriTemplate: { address: '', txHash: '', api: '' }, + ensRegistryAddress: '', + recommendedMasterCopyVersion: '', + disabledWallets: [], + features: [], + gasPrice: [], + publicRpcUri: { authentication: 'NO_AUTHENTICATION', value: '' }, + rpcUri: { authentication: 'NO_AUTHENTICATION', value: '' }, + safeAppsRpcUri: { authentication: 'NO_AUTHENTICATION', value: '' }, + shortName: 'eth', + theme: { textColor: '', backgroundColor: '' }, + } as unknown as Chain, + ] + + const mockDelegates: Record<Address, Record<string, DelegateInfo>> = { + [mockOwnerAddress]: { + [mockDelegateAddress1]: { + safe: '0xsafe1', + delegate: mockDelegateAddress1, + delegator: mockOwnerAddress, + label: 'Delegate 1', + }, + [mockDelegateAddress2]: { + safe: '0xsafe2', + delegate: mockDelegateAddress2, + delegator: mockOwnerAddress, + label: 'Delegate 2', + }, + }, + } + + const mockConfig = { + allChains: mockChains, + allDelegates: mockDelegates, + cleanupNotificationsForDelegate: jest.fn(), + deleteDelegate: jest.fn(), + dispatch: jest.fn(), + onProgress: jest.fn(), + } + + let service: DelegateCleanupService + + beforeEach(() => { + jest.clearAllMocks() + service = new DelegateCleanupService(mockConfig) + + // Mock Wallet constructor + ;(Wallet as unknown as jest.Mock).mockImplementation(() => ({ + signTypedData: jest.fn().mockResolvedValue('0xmockedsignature'), + })) + + // Default mock implementations + mockCleanupDelegateNotifications.mockResolvedValue({ success: true }) + mockRemoveDelegatesFromBackend.mockResolvedValue({ success: true }) + mockCleanupDelegateKeychain.mockResolvedValue({ success: true }) + mockRemoveDelegate.mockReturnValue({ + type: 'delegates/removeDelegate' as const, + payload: { ownerAddress: mockOwnerAddress, delegateAddress: mockDelegateAddress1 }, + }) + }) + + describe('removeAllDelegatesForOwner', () => { + it('should return validation error when owner address is missing', async () => { + const result = await service.removeAllDelegatesForOwner('' as Address, mockOwnerPrivateKey) + + expect(result.success).toBe(false) + expect(result.error?.type).toBe(ErrorType.VALIDATION_ERROR) + expect(result.error?.message).toBe('Owner address and private key are required') + }) + + it('should return validation error when private key is missing', async () => { + const result = await service.removeAllDelegatesForOwner(mockOwnerAddress, '') + + expect(result.success).toBe(false) + expect(result.error?.type).toBe(ErrorType.VALIDATION_ERROR) + expect(result.error?.message).toBe('Owner address and private key are required') + }) + + it('should return success with 0 processed when no delegates found', async () => { + const emptyConfig = { + ...mockConfig, + allDelegates: {}, + } + const emptyService = new DelegateCleanupService(emptyConfig) + + const result = await emptyService.removeAllDelegatesForOwner(mockOwnerAddress, mockOwnerPrivateKey) + + expect(result.success).toBe(true) + expect(result.data?.processedCount).toBe(0) + expect(mockConfig.onProgress).toHaveBeenCalledWith({ + phase: DelegateCleanupPhase.COMPLETED, + message: 'No delegates to clean up', + completedDelegates: undefined, + totalDelegates: undefined, + }) + }) + + it('should successfully orchestrate all cleanup phases', async () => { + const result = await service.removeAllDelegatesForOwner(mockOwnerAddress, mockOwnerPrivateKey) + + expect(result.success).toBe(true) + expect(result.data?.processedCount).toBe(2) + + // Verify progress callbacks + expect(mockConfig.onProgress).toHaveBeenCalledWith({ + phase: DelegateCleanupPhase.CLEANING_NOTIFICATIONS, + message: expect.any(String), + completedDelegates: 0, + totalDelegates: 2, + }) + expect(mockConfig.onProgress).toHaveBeenCalledWith({ + phase: DelegateCleanupPhase.REMOVING_FROM_BACKEND, + message: expect.any(String), + completedDelegates: 0, + totalDelegates: 2, + }) + expect(mockConfig.onProgress).toHaveBeenCalledWith({ + phase: DelegateCleanupPhase.CLEANING_KEYCHAIN, + message: expect.any(String), + completedDelegates: 0, + totalDelegates: 2, + }) + expect(mockConfig.onProgress).toHaveBeenCalledWith({ + phase: DelegateCleanupPhase.UPDATING_STORE, + message: expect.any(String), + completedDelegates: 0, + totalDelegates: 2, + }) + expect(mockConfig.onProgress).toHaveBeenCalledWith({ + phase: DelegateCleanupPhase.COMPLETED, + message: expect.any(String), + completedDelegates: 2, + totalDelegates: 2, + }) + + // Verify cleanup functions were called + expect(mockCleanupDelegateNotifications).toHaveBeenCalledWith( + mockOwnerAddress, + mockDelegateAddresses, + mockConfig.cleanupNotificationsForDelegate, + ) + expect(mockRemoveDelegatesFromBackend).toHaveBeenCalledWith( + mockOwnerAddress, + mockDelegateAddresses, + expect.objectContaining({ signTypedData: expect.any(Function) }), + mockChains, + mockConfig.deleteDelegate, + ) + expect(mockCleanupDelegateKeychain).toHaveBeenCalledWith(mockOwnerAddress, mockDelegateAddresses) + + // Verify Redux store updates + expect(mockConfig.dispatch).toHaveBeenCalledTimes(2) + expect(mockRemoveDelegate).toHaveBeenCalledWith({ + ownerAddress: mockOwnerAddress, + delegateAddress: mockDelegateAddress1, + }) + expect(mockRemoveDelegate).toHaveBeenCalledWith({ + ownerAddress: mockOwnerAddress, + delegateAddress: mockDelegateAddress2, + }) + }) + + it('should fail if notification cleanup fails', async () => { + mockCleanupDelegateNotifications.mockResolvedValue({ + success: false, + error: 'Network error during notification cleanup', + failedDelegates: [mockDelegateAddress1], + }) + + const result = await service.removeAllDelegatesForOwner(mockOwnerAddress, mockOwnerPrivateKey) + + expect(result.success).toBe(false) + expect(result.error?.type).toBe(ErrorType.CLEANUP_ERROR) + expect(result.error?.message).toBe('Network error during notification cleanup') + expect(result.error?.details?.failedDelegates).toEqual([mockDelegateAddress1]) + // Check the original error is a DelegateCleanupError + const originalError = result.error?.originalError as DelegateCleanupError + expect(originalError?.type).toBe(DelegateCleanupErrorType.NOTIFICATION_CLEANUP_FAILED) + + // Verify that subsequent phases were not executed + expect(mockRemoveDelegatesFromBackend).not.toHaveBeenCalled() + expect(mockCleanupDelegateKeychain).not.toHaveBeenCalled() + expect(mockConfig.dispatch).not.toHaveBeenCalled() + }) + + it('should continue with keychain cleanup even if backend removal fails', async () => { + mockRemoveDelegatesFromBackend.mockResolvedValue({ + success: false, + error: 'API error during backend removal', + failedDelegates: [mockDelegateAddress2], + }) + + const result = await service.removeAllDelegatesForOwner(mockOwnerAddress, mockOwnerPrivateKey) + + expect(result.success).toBe(false) + expect(result.error?.type).toBe(ErrorType.CLEANUP_ERROR) + expect(result.error?.message).toBe('API error during backend removal') + expect(result.error?.details?.failedDelegates).toEqual([mockDelegateAddress2]) + // Check the original error is a DelegateCleanupError + const originalError = result.error?.originalError as DelegateCleanupError + expect(originalError?.type).toBe(DelegateCleanupErrorType.BACKEND_REMOVAL_FAILED) + + // Verify that notification cleanup succeeded + expect(mockCleanupDelegateNotifications).toHaveBeenCalled() + + // Verify that keychain cleanup was still attempted + expect(mockCleanupDelegateKeychain).toHaveBeenCalledWith(mockOwnerAddress, mockDelegateAddresses) + + // Verify Redux store was still updated + expect(mockConfig.dispatch).toHaveBeenCalledTimes(2) + }) + + it('should update Redux store for all delegates', async () => { + await service.removeAllDelegatesForOwner(mockOwnerAddress, mockOwnerPrivateKey) + + expect(mockConfig.dispatch).toHaveBeenCalledTimes(2) + expect(mockRemoveDelegate).toHaveBeenCalledWith({ + ownerAddress: mockOwnerAddress, + delegateAddress: mockDelegateAddress1, + }) + expect(mockRemoveDelegate).toHaveBeenCalledWith({ + ownerAddress: mockOwnerAddress, + delegateAddress: mockDelegateAddress2, + }) + }) + + it('should handle unexpected errors during cleanup', async () => { + mockCleanupDelegateNotifications.mockRejectedValue(new Error('Unexpected error')) + + const result = await service.removeAllDelegatesForOwner(mockOwnerAddress, mockOwnerPrivateKey) + + expect(result.success).toBe(false) + expect(result.error?.type).toBe(ErrorType.SYSTEM_ERROR) + expect(result.error?.message).toBe('An unexpected error occurred during delegate cleanup') + expect(mockLogger.error).toHaveBeenCalledWith('Unexpected error during delegate cleanup', expect.any(Error)) + }) + + it('should create wallet with correct private key', async () => { + await service.removeAllDelegatesForOwner(mockOwnerAddress, mockOwnerPrivateKey) + + expect(Wallet).toHaveBeenCalledWith(mockOwnerPrivateKey) + }) + + it('should pass correct parameters to cleanup functions', async () => { + await service.removeAllDelegatesForOwner(mockOwnerAddress, mockOwnerPrivateKey) + + // Verify cleanup notifications + expect(mockCleanupDelegateNotifications).toHaveBeenCalledWith( + mockOwnerAddress, + mockDelegateAddresses, + mockConfig.cleanupNotificationsForDelegate, + ) + + // Verify backend removal + expect(mockRemoveDelegatesFromBackend).toHaveBeenCalledWith( + mockOwnerAddress, + mockDelegateAddresses, + expect.objectContaining({ signTypedData: expect.any(Function) }), + mockConfig.allChains, + mockConfig.deleteDelegate, + ) + + // Verify keychain cleanup + expect(mockCleanupDelegateKeychain).toHaveBeenCalledWith(mockOwnerAddress, mockDelegateAddresses) + }) + }) +}) diff --git a/apps/mobile/src/services/delegate-cleanup/DelegateCleanupService.ts b/apps/mobile/src/services/delegate-cleanup/DelegateCleanupService.ts new file mode 100644 index 0000000000..6518f4a993 --- /dev/null +++ b/apps/mobile/src/services/delegate-cleanup/DelegateCleanupService.ts @@ -0,0 +1,233 @@ +import { Address } from '@/src/types/address' +import { AppDispatch } from '@/src/store' +import { cgwApi } from '@safe-global/store/gateway/AUTO_GENERATED/delegates' +import { useNotificationCleanup } from '@/src/hooks/useNotificationCleanup' +import { + cleanupDelegateNotifications, + removeDelegatesFromBackend, + cleanupDelegateKeychain, +} from '@/src/hooks/useDelegateCleanup/utils' +import Logger from '@/src/utils/logger' +import { StandardErrorResult, ErrorType, createErrorResult, createSuccessResult } from '@/src/utils/errors' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import { DelegateInfo, removeDelegate } from '@/src/store/delegatesSlice' +import { Wallet } from 'ethers' + +// Enhanced error types for better error handling +export enum DelegateCleanupErrorType { + NOTIFICATION_CLEANUP_FAILED = 'NOTIFICATION_CLEANUP_FAILED', + BACKEND_REMOVAL_FAILED = 'BACKEND_REMOVAL_FAILED', + KEYCHAIN_CLEANUP_FAILED = 'KEYCHAIN_CLEANUP_FAILED', + ORCHESTRATION_FAILED = 'ORCHESTRATION_FAILED', + NO_DELEGATES_FOUND = 'NO_DELEGATES_FOUND', + INVALID_PARAMETERS = 'INVALID_PARAMETERS', +} + +export interface DelegateCleanupError { + type: DelegateCleanupErrorType + message: string + details?: { + failedDelegates?: Address[] + phase?: string + } +} + +// Progress tracking types +export enum DelegateCleanupPhase { + IDLE = 'IDLE', + VALIDATING = 'VALIDATING', + CLEANING_NOTIFICATIONS = 'CLEANING_NOTIFICATIONS', + REMOVING_FROM_BACKEND = 'REMOVING_FROM_BACKEND', + CLEANING_KEYCHAIN = 'CLEANING_KEYCHAIN', + UPDATING_STORE = 'UPDATING_STORE', + COMPLETED = 'COMPLETED', +} + +export interface DelegateCleanupProgress { + phase: DelegateCleanupPhase + message: string + completedDelegates?: number + totalDelegates?: number +} + +// Configuration interface for dependency injection +export interface DelegateCleanupConfig { + allChains: Chain[] + allDelegates: Record<Address, Record<string, DelegateInfo>> + cleanupNotificationsForDelegate: ReturnType<typeof useNotificationCleanup>['cleanupNotificationsForDelegate'] + deleteDelegate: ReturnType<typeof cgwApi.useDelegatesDeleteDelegateV2Mutation>[0] + dispatch: AppDispatch + onProgress?: (progress: DelegateCleanupProgress) => void +} + +export class DelegateCleanupService { + private config: DelegateCleanupConfig + + constructor(config: DelegateCleanupConfig) { + this.config = config + } + + async removeAllDelegatesForOwner( + ownerAddress: Address, + ownerPrivateKey: string, + ): Promise<StandardErrorResult<{ processedCount: number }>> { + try { + this.reportProgress(DelegateCleanupPhase.VALIDATING, 'Validating parameters and checking delegates...') + + // Validate parameters + if (!ownerAddress || !ownerPrivateKey) { + return createErrorResult(ErrorType.VALIDATION_ERROR, 'Owner address and private key are required', null, { + ownerAddress, + }) + } + + const delegates = this.config.allDelegates[ownerAddress] + + if (!delegates || Object.keys(delegates).length === 0) { + Logger.info('No delegates found for owner', { ownerAddress }) + this.reportProgress(DelegateCleanupPhase.COMPLETED, 'No delegates to clean up') + return createSuccessResult({ processedCount: 0 }) + } + + const delegateAddresses = Object.keys(delegates) as Address[] + Logger.info('Starting delegate cleanup process', { + ownerAddress, + delegateCount: delegateAddresses.length, + delegateAddresses, + }) + + // Create owner wallet for signing + const ownerWallet = new Wallet(ownerPrivateKey) + + // PHASE 1: Clean up notifications for all delegates + // This is critical and must succeed before we proceed + this.reportProgress( + DelegateCleanupPhase.CLEANING_NOTIFICATIONS, + 'Cleaning up notifications...', + 0, + delegateAddresses.length, + ) + Logger.info('Starting notification cleanup for delegates', { delegateAddresses }) + + const notificationCleanupResult = await cleanupDelegateNotifications( + ownerAddress, + delegateAddresses, + this.config.cleanupNotificationsForDelegate, + ) + + if (!notificationCleanupResult.success) { + Logger.error('Notification cleanup failed, aborting delegate removal', notificationCleanupResult.error) + + const cleanupError: DelegateCleanupError = { + type: DelegateCleanupErrorType.NOTIFICATION_CLEANUP_FAILED, + message: notificationCleanupResult.error || 'Failed to cleanup notifications', + details: { + failedDelegates: notificationCleanupResult.failedDelegates, + phase: 'notification', + }, + } + + return createErrorResult(ErrorType.CLEANUP_ERROR, cleanupError.message, cleanupError, { + ownerAddress, + failedDelegates: cleanupError.details?.failedDelegates, + }) + } + + // PHASE 2: Remove delegates from backend transaction service + this.reportProgress( + DelegateCleanupPhase.REMOVING_FROM_BACKEND, + 'Removing delegates from backend...', + 0, + delegateAddresses.length, + ) + Logger.info('Starting backend delegate removal', { delegateAddresses }) + + const backendRemovalResult = await removeDelegatesFromBackend( + ownerAddress, + delegateAddresses, + ownerWallet, + this.config.allChains, + this.config.deleteDelegate, + ) + + // PHASE 3: Clean up keychain (always attempt, even if backend removal failed) + this.reportProgress( + DelegateCleanupPhase.CLEANING_KEYCHAIN, + 'Cleaning up keychain...', + 0, + delegateAddresses.length, + ) + Logger.info('Starting keychain cleanup', { delegateAddresses }) + + await cleanupDelegateKeychain(ownerAddress, delegateAddresses) + + // PHASE 4: Update Redux store (remove delegates that were successfully processed) + this.reportProgress( + DelegateCleanupPhase.UPDATING_STORE, + 'Updating application state...', + 0, + delegateAddresses.length, + ) + Logger.info('Updating Redux store', { delegateAddresses }) + + delegateAddresses.forEach((delegateAddress) => { + this.config.dispatch(removeDelegate({ ownerAddress, delegateAddress })) + }) + + // Check if any critical errors occurred + if (!backendRemovalResult.success) { + const cleanupError: DelegateCleanupError = { + type: DelegateCleanupErrorType.BACKEND_REMOVAL_FAILED, + message: backendRemovalResult.error || 'Failed to remove delegates from backend', + details: { + failedDelegates: backendRemovalResult.failedDelegates, + phase: 'backend', + }, + } + + return createErrorResult(ErrorType.CLEANUP_ERROR, cleanupError.message, cleanupError, { + ownerAddress, + failedDelegates: cleanupError.details?.failedDelegates, + }) + } + + Logger.info('Delegate cleanup completed successfully', { + ownerAddress, + delegateCount: delegateAddresses.length, + }) + + this.reportProgress( + DelegateCleanupPhase.COMPLETED, + `Successfully cleaned up ${delegateAddresses.length} delegates`, + delegateAddresses.length, + delegateAddresses.length, + ) + + return createSuccessResult({ processedCount: delegateAddresses.length }) + } catch (error) { + Logger.error('Unexpected error during delegate cleanup', error) + + return createErrorResult(ErrorType.SYSTEM_ERROR, 'An unexpected error occurred during delegate cleanup', error, { + ownerAddress, + }) + } + } + + private reportProgress( + phase: DelegateCleanupPhase, + message: string, + completedDelegates?: number, + totalDelegates?: number, + ) { + const progress: DelegateCleanupProgress = { + phase, + message, + completedDelegates, + totalDelegates, + } + + if (this.config.onProgress) { + this.config.onProgress(progress) + } + } +} diff --git a/apps/mobile/src/services/delegate-cleanup/index.ts b/apps/mobile/src/services/delegate-cleanup/index.ts new file mode 100644 index 0000000000..9be478428f --- /dev/null +++ b/apps/mobile/src/services/delegate-cleanup/index.ts @@ -0,0 +1 @@ +export * from './DelegateCleanupService' diff --git a/apps/mobile/src/services/key-storage/index.ts b/apps/mobile/src/services/key-storage/index.ts new file mode 100644 index 0000000000..0628f42f14 --- /dev/null +++ b/apps/mobile/src/services/key-storage/index.ts @@ -0,0 +1,9 @@ +import { KeyStorageService } from './key-storage.service' +import { WalletService } from './wallet.service' +import { IKeyStorageService, PrivateKeyStorageOptions } from './types' +import { IWalletService } from './wallet.service' + +export { KeyStorageService, WalletService, type IKeyStorageService, type IWalletService, type PrivateKeyStorageOptions } + +export const keyStorageService = new KeyStorageService() +export const walletService = new WalletService() diff --git a/apps/mobile/src/services/key-storage/key-storage.service.ts b/apps/mobile/src/services/key-storage/key-storage.service.ts new file mode 100644 index 0000000000..0ad807675c --- /dev/null +++ b/apps/mobile/src/services/key-storage/key-storage.service.ts @@ -0,0 +1,174 @@ +import DeviceCrypto from 'react-native-device-crypto' +import * as Keychain from 'react-native-keychain' +import DeviceInfo from 'react-native-device-info' +import { IKeyStorageService, PrivateKeyStorageOptions } from './types' +import Logger from '@/src/utils/logger' +import { Platform } from 'react-native' + +export class KeyStorageService implements IKeyStorageService { + private readonly BIOMETRIC_PROMPTS = { + SKIP: { + biometryTitle: '', + biometrySubTitle: '', + biometryDescription: '', + }, + STANDARD: { + biometryTitle: 'Authenticate', + biometrySubTitle: 'Signing', + biometryDescription: 'Authenticate yourself to sign the transactions', + }, + SAVE: { + biometryTitle: 'Authenticate', + biometrySubTitle: 'Saving key', + biometryDescription: 'Please authenticate yourself', + }, + } + + async storePrivateKey( + userId: string, + privateKey: string, + options: PrivateKeyStorageOptions = { requireAuthentication: true }, + ): Promise<void> { + try { + const { requireAuthentication = true } = options + const isEmulator = await DeviceInfo.isEmulator() + await this.storeKey(userId, privateKey, requireAuthentication, isEmulator) + } catch (err) { + Logger.error('Error storing private key:', err) + throw new Error('Failed to store private key') + } + } + + async getPrivateKey( + userId: string, + options: PrivateKeyStorageOptions = { requireAuthentication: true }, + ): Promise<string | undefined> { + try { + return await this.getKey(userId, options.requireAuthentication ?? true) + } catch (err) { + Logger.error('Error getting private key:', err) + return undefined + } + } + + async removePrivateKey( + userId: string, + options: PrivateKeyStorageOptions = { requireAuthentication: true }, + ): Promise<void> { + try { + const { requireAuthentication = true } = options + await this.removeKey(userId, requireAuthentication) + } catch (err) { + Logger.error('Error removing private key:', err instanceof Error ? err.message : 'Unknown error') + throw new Error('Failed to remove private key') + } + } + + private getKeyNameDeviceCrypto(userId: string): string { + return `signer_address_${userId}` + } + + private getKeyService(userId: string): string { + return `${this.getKeyNameDeviceCrypto(userId)}_encrypted_storage` + } + + private async getOrCreateKeyIOS(keyName: string, requireAuth: boolean, isEmulator: boolean): Promise<string> { + try { + await DeviceCrypto.getOrCreateAsymmetricKey(keyName, { + accessLevel: requireAuth ? (isEmulator ? 1 : 2) : 1, + invalidateOnNewBiometry: requireAuth, + }) + + return keyName + } catch (error) { + Logger.error('Error creating key:', error instanceof Error ? error.message : 'Unknown error') + throw new Error('Failed to create encryption key') + } + } + + /** + * The android implementation of the device-crypto diverges from the iOS implementation + * On Android, the encrypt function expects a symmetric key, while on iOS it expects an asymmetric key. + */ + private async getOrCreateKeyAndroid(keyName: string, requireAuth: boolean, isEmulator: boolean): Promise<void> { + try { + await DeviceCrypto.getOrCreateSymmetricKey(keyName, { + accessLevel: requireAuth ? (isEmulator ? 1 : 2) : 1, + invalidateOnNewBiometry: requireAuth, + }) + } catch (error) { + Logger.error('Error creating symmetric encryption key:', error instanceof Error ? error.message : 'Unknown error') + throw new Error('Failed to create symmetric key') + } + } + + private async storeKey(userId: string, privateKey: string, requireAuth: boolean, isEmulator: boolean): Promise<void> { + const keyName = this.getKeyNameDeviceCrypto(userId) + + if (Platform.OS === 'android') { + await this.getOrCreateKeyAndroid(keyName, requireAuth, isEmulator) + } else { + await this.getOrCreateKeyIOS(keyName, requireAuth, isEmulator) + } + + const encryptedPrivateKey = await DeviceCrypto.encrypt(keyName, privateKey, this.BIOMETRIC_PROMPTS.SAVE) + + await Keychain.setGenericPassword( + 'signer_address', + JSON.stringify({ + encryptedPassword: encryptedPrivateKey.encryptedText, + iv: encryptedPrivateKey.iv, + }), + { accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY, service: this.getKeyService(userId) }, + ) + } + + private async getKey(userId: string, requireAuth: boolean): Promise<string> { + const keyName = this.getKeyNameDeviceCrypto(userId) + + const keychainOptions: Keychain.GetOptions = { service: this.getKeyService(userId) } + if (requireAuth) { + keychainOptions.accessControl = Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE + } + + const result = await Keychain.getGenericPassword(keychainOptions) + if (!result) { + throw 'user password not found' + } + + const { encryptedPassword, iv } = JSON.parse(result.password) + + return DeviceCrypto.decrypt(keyName, encryptedPassword, iv, this.BIOMETRIC_PROMPTS.STANDARD) + } + + private async removeKey(userId: string, requireAuth: boolean): Promise<void> { + const keyName = this.getKeyNameDeviceCrypto(userId) + const service = this.getKeyService(userId) + + // First, try to delete from keychain (requires authentication if enabled) + const keychainOptions: Keychain.GetOptions = { service } + if (requireAuth) { + keychainOptions.accessControl = Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE + } + + try { + // Check if the key exists in keychain + const result = await Keychain.getGenericPassword(keychainOptions) + if (result) { + // Delete from keychain + await Keychain.resetGenericPassword({ service }) + } + } catch (error) { + // If key doesn't exist, that's fine - we still want to try to remove from device crypto + Logger.warn('Key not found in keychain or authentication failed:', error) + } + + // Try to remove the encryption key from device crypto + try { + await DeviceCrypto.deleteKey(keyName) + } catch (error) { + // If the key doesn't exist in device crypto, that's acceptable + Logger.warn('Key not found in device crypto:', error) + } + } +} diff --git a/apps/mobile/src/services/key-storage/types.ts b/apps/mobile/src/services/key-storage/types.ts new file mode 100644 index 0000000000..62882d4c67 --- /dev/null +++ b/apps/mobile/src/services/key-storage/types.ts @@ -0,0 +1,9 @@ +export type PrivateKeyStorageOptions = { + requireAuthentication?: boolean +} + +export interface IKeyStorageService { + storePrivateKey(userId: string, privateKey: string, options?: PrivateKeyStorageOptions): Promise<void> + getPrivateKey(userId: string, options?: PrivateKeyStorageOptions): Promise<string | undefined> + removePrivateKey(userId: string, options?: PrivateKeyStorageOptions): Promise<void> +} diff --git a/apps/mobile/src/services/key-storage/wallet.service.ts b/apps/mobile/src/services/key-storage/wallet.service.ts new file mode 100644 index 0000000000..54aa6c018c --- /dev/null +++ b/apps/mobile/src/services/key-storage/wallet.service.ts @@ -0,0 +1,21 @@ +import { Wallet, HDNodeWallet } from 'ethers' +import Logger from '@/src/utils/logger' + +export interface IWalletService { + createMnemonicAccount(mnemonic: string): Promise<HDNodeWallet | undefined> +} + +export class WalletService implements IWalletService { + async createMnemonicAccount(mnemonic: string): Promise<HDNodeWallet | undefined> { + try { + if (!mnemonic) { + return + } + + return Wallet.fromPhrase(mnemonic) + } catch (err) { + Logger.error('CreateMnemonicAccountFailed', err) + return undefined + } + } +} diff --git a/apps/mobile/src/services/notifications/FCMService.ts b/apps/mobile/src/services/notifications/FCMService.ts index 00bcf4ddc6..2cb1b55a43 100644 --- a/apps/mobile/src/services/notifications/FCMService.ts +++ b/apps/mobile/src/services/notifications/FCMService.ts @@ -1,15 +1,11 @@ -import messaging, { FirebaseMessagingTypes } from '@react-native-firebase/messaging' +import { getMessaging } from '@react-native-firebase/messaging' import Logger from '@/src/utils/logger' -import NotificationsService from './NotificationService' -import { ChannelId } from '@/src/utils/notifications' -import { store } from '@/src/store' import { savePushToken } from '@/src/store/notificationsSlice' - -type UnsubscribeFunc = () => void +import { getStore } from '@/src/store/utils/singletonStore' class FCMService { async getFCMToken(): Promise<string | undefined> { - const { fcmToken } = store.getState().notifications + const { fcmToken } = getStore().getState().notifications const token = fcmToken || undefined if (!token) { Logger.info('getFCMToken: No FCM token found') @@ -19,48 +15,24 @@ class FCMService { async saveFCMToken(): Promise<void> { try { - const fcmToken = await messaging().getToken() + const fcmToken = await getMessaging().getToken() + Logger.info('FCMService :: fcmToken', fcmToken) if (fcmToken) { - store.dispatch(savePushToken(fcmToken)) + getStore().dispatch(savePushToken(fcmToken)) } } catch (error) { Logger.info('FCMService :: error saving', error) } } - listenForMessagesForeground = (): UnsubscribeFunc => - messaging().onMessage(async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { - NotificationsService.displayNotification({ - channelId: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, - title: remoteMessage.notification?.title || '', - body: remoteMessage.notification?.body || '', - data: remoteMessage.data, - }) - Logger.trace('listenForMessagesForeground: listening for messages in Foreground', remoteMessage) - }) - - listenForMessagesBackground = (): void => { - messaging().setBackgroundMessageHandler(async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { - NotificationsService.displayNotification({ - channelId: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, - title: remoteMessage.notification?.title || '', - body: remoteMessage.notification?.body || '', - data: remoteMessage.data, - }) - Logger.trace('listenForMessagesBackground :: listening for messages in background', remoteMessage) - }) - } - - async registerAppWithFCM(): Promise<void> { - if (!messaging().registerDeviceForRemoteMessages) { - await messaging() - .registerDeviceForRemoteMessages() - .then((status: unknown) => { - Logger.info('registerDeviceForRemoteMessages status', status) - }) - .catch((error) => { - Logger.error('registerAppWithFCM: Something went wrong', error) - }) + async initNotification(): Promise<string | undefined> { + try { + await this.saveFCMToken() + const fcmToken = await this.getFCMToken() + return fcmToken + } catch (error) { + Logger.error('initNotification: Something went wrong', error) + return undefined } } } diff --git a/apps/mobile/src/services/notifications/NotificationService.ts b/apps/mobile/src/services/notifications/NotificationService.ts index 317ab6c5b8..033d57613a 100644 --- a/apps/mobile/src/services/notifications/NotificationService.ts +++ b/apps/mobile/src/services/notifications/NotificationService.ts @@ -1,28 +1,32 @@ import notifee, { - AuthorizationStatus, Event as NotifeeEvent, EventType, EventDetail, AndroidChannel, + AuthorizationStatus, + AndroidImportance, + AndroidVisibility, } from '@notifee/react-native' +import { parseNotification } from './notificationParser' +import { FirebaseMessagingTypes } from '@react-native-firebase/messaging' import { Linking, Platform, Alert as NativeAlert } from 'react-native' -import { store } from '@/src/store' import { updatePromptAttempts, updateLastTimePromptAttempted } from '@/src/store/notificationsSlice' import { toggleAppNotifications, toggleDeviceNotifications } from '@/src/store/notificationsSlice' - import { HandleNotificationCallback, LAUNCH_ACTIVITY, PressActionId } from '@/src/store/constants' +import { getMessaging } from '@react-native-firebase/messaging' +import { NotificationNavigationHandler } from './notificationNavigationHandler' import { ChannelId, notificationChannels, withTimeout } from '@/src/utils/notifications' import Logger from '@/src/utils/logger' - -import { FirebaseMessagingTypes } from '@react-native-firebase/messaging' -import { router } from 'expo-router' +import { getStore } from '@/src/store/utils/singletonStore' interface AlertButton { text: string onPress: () => void | Promise<void> } +type UnsubscribeFunc = () => void + class NotificationsService { async getBlockedNotifications(): Promise<Map<ChannelId, boolean>> { try { @@ -50,43 +54,91 @@ class NotificationsService { } } - async getAllPermissions(shouldOpenSettings = true) { + enableNotifications() { + try { + getStore().dispatch(toggleDeviceNotifications(true)) + getStore().dispatch(toggleAppNotifications(true)) + getStore().dispatch(updatePromptAttempts(0)) + getStore().dispatch(updateLastTimePromptAttempted(0)) + } catch (error) { + Logger.error('Error checking if a user has push notifications permission', error) + } + } + + async getAllPermissions(shouldOpenSettings = false) { try { const promises: Promise<string>[] = notificationChannels.map((channel: AndroidChannel) => withTimeout(this.createChannel(channel), 5000), ) // 1 - Creates android's notifications channel await Promise.allSettled(promises) - // 2 - Verifies granted permission from device - let permission = await withTimeout(this.checkCurrentPermissions(), 5000) - // 3 - Verifies blocked notifications + const { authorizationStatus } = await notifee.requestPermission() + // 2 - Verifies blocked notifications const blockedNotifications = await withTimeout(this.getBlockedNotifications(), 5000) /** - * 4 - If permission has not being granted already or blocked notifications are found, open device's settings - * so that user can enable DEVICE notifications + * 3 - If permission has not being granted already or blocked notifications are found, open device's settings + * so that user can enable DEVICE notifications, but ONLY if explicitly requested via shouldOpenSettings **/ - if ((permission !== 'authorized' || blockedNotifications.size !== 0) && shouldOpenSettings) { - await this.requestPushNotificationsPermission() - permission = await withTimeout(this.checkCurrentPermissions(), 5000) + if (shouldOpenSettings && authorizationStatus === AuthorizationStatus.DENIED) { + const settings = await notifee.getNotificationSettings() + + if ( + settings.authorizationStatus === AuthorizationStatus.NOT_DETERMINED || + settings.authorizationStatus === AuthorizationStatus.DENIED + ) { + await this.openDeviceSettings() + } } - return { permission, blockedNotifications } - } catch (error) { - Logger.error('Error occurred while fetching permissions:', error) - return { permission: 'denied', blockedNotifications: new Set() } + // 4 - Check if the user has enabled device notifications + const permission = await withTimeout(this.checkCurrentPermissions(), 5000) + + return { + permission, + blockedNotifications, + } + } catch (error) { + Logger.error('Error checking if a user has push notifications permission', error) + return { + permission: 'denied', + blockedNotifications: new Map<ChannelId, boolean>(), + } } } async isDeviceNotificationEnabled() { - const permission = await notifee.getNotificationSettings() + const settings = await notifee.getNotificationSettings() const isAuthorized = - permission.authorizationStatus === AuthorizationStatus.AUTHORIZED || - permission.authorizationStatus === AuthorizationStatus.PROVISIONAL + settings.authorizationStatus === AuthorizationStatus.AUTHORIZED || + settings.authorizationStatus === AuthorizationStatus.PROVISIONAL return isAuthorized } + async getAuthorizationStatus() { + const settings = await notifee.getNotificationSettings() + return settings.authorizationStatus + } + + async isAuthorizationDenied() { + const status = await this.getAuthorizationStatus() + return status === AuthorizationStatus.DENIED + } + + async openDeviceSettings() { + await notifee.requestPermission() + try { + if (Platform.OS === 'ios') { + Linking.openURL('app-settings:') + } else { + Linking.openSettings() + } + } catch (error) { + Logger.error('Error checking if a user has push notifications permission', error) + } + } + defaultButtons = (resolve: (value: boolean) => void): AlertButton[] => [ { text: 'Maybe later', @@ -95,23 +147,15 @@ class NotificationsService { * When user decides to NOT enable notifications, we should register the number of attempts and its dates * so we avoid to prompt the user again within a month given a maximum of 3 attempts */ - store.dispatch(updatePromptAttempts(1)) - store.dispatch(updateLastTimePromptAttempted(Date.now())) - router.navigate('/(tabs)') - + getStore().dispatch(updatePromptAttempts(1)) + getStore().dispatch(updateLastTimePromptAttempted(Date.now())) resolve(false) }, }, { text: 'Turn on', onPress: async () => { - store.dispatch(toggleDeviceNotifications(true)) - store.dispatch(toggleAppNotifications(true)) - store.dispatch(updatePromptAttempts(0)) - store.dispatch(updateLastTimePromptAttempted(0)) - - await notifee.requestPermission() - this.openSystemSettings() + await this.openDeviceSettings() resolve(true) }, }, @@ -139,20 +183,16 @@ class NotificationsService { } } - openSystemSettings() { - if (Platform.OS === 'ios') { - Linking.openSettings() - } else { - notifee.openNotificationSettings() - } - } - async checkCurrentPermissions() { const settings = await notifee.getNotificationSettings() - return settings.authorizationStatus === AuthorizationStatus.AUTHORIZED || + + const isAuthorized = + settings.authorizationStatus === AuthorizationStatus.AUTHORIZED || settings.authorizationStatus === AuthorizationStatus.PROVISIONAL - ? 'authorized' - : 'denied' + ? 'granted' + : 'denied' + + return isAuthorized } onForegroundEvent(observer: (event: NotifeeEvent) => Promise<void>): () => void { @@ -164,19 +204,35 @@ class NotificationsService { } async incrementBadgeCount(incrementBy?: number) { - return await notifee.incrementBadgeCount(incrementBy) + await notifee.incrementBadgeCount(incrementBy) + const newCount = await notifee.getBadgeCount() + Logger.info(`Badge incremented by ${incrementBy || 1}, new count: ${newCount}`) } async decrementBadgeCount(decrementBy?: number) { - return await notifee.decrementBadgeCount(decrementBy) + await notifee.decrementBadgeCount(decrementBy) + const newCount = await notifee.getBadgeCount() + Logger.info(`Badge decremented by ${decrementBy || 1}, new count: ${newCount}`) } async setBadgeCount(count: number) { - return await notifee.setBadgeCount(count) + await notifee.setBadgeCount(count) + Logger.info(`Badge count set to: ${count}`) } async getBadgeCount() { - return await notifee.getBadgeCount() + const count = await notifee.getBadgeCount() + Logger.info(`Current badge count: ${count}`) + return count + } + + async clearAllBadges() { + try { + await this.setBadgeCount(0) + Logger.info('All badges cleared manually') + } catch (error) { + Logger.error('Failed to clear badges manually', error) + } } async handleNotificationPress({ @@ -186,12 +242,17 @@ class NotificationsService { detail: EventDetail callback?: (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => void }) { - this.decrementBadgeCount(1) + await this.clearAllBadges() + if (detail?.notification?.id) { await this.cancelTriggerNotification(detail.notification.id) } if (detail?.notification?.data) { + await NotificationNavigationHandler.handleNotificationPress( + detail.notification.data as FirebaseMessagingTypes.RemoteMessage['data'], + ) + callback?.(detail.notification as FirebaseMessagingTypes.RemoteMessage) } } @@ -255,9 +316,10 @@ class NotificationsService { body, data, android: { - smallIcon: 'ic_notification_small', - largeIcon: 'ic_notification', channelId: channelId ?? ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, + importance: AndroidImportance.HIGH, + visibility: AndroidVisibility.PUBLIC, + smallIcon: 'ic_notification', pressAction: { id: PressActionId.OPEN_NOTIFICATIONS_VIEW, launchActivity: LAUNCH_ACTIVITY, @@ -277,9 +339,66 @@ class NotificationsService { }, }) } catch (error) { - Logger.error('NotificationService.displayNotification :: error', error) + Logger.info('NotificationService.displayNotification :: error', error) } } + + /** + * Initializes all notification handlers + */ + initializeNotificationHandlers(): void { + // Core Firebase handlers + this.listenForMessagesForeground() // FCM foreground messages + this.registerFirebaseNotificationOpenedHandler() // App opened from notification + + Logger.info('NotificationService: Successfully initialized simplified notification handlers') + } + + private listenForMessagesForeground = (): UnsubscribeFunc => { + return getMessaging().onMessage(async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { + const parsed = parseNotification(remoteMessage.data) + this.displayNotification({ + channelId: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, + title: parsed?.title || remoteMessage.notification?.title || '', + body: parsed?.body || remoteMessage.notification?.body || '', + data: remoteMessage.data, + }) + Logger.info('listenForMessagesForeground: listening for messages in Foreground', remoteMessage) + }) + } + + /** + * Registers Firebase messaging handlers for when app is opened from notification + */ + private registerFirebaseNotificationOpenedHandler(): void { + // Handle notification opened app when app is in background + getMessaging().onNotificationOpenedApp(async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { + Logger.info('Notification caused app to open from background state:', remoteMessage) + + await this.clearAllBadges() + + if (remoteMessage.data) { + await NotificationNavigationHandler.handleNotificationPress(remoteMessage.data) + } + }) + + // Handle notification opened app when app was quit + getMessaging() + .getInitialNotification() + .then(async (remoteMessage: FirebaseMessagingTypes.RemoteMessage | null) => { + if (remoteMessage) { + Logger.info('Notification caused app to open from quit state:', remoteMessage) + if (remoteMessage.data) { + // Add extra delay for app startup from killed state + setTimeout(async () => { + // Clear badge when app is opened from notification + await this.clearAllBadges() + await NotificationNavigationHandler.handleNotificationPress(remoteMessage.data) + }, 1000) // Wait 1 second for app to fully initialize + } + } + }) + } } export default new NotificationsService() diff --git a/apps/mobile/src/services/notifications/SubscriptionManager.ts b/apps/mobile/src/services/notifications/SubscriptionManager.ts new file mode 100644 index 0000000000..79da0acca8 --- /dev/null +++ b/apps/mobile/src/services/notifications/SubscriptionManager.ts @@ -0,0 +1 @@ +export { registerSafe as subscribeSafe, unregisterSafe as unsubscribeSafe } from './registration' diff --git a/apps/mobile/src/services/notifications/__tests__/backend.test.ts b/apps/mobile/src/services/notifications/__tests__/backend.test.ts new file mode 100644 index 0000000000..d7731c666c --- /dev/null +++ b/apps/mobile/src/services/notifications/__tests__/backend.test.ts @@ -0,0 +1,274 @@ +import { Wallet } from 'ethers' +import { SiweMessage } from 'siwe' +import DeviceInfo from 'react-native-device-info' + +import { NOTIFICATION_ACCOUNT_TYPE } from '@/src/store/constants' +import { + authenticateSigner, + registerForNotificationsOnBackEnd, + unregisterForNotificationsOnBackEnd, + getDeviceUuid, +} from '../backend' + +// Mock modules +jest.mock('react-native-device-info') +jest.mock('@/src/utils/uuid') +jest.mock('@/src/config/constants', () => ({ + isAndroid: false, + GATEWAY_URL: 'https://safe-client.staging.5afe.dev', +})) +jest.mock('@/src/utils/logger') + +// Add MSW handlers for the API endpoints +import { server } from '@/src/tests/server' +import { http, HttpResponse } from 'msw' + +// Mock the store +const mockStore = { + dispatch: jest.fn(), +} + +jest.mock('@/src/store/utils/singletonStore', () => ({ + getStore: () => mockStore, +})) + +// Mock SiweMessage +jest.mock('siwe', () => { + return { + SiweMessage: jest.fn().mockImplementation(function ( + this: { prepareMessage: () => string }, + params: Record<string, unknown>, + ) { + this.prepareMessage = () => 'prepared-siwe-message' + Object.assign(this, params) + }), + } +}) + +// Mock dependencies +const mockDeviceInfo = DeviceInfo as jest.Mocked<typeof DeviceInfo> +const mockConvertToUuid = require('@/src/utils/uuid').convertToUuid as jest.MockedFunction< + typeof import('@/src/utils/uuid').convertToUuid +> +const mockLogger = require('@/src/utils/logger').default as jest.Mocked<typeof import('@/src/utils/logger').default> + +describe('backend notification functions', () => { + const mockSigner = { + address: '0x1234567890123456789012345678901234567890', + signMessage: jest.fn().mockResolvedValue('signed-message'), + } as unknown as Wallet + + const mockChainId = '1' + const mockDeviceId = 'test-device-id' + const mockDeviceUuid = 'test-device-uuid' + const mockSafeAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef' + const mockFcmToken = 'test-fcm-token' + + beforeEach(() => { + jest.clearAllMocks() + + // Setup default mocks + mockDeviceInfo.getUniqueId.mockResolvedValue(mockDeviceId) + mockConvertToUuid.mockReturnValue(mockDeviceUuid) + + // Add MSW handlers for auth and notification endpoints + server.use( + http.post('https://safe-client.staging.5afe.dev/v1/auth/verify', () => { + return HttpResponse.json({}, { status: 200 }) + }), + http.post('https://safe-client.staging.5afe.dev/v2/register/notifications', () => { + return HttpResponse.json({}, { status: 201 }) + }), + http.delete('https://safe-client.staging.5afe.dev/v2/notifications/subscriptions', () => { + return HttpResponse.json({}, { status: 200 }) + }), + ) + + // Mock store dispatch to verify calls + const mockUnwrap = jest.fn().mockResolvedValue({}) + mockStore.dispatch.mockImplementation((action) => ({ + unwrap: mockUnwrap, + ...action, + })) + }) + + describe('getDeviceUuid', () => { + it('should get device UUID', async () => { + const result = await getDeviceUuid() + + expect(mockDeviceInfo.getUniqueId).toHaveBeenCalled() + expect(mockConvertToUuid).toHaveBeenCalledWith(mockDeviceId) + expect(result).toBe(mockDeviceUuid) + }) + }) + + describe('authenticateSigner', () => { + it('should authenticate a signer successfully', async () => { + await authenticateSigner(mockSigner, mockChainId) + + // Check the signature was requested with the prepared message + expect(mockSigner.signMessage).toHaveBeenCalledWith('prepared-siwe-message') + + // Check that store.dispatch was called for verification + expect(mockStore.dispatch).toHaveBeenCalledTimes(1) + + // Check that SiweMessage constructor was called with the correct parameters + expect(SiweMessage).toHaveBeenCalledWith({ + address: mockSigner.address, + chainId: Number(mockChainId), + domain: 'global.safe.mobileapp', + statement: 'Safe Wallet wants you to sign in with your Ethereum account', + nonce: expect.any(String), // Nonce is dynamic + uri: 'https://safe.global', + version: '1', + issuedAt: expect.any(String), + }) + + expect(mockLogger.info).toHaveBeenCalledWith('Authenticated signer', { signerAddress: mockSigner.address }) + }) + + it('should return early if signer is null', async () => { + await authenticateSigner(null, mockChainId) + + expect(mockStore.dispatch).not.toHaveBeenCalled() + }) + }) + + describe('registerForNotificationsOnBackEnd', () => { + it('should register for notifications as owner', async () => { + await registerForNotificationsOnBackEnd({ + safeAddress: mockSafeAddress, + signer: mockSigner, + chainIds: [mockChainId], + fcmToken: mockFcmToken, + notificationAccountType: NOTIFICATION_ACCOUNT_TYPE.OWNER, + }) + + // Verify authentication was called + expect(mockSigner.signMessage).toHaveBeenCalled() + + // Verify registration dispatch was called + expect(mockStore.dispatch).toHaveBeenCalled() + }) + + it('should register for notifications as regular user', async () => { + await registerForNotificationsOnBackEnd({ + safeAddress: mockSafeAddress, + signer: mockSigner, + chainIds: [mockChainId], + fcmToken: mockFcmToken, + notificationAccountType: NOTIFICATION_ACCOUNT_TYPE.REGULAR, + }) + + expect(mockStore.dispatch).toHaveBeenCalled() + }) + }) + + describe('unregisterForNotificationsOnBackEnd', () => { + it('should unregister notifications successfully', async () => { + const chainIds = ['1', '5'] + + await unregisterForNotificationsOnBackEnd({ + signer: mockSigner, + safeAddress: mockSafeAddress, + chainIds, + }) + + // Verify authentication was called + expect(mockSigner.signMessage).toHaveBeenCalled() + + // Verify unregistration dispatch was called + expect(mockStore.dispatch).toHaveBeenCalled() + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Unregistering notifications for subscriptions', + expect.objectContaining({ + deleteAllSubscriptionsDto: { + subscriptions: chainIds.map((chainId) => ({ + chainId, + deviceUuid: mockDeviceUuid, + safeAddress: mockSafeAddress, + })), + }, + }), + ) + }) + + it('should return early if no chainIds provided', async () => { + await unregisterForNotificationsOnBackEnd({ + signer: mockSigner, + safeAddress: mockSafeAddress, + chainIds: [], + }) + + expect(mockLogger.warn).toHaveBeenCalledWith('No chainIds provided for unregistering notifications', { + safeAddress: mockSafeAddress, + }) + }) + + it('should handle 404 errors gracefully', async () => { + // Mock the store dispatch to simulate RTK Query 404 error for unregister call + const error404 = { status: 404 } + const mockUnwrap = jest + .fn() + .mockResolvedValueOnce({}) // First call for auth succeeds + .mockRejectedValue(error404) // Second call returns 404 + + mockStore.dispatch.mockImplementation((action) => ({ + unwrap: mockUnwrap, + ...action, + })) + + await unregisterForNotificationsOnBackEnd({ + signer: mockSigner, + safeAddress: mockSafeAddress, + chainIds: [mockChainId], + }) + + expect(mockLogger.info).toHaveBeenCalledWith('Safe was already unsubscribed from notifications', { + safeAddress: mockSafeAddress, + chainIds: [mockChainId], + }) + }) + + it('should throw other errors', async () => { + // Mock the store dispatch to simulate RTK Query error for unregister call + const error = new Error('Network error') + const mockUnwrap = jest + .fn() + .mockResolvedValueOnce({}) // First call for auth succeeds + .mockRejectedValue(error) // Second call throws error + + mockStore.dispatch.mockImplementation((action) => ({ + unwrap: mockUnwrap, + ...action, + })) + + await expect( + unregisterForNotificationsOnBackEnd({ + signer: mockSigner, + safeAddress: mockSafeAddress, + chainIds: [mockChainId], + }), + ).rejects.toThrow('Network error') + + expect(mockLogger.error).toHaveBeenCalledWith('Failed to unregister notifications', { + error, + safeAddress: mockSafeAddress, + chainIds: [mockChainId], + }) + }) + + it('should throw error if deviceUuid is missing', async () => { + mockConvertToUuid.mockReturnValue('') + + await expect( + unregisterForNotificationsOnBackEnd({ + signer: mockSigner, + safeAddress: mockSafeAddress, + chainIds: [mockChainId], + }), + ).rejects.toThrow('Missing required parameters for unregistering notifications') + }) + }) +}) diff --git a/apps/mobile/src/services/notifications/__tests__/notificationNavigationHandler.test.ts b/apps/mobile/src/services/notifications/__tests__/notificationNavigationHandler.test.ts new file mode 100644 index 0000000000..f367e439fc --- /dev/null +++ b/apps/mobile/src/services/notifications/__tests__/notificationNavigationHandler.test.ts @@ -0,0 +1,270 @@ +/** + * Unit tests for NotificationNavigationHandler + * This test file is isolated and mocks all dependencies to avoid complex setup issues + */ +import { NotificationType } from '@safe-global/store/gateway/AUTO_GENERATED/notifications' +import { Address } from '@/src/types/address' + +// Define types for test data +interface MockActiveSafeAction { + type: string + payload: { + address: Address + chainId: string + } +} + +interface TestNotificationData { + type: NotificationType | string + chainId: string + address: string + safeTxHash?: string +} + +describe('NotificationNavigationHandler', () => { + // Mock all external dependencies + const mockRouter = { + push: jest.fn(), + canGoBack: jest.fn().mockReturnValue(true), + dismissAll: jest.fn(), + } + + const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } + + const mockStore = { + getState: jest.fn(), + dispatch: jest.fn(), + } + + const mockSelectAllSafes = jest.fn() + const mockSetActiveSafe = jest.fn( + (payload: { address: Address; chainId: string }): MockActiveSafeAction => ({ + type: 'activeSafe/setActiveSafe', + payload, + }), + ) + + // Set up mocks before importing the module + beforeAll(() => { + jest.doMock('expo-router', () => ({ + router: mockRouter, + })) + + jest.doMock('@/src/utils/logger', () => ({ + __esModule: true, + default: mockLogger, + })) + + jest.doMock('@/src/store/utils/singletonStore', () => ({ + getStore: () => mockStore, + })) + + jest.doMock('@/src/store/activeSafeSlice', () => ({ + setActiveSafe: mockSetActiveSafe, + })) + + jest.doMock('@/src/store/safesSlice', () => ({ + selectAllSafes: mockSelectAllSafes, + })) + + // Mock the setTimeout to resolve immediately in tests + global.setTimeout = jest.fn((callback: () => void) => { + callback() + return 1 as unknown as NodeJS.Timeout + }) as unknown as typeof global.setTimeout + }) + + const mockAddress = '0x1234567890123456789012345678901234567890' as Address + const mockChainId = '1' + const mockSafeTxHash = 'tx-hash-123' + + const mockSafesState = { + [mockAddress]: { + [mockChainId]: { + address: { value: mockAddress }, + chainId: mockChainId, + threshold: 1, + owners: [], + fiatTotal: '0', + queued: 0, + }, + }, + } + + beforeEach(() => { + jest.clearAllMocks() + mockStore.getState.mockReturnValue({}) + mockSelectAllSafes.mockReturnValue(mockSafesState) + mockRouter.canGoBack.mockReturnValue(true) + }) + + // Import the handler after mocks are set up + let NotificationNavigationHandler: typeof import('../notificationNavigationHandler').NotificationNavigationHandler + beforeAll(() => { + NotificationNavigationHandler = require('../notificationNavigationHandler').NotificationNavigationHandler + }) + + + describe('switchToSafe', () => { + it('should switch to the correct safe successfully', async () => { + await NotificationNavigationHandler.switchToSafe(mockAddress, mockChainId) + + expect(mockStore.dispatch).toHaveBeenCalled() + }) + + it('should throw error when safe does not exist in user wallet', async () => { + mockSelectAllSafes.mockReturnValue({}) + + await expect(NotificationNavigationHandler.switchToSafe(mockAddress, mockChainId)).rejects.toThrow( + 'Safe not found in user wallet', + ) + + expect(mockStore.dispatch).not.toHaveBeenCalled() + }) + }) + + describe('handleNotificationPress edge cases', () => { + it('should handle missing notification data gracefully', async () => { + await NotificationNavigationHandler.handleNotificationPress(undefined) + }) + + it('should handle missing required fields gracefully', async () => { + const incompleteData = { + type: 'INCOMING_ETHER' as NotificationType, + // Missing chainId and address + } + + await NotificationNavigationHandler.handleNotificationPress(incompleteData) + }) + }) + + describe('navigation methods', () => { + it('should test navigateToTransactionHistory calls safeNavigate with correct path', async () => { + // Mock safeNavigate to track calls + const originalSafeNavigate = NotificationNavigationHandler.safeNavigate + const safeNavigateMock = jest.fn().mockResolvedValue(undefined) + NotificationNavigationHandler.safeNavigate = safeNavigateMock + + await NotificationNavigationHandler.navigateToTransactionHistory() + + expect(safeNavigateMock).toHaveBeenCalledWith('/transactions') + + // Restore original + NotificationNavigationHandler.safeNavigate = originalSafeNavigate + }) + + it('should test navigateToConfirmTransaction with safeTxHash', async () => { + const originalSafeNavigate = NotificationNavigationHandler.safeNavigate + const safeNavigateMock = jest.fn().mockResolvedValue(undefined) + NotificationNavigationHandler.safeNavigate = safeNavigateMock + + await NotificationNavigationHandler.navigateToConfirmTransaction(mockSafeTxHash) + + expect(safeNavigateMock).toHaveBeenCalledWith({ + pathname: '/confirm-transaction', + params: { txId: mockSafeTxHash }, + }) + + NotificationNavigationHandler.safeNavigate = originalSafeNavigate + }) + + it('should test navigateToConfirmTransaction without safeTxHash', async () => { + const originalSafeNavigate = NotificationNavigationHandler.safeNavigate + const safeNavigateMock = jest.fn().mockResolvedValue(undefined) + NotificationNavigationHandler.safeNavigate = safeNavigateMock + + await NotificationNavigationHandler.navigateToConfirmTransaction() + + expect(safeNavigateMock).toHaveBeenCalledWith('/pending-transactions') + + NotificationNavigationHandler.safeNavigate = originalSafeNavigate + }) + }) + + describe('handleNotificationPress with various notification types', () => { + it('should handle INCOMING_ETHER notification correctly', async () => { + // Mock the internal methods + const switchToSafeMock = jest.fn().mockResolvedValue(undefined) + const navigateToTransactionHistoryMock = jest.fn().mockResolvedValue(undefined) + NotificationNavigationHandler.switchToSafe = switchToSafeMock + NotificationNavigationHandler.navigateToTransactionHistory = navigateToTransactionHistoryMock + + const notificationData: TestNotificationData = { + type: 'INCOMING_ETHER' as NotificationType, + chainId: mockChainId, + address: mockAddress, + } + + await NotificationNavigationHandler.handleNotificationPress( + notificationData as unknown as Record<string, string | object>, + ) + + expect(switchToSafeMock).toHaveBeenCalledWith(mockAddress, mockChainId) + expect(navigateToTransactionHistoryMock).toHaveBeenCalled() + }) + + it('should handle CONFIRMATION_REQUEST notification correctly', async () => { + const switchToSafeMock = jest.fn().mockResolvedValue(undefined) + const navigateToConfirmTransactionMock = jest.fn().mockResolvedValue(undefined) + NotificationNavigationHandler.switchToSafe = switchToSafeMock + NotificationNavigationHandler.navigateToConfirmTransaction = navigateToConfirmTransactionMock + + const notificationData: TestNotificationData = { + type: 'CONFIRMATION_REQUEST' as NotificationType, + chainId: mockChainId, + address: mockAddress, + safeTxHash: mockSafeTxHash, + } + + await NotificationNavigationHandler.handleNotificationPress( + notificationData as unknown as Record<string, string | object>, + ) + + expect(switchToSafeMock).toHaveBeenCalledWith(mockAddress, mockChainId) + expect(navigateToConfirmTransactionMock).toHaveBeenCalledWith(mockSafeTxHash) + }) + + it('should handle unknown notification type with fallback navigation', async () => { + const switchToSafeMock = jest.fn().mockResolvedValue(undefined) + const safeNavigateMock = jest.fn().mockResolvedValue(undefined) + NotificationNavigationHandler.switchToSafe = switchToSafeMock + NotificationNavigationHandler.safeNavigate = safeNavigateMock + + const notificationData: TestNotificationData = { + type: 'UNKNOWN_TYPE', + chainId: mockChainId, + address: mockAddress, + } + + await NotificationNavigationHandler.handleNotificationPress( + notificationData as unknown as Record<string, string | object>, + ) + + expect(switchToSafeMock).toHaveBeenCalledWith(mockAddress, mockChainId) + expect(safeNavigateMock).toHaveBeenCalledWith('/') + }) + + it('should handle errors during navigation and fallback to home', async () => { + const switchToSafeMock = jest.fn().mockRejectedValue(new Error('Switch failed')) + const safeNavigateMock = jest.fn().mockResolvedValue(undefined) + NotificationNavigationHandler.switchToSafe = switchToSafeMock + NotificationNavigationHandler.safeNavigate = safeNavigateMock + + const notificationData: TestNotificationData = { + type: 'INCOMING_ETHER' as NotificationType, + chainId: mockChainId, + address: mockAddress, + } + + await NotificationNavigationHandler.handleNotificationPress( + notificationData as unknown as Record<string, string | object>, + ) + + expect(safeNavigateMock).toHaveBeenCalledWith('/') + }) + }) +}) diff --git a/apps/mobile/src/services/notifications/backend.ts b/apps/mobile/src/services/notifications/backend.ts new file mode 100644 index 0000000000..ea83406669 --- /dev/null +++ b/apps/mobile/src/services/notifications/backend.ts @@ -0,0 +1,188 @@ +import DeviceInfo from 'react-native-device-info' +import { SiweMessage } from 'siwe' +import { Wallet, HDNodeWallet } from 'ethers' + +import { NOTIFICATION_ACCOUNT_TYPE, ERROR_MSG } from '@/src/store/constants' +import { REGULAR_NOTIFICATIONS, OWNER_NOTIFICATIONS } from '@/src/utils/notifications' +import { cgwApi as authApi } from '@safe-global/store/gateway/AUTO_GENERATED/auth' +import { cgwApi as notificationsApi } from '@safe-global/store/gateway/AUTO_GENERATED/notifications' +import { convertToUuid } from '@/src/utils/uuid' +import { isAndroid, GATEWAY_URL } from '@/src/config/constants' +import Logger from '@/src/utils/logger' +import { getStore } from '@/src/store/utils/singletonStore' + +// Type for RTK Query options with our custom property +interface CustomRTKQueryOptions { + track?: boolean + fixedCacheKey?: string + forceOmitCredentials?: boolean +} + +export const getDeviceUuid = async () => { + const deviceId = await DeviceInfo.getUniqueId() + return convertToUuid(deviceId) +} + +const getNotificationRegisterPayload = async ({ + signer, + chainId, +}: { + signer: Wallet | HDNodeWallet + chainId: string +}) => { + const nonceResponse = await fetch(`${GATEWAY_URL}/v1/auth/nonce`, { + method: 'GET', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + }) + + if (!nonceResponse.ok) { + throw new Error(`Failed to get nonce: ${nonceResponse.status} ${nonceResponse.statusText}`) + } + + const { nonce } = await nonceResponse.json() + + if (!nonce) { + throw new Error(ERROR_MSG) + } + + const message = new SiweMessage({ + address: signer.address, + chainId: Number(chainId), + domain: 'global.safe.mobileapp', + statement: 'Safe Wallet wants you to sign in with your Ethereum account', + nonce, + uri: 'https://safe.global', + version: '1', + issuedAt: new Date().toISOString(), + }) + + return { siweMessage: message.prepareMessage() } +} + +export const authenticateSigner = async (signer: Wallet | HDNodeWallet | null, chainId: string) => { + if (!signer) { + return + } + + const signerAddress = signer.address + + const { siweMessage } = await getNotificationRegisterPayload({ signer, chainId }) + const signature = await signer.signMessage(siweMessage) + + await getStore() + .dispatch( + authApi.endpoints.authVerifyV1.initiate({ + siweDto: { message: siweMessage, signature }, + }), + ) + .unwrap() + + Logger.info('Authenticated signer', { signerAddress }) +} + +export const registerForNotificationsOnBackEnd = async ({ + safeAddress, + signer, + chainIds, + fcmToken, + notificationAccountType, + noAuth = false, +}: { + safeAddress: string + signer: Wallet | HDNodeWallet | null + chainIds: string[] + fcmToken: string + notificationAccountType: NOTIFICATION_ACCOUNT_TYPE + noAuth?: boolean +}) => { + const isOwner = notificationAccountType === NOTIFICATION_ACCOUNT_TYPE.OWNER + const deviceUuid = await getDeviceUuid() + + if (!noAuth) { + await authenticateSigner(signer, chainIds[0]) + } + + const NOTIFICATIONS_GRANTED = isOwner ? OWNER_NOTIFICATIONS : REGULAR_NOTIFICATIONS + + await getStore() + .dispatch( + notificationsApi.endpoints.notificationsUpsertSubscriptionsV2.initiate( + { + upsertSubscriptionsDto: { + cloudMessagingToken: fcmToken, + safes: chainIds.map((chainId) => ({ + chainId, + address: safeAddress, + notificationTypes: NOTIFICATIONS_GRANTED, + })), + deviceType: isAndroid ? 'ANDROID' : 'IOS', + deviceUuid, + }, + }, + { + forceOmitCredentials: noAuth, + } as CustomRTKQueryOptions, + ), + ) + .unwrap() +} + +export const unregisterForNotificationsOnBackEnd = async ({ + signer, + safeAddress, + chainIds, +}: { + signer: Wallet | HDNodeWallet | null + safeAddress: string + chainIds: string[] +}) => { + // Validate input parameters + if (!chainIds || chainIds.length === 0) { + Logger.warn('No chainIds provided for unregistering notifications', { safeAddress }) + return + } + + await authenticateSigner(signer, chainIds[0]) + const deviceUuid = await getDeviceUuid() + + // Ensure we have all required data + if (!deviceUuid || !safeAddress) { + throw new Error('Missing required parameters for unregistering notifications') + } + + // Use the new bulk delete endpoint for better efficiency + const subscriptions = chainIds.map((chainId) => ({ + chainId: chainId, + deviceUuid: deviceUuid, + safeAddress: safeAddress, + })) + + // Ensure we have valid subscriptions array + if (!Array.isArray(subscriptions) || subscriptions.length === 0) { + throw new Error('No valid subscriptions to delete') + } + + const deleteAllSubscriptionsDto = { + subscriptions: subscriptions, + } + + Logger.info('Unregistering notifications for subscriptions', { deleteAllSubscriptionsDto }) + + try { + await getStore() + .dispatch( + notificationsApi.endpoints.notificationsDeleteAllSubscriptionsV2.initiate({ + deleteAllSubscriptionsDto, + }), + ) + .unwrap() + } catch (error: unknown) { + // Treat 404 errors as successful unregistration since the safe was already unsubscribed + if (error && typeof error === 'object' && 'status' in error && error.status === 404) { + Logger.info('Safe was already unsubscribed from notifications', { safeAddress, chainIds }) + } else { + Logger.error('Failed to unregister notifications', { error, safeAddress, chainIds }) + throw error + } + } +} diff --git a/apps/mobile/src/services/notifications/backgroundHandlers.ts b/apps/mobile/src/services/notifications/backgroundHandlers.ts new file mode 100644 index 0000000000..e64bd5da70 --- /dev/null +++ b/apps/mobile/src/services/notifications/backgroundHandlers.ts @@ -0,0 +1,59 @@ +import { getMessaging } from '@react-native-firebase/messaging' +import notifee from '@notifee/react-native' + +/** + * The background handlers here are only used by the Android version of the app. + * On iOS, the Notification Service Extension is intercepting the notifications and those + * functions here never get called. + */ +getMessaging().setBackgroundMessageHandler(async (remoteMessage) => { + const messageId = remoteMessage.messageId || `${remoteMessage.data?.type}-${Date.now()}` + console.log('[Firebase Background] Processing message:', messageId) + + try { + // Check for message deduplication and mark as processed + const { checkAndMarkMessageProcessed } = await import('@/src/services/notifications/utils/messageDeduplication') + + if (checkAndMarkMessageProcessed(messageId)) { + console.log('[Firebase Background] Message already processed, acknowledging:', messageId) + return // Already processed - acknowledge and skip + } + + console.log('[Firebase Background] Displaying notification for message:', messageId) + + // Use regular parser with automatic fallback to extension MMKV storage + const { parseNotification } = await import('./notificationParser') + const NotificationsService = (await import('./NotificationService')).default + const { ChannelId } = await import('@/src/utils/notifications') + + const parsed = parseNotification(remoteMessage.data) + + // Add timeout to prevent hanging + await NotificationsService.displayNotification({ + channelId: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, + title: parsed?.title || remoteMessage.notification?.title || '', + body: parsed?.body || remoteMessage.notification?.body || '', + data: remoteMessage.data, + }) + + console.log('[Firebase Background] Successfully processed message:', messageId) + } catch (error) { + console.error('[Firebase Background] Error processing message:', messageId, error) + } + + return Promise.resolve() +}) + +notifee.onBackgroundEvent(async ({ type, detail }) => { + console.log('[Notifee Background] Event received:', type) + + try { + // Delegate to NotificationService for consistent logic + const NotificationsService = (await import('./NotificationService')).default + await NotificationsService.handleNotificationEvent({ type, detail }) + + console.log('[Notifee Background] Event processed successfully') + } catch (error) { + console.error('[Notifee Background] Error processing event:', error) + } +}) diff --git a/apps/mobile/src/services/notifications/notificationNavigationHandler.ts b/apps/mobile/src/services/notifications/notificationNavigationHandler.ts new file mode 100644 index 0000000000..93f2e8864c --- /dev/null +++ b/apps/mobile/src/services/notifications/notificationNavigationHandler.ts @@ -0,0 +1,171 @@ +import { router } from 'expo-router' +import { getStore } from '@/src/store/utils/singletonStore' +import { setActiveSafe } from '@/src/store/activeSafeSlice' +import { selectAllSafes } from '@/src/store/safesSlice' +import { NotificationType } from '@safe-global/store/gateway/AUTO_GENERATED/notifications' +import { FirebaseMessagingTypes } from '@react-native-firebase/messaging' +import { Address } from '@/src/types/address' +import Logger from '@/src/utils/logger' + +// Helper function to wait for router to be ready +const waitForRouter = async (maxAttempts = 50, delayMs = 100): Promise<boolean> => { + for (let i = 0; i < maxAttempts; i++) { + try { + // Test if router is ready by checking if we can access navigation state + if (router.canGoBack !== undefined) { + // Additional check to ensure router is truly ready + await new Promise((resolve) => setTimeout(resolve, 50)) + return true + } + } catch (_error) { + // Router not ready yet + Logger.info(`Router not ready, attempt ${i + 1}/${maxAttempts}`) + } + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + Logger.error('Router failed to become ready within timeout') + return false +} + +export interface NotificationNavigationData { + type: NotificationType + chainId: string + address: string + safeTxHash?: string + _txHash?: string + value?: string + failed?: string +} + +export const NotificationNavigationHandler = { + async handleNotificationPress(data: FirebaseMessagingTypes.RemoteMessage['data']): Promise<void> { + Logger.info('NotificationNavigationHandler: handleNotificationPress called with data:', data) + + if (!data) { + Logger.warn('NotificationNavigationHandler: No data provided') + return + } + + try { + // Wait for router to be ready before attempting navigation + Logger.info('NotificationNavigationHandler: Waiting for router to be ready...') + const isRouterReady = await waitForRouter() + + if (!isRouterReady) { + Logger.error('NotificationNavigationHandler: Router not ready after waiting, aborting navigation') + return + } + + Logger.info('NotificationNavigationHandler: Router is ready, proceeding with navigation') + + const notificationData = data as unknown as NotificationNavigationData + const { type, chainId, address, safeTxHash } = notificationData + + Logger.info('NotificationNavigationHandler: Parsed notification data:', { type, chainId, address, safeTxHash }) + + if (!type || !chainId || !address) { + Logger.warn('NotificationNavigationHandler: Missing required notification data', { type, chainId, address }) + return + } + + // Switch to the correct safe and chain + await this.switchToSafe(address as Address, chainId) + + // Navigate based on notification type + switch (type) { + case 'INCOMING_ETHER': + case 'INCOMING_TOKEN': + case 'EXECUTED_MULTISIG_TRANSACTION': + Logger.info('NotificationNavigationHandler: Navigating to transaction history') + await this.navigateToTransactionHistory() + break + case 'CONFIRMATION_REQUEST': + Logger.info('NotificationNavigationHandler: Navigating to confirm transaction') + await this.navigateToConfirmTransaction(safeTxHash) + break + default: + Logger.warn('NotificationNavigationHandler: Unknown notification type', { type }) + // Fallback to home screen with correct safe + await this.safeNavigate('/') + break + } + } catch (error) { + Logger.error('NotificationNavigationHandler: Error handling notification press', error) + // Fallback to home screen + await this.safeNavigate('/') + } + }, + + async switchToSafe(address: Address, chainId: string): Promise<void> { + const currentState = getStore().getState() + const allSafes = selectAllSafes(currentState) + + // Check if the safe exists in the user's wallet + const safeExists = allSafes[address] && allSafes[address][chainId] + + if (!safeExists) { + Logger.warn('NotificationNavigationHandler: Safe not found in user wallet', { address, chainId }) + throw new Error('Safe not found in user wallet') + } + + // This is a bit of a hack, but for now we need to make it. + // if the user is in a different safe and he is in the confirm tx screen + // if we just switch the safe and try to push the new confirm tx screen from the notification + // the app is going to crash (because we have a tx for a safe that is not the active safe) + // so we need to dismiss all the screens and then switch the safe + await this.safeDismissAll() + + // Switch to the safe + getStore().dispatch( + setActiveSafe({ + address, + chainId, + }), + ) + + Logger.info('NotificationNavigationHandler: Switched to safe', { address, chainId }) + }, + + /** + * Safe navigation wrapper that handles router readiness + */ + async safeNavigate(path: string | { pathname: string; params?: Record<string, string> }): Promise<void> { + try { + const isRouterReady = await waitForRouter() + if (!isRouterReady) { + Logger.error('NotificationNavigationHandler: Router not ready for navigation') + return + } + + if (typeof path === 'string') { + router.push(path as never) + } else { + router.push(path as never) + } + } catch (error) { + Logger.error('NotificationNavigationHandler: Error during safe navigation', error) + } + }, + + async safeDismissAll(): Promise<void> { + const isRouterReady = await waitForRouter() + if (isRouterReady && router.canGoBack()) { + router.dismissAll() + } + }, + + async navigateToTransactionHistory(): Promise<void> { + await this.safeNavigate('/transactions') + }, + + async navigateToConfirmTransaction(safeTxHash?: string): Promise<void> { + if (safeTxHash) { + await this.safeNavigate({ + pathname: '/confirm-transaction', + params: { txId: safeTxHash }, + }) + } else { + await this.safeNavigate('/pending-transactions') + } + }, +} diff --git a/apps/mobile/src/services/notifications/notificationParser.ts b/apps/mobile/src/services/notifications/notificationParser.ts new file mode 100644 index 0000000000..35a1400703 --- /dev/null +++ b/apps/mobile/src/services/notifications/notificationParser.ts @@ -0,0 +1,105 @@ +import { formatUnits } from 'ethers' +import { NotificationType } from '@safe-global/store/gateway/AUTO_GENERATED/notifications' +import { selectChainById } from '@/src/store/chains' +import { shortenAddress } from '@/src/utils/formatters' +import { selectContactByAddress } from '@/src/store/addressBookSlice' +import { getStore } from '@/src/store/utils/singletonStore' +import { getExtensionData } from './store-sync/read' +import Logger from '@/src/utils/logger' + +export interface ParsedNotification { + title: string + body: string +} + +interface NotificationMetadata { + chainName: string + chainSymbol: string + chainDecimals: number + safeName: string +} + +const getNotificationMetadata = (chainId?: string, address?: string): NotificationMetadata => { + let chainData: { name?: string; symbol?: string; decimals?: number } | null = null + let contactName: string | undefined = undefined + + try { + // Try to use Redux store first (foreground mode) + const state = getStore().getState() + const chain = chainId ? selectChainById(state, chainId) : null + chainData = chain + ? { + name: chain.chainName, + symbol: chain.nativeCurrency?.symbol, + decimals: chain.nativeCurrency?.decimals, + } + : null + contactName = selectContactByAddress(address as `0x${string}`)(state)?.name + } catch (_error) { + // Fallback to extension MMKV storage (background|quit mode) + Logger.info('parseNotification: Redux store not available, using extension storage fallback') + const extensionData = getExtensionData() + const extChainData = chainId ? extensionData?.chains[chainId] : undefined + chainData = extChainData + ? { + name: extChainData.name, + symbol: extChainData.symbol, + decimals: extChainData.decimals, + } + : null + contactName = address && extensionData?.contacts[address] + } + + // Set variables based on fetched data + return { + chainName: chainData?.name ?? `Chain Id ${chainId}`, + chainSymbol: chainData?.symbol ?? 'ETH', + chainDecimals: chainData?.decimals ?? 18, + safeName: contactName ?? (address ? shortenAddress(address) : ''), + } +} + +export const parseNotification = (data?: Record<string, unknown>): ParsedNotification | null => { + if (!data || !data.type) { + return null + } + + const strData = data as Record<string, string> + + const type = strData.type as NotificationType + const chainId = strData.chainId + const address = strData.address + + const { chainName, chainSymbol, chainDecimals, safeName } = getNotificationMetadata(chainId, address) + + switch (type) { + case 'INCOMING_ETHER': { + const value = strData.value ? formatUnits(strData.value, chainDecimals) : '' + return { + title: `Incoming ${chainSymbol} (${chainName})`, + body: `${safeName}: ${value} ${chainSymbol} received`, + } + } + case 'INCOMING_TOKEN': { + return { + title: `Incoming token (${chainName})`, + body: `${safeName}: tokens received`, + } + } + case 'EXECUTED_MULTISIG_TRANSACTION': { + const status = strData.failed === 'true' ? 'failed' : 'successful' + return { + title: `Transaction ${status} (${chainName})`, + body: `${safeName}: Transaction ${status}`, + } + } + case 'CONFIRMATION_REQUEST': { + return { + title: `Confirmation required (${chainName})`, + body: `${safeName}: A transaction requires your confirmation!`, + } + } + default: + return null + } +} diff --git a/apps/mobile/src/services/notifications/operations.ts b/apps/mobile/src/services/notifications/operations.ts new file mode 100644 index 0000000000..100dc0a8c7 --- /dev/null +++ b/apps/mobile/src/services/notifications/operations.ts @@ -0,0 +1,74 @@ +import { cgwApi as notificationsApi } from '@safe-global/store/gateway/AUTO_GENERATED/notifications' +import { getDeviceUuid, registerForNotificationsOnBackEnd } from '@/src/services/notifications/backend' +import FCMService from '@/src/services/notifications/FCMService' +import { NOTIFICATION_ACCOUNT_TYPE } from '@/src/store/constants' +import { getStore } from '@/src/store/utils/singletonStore' +import { createSubscriptionData, clearAuthBeforeUnauthenticatedCall } from '@/src/utils/notifications/cleanup' +import { withRateLimitRetry } from '@/src/utils/retry' +import Logger from '@/src/utils/logger' + +export const unsubscribeDelegateFromNotifications = async ( + safeAddress: string, + delegateAddress: string, + chainIds: string[], +): Promise<void> => { + const deviceUuid = await getDeviceUuid() + const subscriptions = await createSubscriptionData(safeAddress, chainIds, deviceUuid, delegateAddress) + + await withRateLimitRetry(async () => { + await getStore() + .dispatch( + notificationsApi.endpoints.notificationsDeleteAllSubscriptionsV2.initiate({ + deleteAllSubscriptionsDto: { subscriptions }, + }), + ) + .unwrap() + }) + + Logger.info(`Unsubscribed delegate ${delegateAddress} from safe ${safeAddress}`, { + safeAddress, + delegateAddress, + chainIds, + }) +} + +export const subscribeToRegularNotifications = async (safeAddress: string, chainIds: string[]): Promise<void> => { + const fcmToken = await FCMService.getFCMToken() + + if (!fcmToken) { + Logger.warn('No FCM token available for regular notification subscription') + return + } + + // Clear authentication cookies first to prevent React Native bug where credentials: 'omit' is ignored + await clearAuthBeforeUnauthenticatedCall() + + await registerForNotificationsOnBackEnd({ + safeAddress, + signer: null, // No signer for regular notifications + chainIds, + fcmToken, + notificationAccountType: NOTIFICATION_ACCOUNT_TYPE.OWNER, + noAuth: true, // Don't send credentials to avoid automatic delegate subscription + }) + + Logger.info(`Subscribed to regular notifications for safe ${safeAddress}`, { + safeAddress, + chainIds, + }) +} + +export const cleanupSafeNotifications = async ( + safeAddress: string, + chainIds: string[], + delegateAddress: string, + hasOtherDelegates: boolean, +): Promise<void> => { + // Unsubscribe the specific delegate + await unsubscribeDelegateFromNotifications(safeAddress, delegateAddress, chainIds) + + // If no other delegates remain, subscribe to regular notifications + if (!hasOtherDelegates) { + await subscribeToRegularNotifications(safeAddress, chainIds) + } +} diff --git a/apps/mobile/src/services/notifications/registration.ts b/apps/mobile/src/services/notifications/registration.ts new file mode 100644 index 0000000000..466366095d --- /dev/null +++ b/apps/mobile/src/services/notifications/registration.ts @@ -0,0 +1,104 @@ +import { Wallet, HDNodeWallet } from 'ethers' + +import type { Store } from '@reduxjs/toolkit' +import type { RootState } from '@/src/store' + +type StoreLike = Pick<Store<RootState>, 'dispatch' | 'getState'> +import { selectSafeInfo } from '@/src/store/safesSlice' +import { selectSigners } from '@/src/store/signersSlice' +import { selectAllDelegatesForSafeOwners, selectFirstDelegateForAnySafeOwner } from '@/src/store/delegatesSlice' +import { notificationChannels, withTimeout, getSigner } from '@/src/utils/notifications' +import { getAccountType } from '@/src/utils/notifications/accountType' +import FCMService from './FCMService' +import NotificationService from './NotificationService' +import { setSafeSubscriptionStatus } from '@/src/store/safeSubscriptionsSlice' +import Logger from '@/src/utils/logger' +import { getPrivateKey } from '@/src/hooks/useSign/useSign' +import { registerForNotificationsOnBackEnd, unregisterForNotificationsOnBackEnd } from './backend' +import { getDelegateKeyId } from '@/src/utils/delegate' +import { getStore } from '@/src/store/utils/singletonStore' + +type DelegateInfo = { owner: string; delegateAddress: string } | null + +export const getDelegateSigner = async (delegate: DelegateInfo) => { + if (!delegate) { + return { signer: null as Wallet | HDNodeWallet | null } + } + const { owner, delegateAddress } = delegate + const delegateKeyId = getDelegateKeyId(owner, delegateAddress) + const privateKey = await getPrivateKey(delegateKeyId, { requireAuthentication: false }) + const signer = privateKey ? getSigner(privateKey) : null + return { signer } +} + +export const getNotificationAccountType = (safeAddress: string) => { + const state = getStore().getState() + const safeInfoItem = selectSafeInfo(state, safeAddress as `0x${string}`) + const signers = selectSigners(state) + return getAccountType(safeInfoItem?.SafeInfo, signers) +} + +export async function registerSafe(store: StoreLike, address: string, chainIds: string[]): Promise<void> { + try { + const allDelegates = selectAllDelegatesForSafeOwners(store.getState(), address as `0x${string}`) + const { accountType } = getNotificationAccountType(address) + + const fcmToken = await FCMService.initNotification() + await withTimeout(NotificationService.createChannel(notificationChannels[0]), 5000) + + // If no delegates found, try to register without a signer (for owner-based notifications) + if (allDelegates.length === 0) { + Logger.warn(`No delegates found for Safe ${address}, registering without delegate signer`) + await registerForNotificationsOnBackEnd({ + safeAddress: address, + signer: null, + chainIds, + fcmToken: fcmToken || '', + notificationAccountType: accountType, + }) + } else { + // Register each delegate for notifications + const registrationPromises = allDelegates.map(async (delegate) => { + try { + const { signer } = await getDelegateSigner(delegate) + await registerForNotificationsOnBackEnd({ + safeAddress: address, + signer, + chainIds, + fcmToken: fcmToken || '', + notificationAccountType: accountType, + }) + Logger.info(`Successfully registered delegate ${delegate.delegateAddress} for Safe ${address}`) + } catch (err) { + Logger.error(`Failed to register delegate ${delegate.delegateAddress} for Safe ${address}`, err) + // Don't throw here - we want to continue with other delegates + } + }) + + // Wait for all registrations to complete + await Promise.allSettled(registrationPromises) + } + + // Update subscription status for all chains + chainIds.forEach((chainId) => + store.dispatch(setSafeSubscriptionStatus({ safeAddress: address, chainId, subscribed: true })), + ) + } catch (err) { + Logger.error('registerSafe failed', err) + } +} + +export async function unregisterSafe(store: StoreLike, address: string, chainIds: string[]): Promise<void> { + try { + const delegate = selectFirstDelegateForAnySafeOwner(store.getState(), address as `0x${string}`) + const { signer } = await getDelegateSigner(delegate) + + await unregisterForNotificationsOnBackEnd({ signer, safeAddress: address, chainIds }) + + chainIds.forEach((chainId) => + store.dispatch(setSafeSubscriptionStatus({ safeAddress: address, chainId, subscribed: false })), + ) + } catch (err) { + Logger.error('unregisterSafe failed', err) + } +} diff --git a/apps/mobile/src/services/notifications/store-sync/const.ts b/apps/mobile/src/services/notifications/store-sync/const.ts new file mode 100644 index 0000000000..b20d993fca --- /dev/null +++ b/apps/mobile/src/services/notifications/store-sync/const.ts @@ -0,0 +1,9 @@ +import { MMKV } from 'react-native-mmkv' + +/** + * Shared MMKV instance for extension storage + * + * Fun fact: it's named extensionStorage, because it was supposed to be used only in + * ios's service extension. Now it is also used on Android, but in headless mode. + */ +export const extensionStorage = new MMKV({ id: 'extension' }) diff --git a/apps/mobile/src/services/notifications/store-sync/read.ts b/apps/mobile/src/services/notifications/store-sync/read.ts new file mode 100644 index 0000000000..cedaec7d98 --- /dev/null +++ b/apps/mobile/src/services/notifications/store-sync/read.ts @@ -0,0 +1,25 @@ +import { STORAGE_IDS } from '@/src/store/constants' +import { extensionStorage } from './const' + +interface ExtensionStore { + chains: Record<string, { name: string; symbol: string; decimals: number }> + contacts: Record<string, string> +} + +/** + * Read extension data from MMKV storage + * This function is separate from extensionSync.ts to avoid require cycles + * It only reads data and has no dependencies on the Redux store + */ +export function getExtensionData(): ExtensionStore | null { + try { + const data = extensionStorage.getString(STORAGE_IDS.NOTIFICATION_EXTENSION_DATA) + if (!data) { + return null + } + return JSON.parse(data) as ExtensionStore + } catch (error) { + console.error('extensionDataReader: Failed to get extension data', error) + return null + } +} diff --git a/apps/mobile/src/services/notifications/store-sync/sync.ts b/apps/mobile/src/services/notifications/store-sync/sync.ts new file mode 100644 index 0000000000..651b61007d --- /dev/null +++ b/apps/mobile/src/services/notifications/store-sync/sync.ts @@ -0,0 +1,38 @@ +import { selectAllContacts } from '@/src/store/addressBookSlice' +import { selectAllChains } from '@/src/store/chains' +import { STORAGE_IDS } from '@/src/store/constants' +import { extensionStorage } from './const' +import type { AppStore } from '@/src/store' + +/** + * On iOS we need to intercept the push notification payload and + * modify the title and body withing the ExtensionService. This happens + * on the native side. We need to sync the data to the extension storage + * so that the ExtensionService can use it on the native side. + * + * On Android we run in a Headless service, we could theoretically init the redux store, + * but using MMKV directly for the push notifications is easier. + */ +export function syncNotificationExtensionData(store: AppStore) { + const state = store.getState() + const contacts = selectAllContacts(state) + const chains = selectAllChains(state) + + // Store enhanced chain data including native currency info for proper symbol handling + const chainMap: Record<string, { name: string; symbol: string; decimals: number }> = {} + chains.forEach((c) => { + chainMap[c.chainId] = { + name: c.chainName, + symbol: c.nativeCurrency?.symbol ?? 'ETH', + decimals: c.nativeCurrency?.decimals ?? 18, + } + }) + + const contactMap: Record<string, string> = {} + contacts.forEach((c) => { + contactMap[c.value] = c.name + }) + + const data = JSON.stringify({ chains: chainMap, contacts: contactMap }) + extensionStorage.set(STORAGE_IDS.NOTIFICATION_EXTENSION_DATA, data) +} diff --git a/apps/mobile/src/services/notifications/utils/messageDeduplication.ts b/apps/mobile/src/services/notifications/utils/messageDeduplication.ts new file mode 100644 index 0000000000..057b30c5cd --- /dev/null +++ b/apps/mobile/src/services/notifications/utils/messageDeduplication.ts @@ -0,0 +1,51 @@ +import { MMKV } from 'react-native-mmkv' + +let processedMessagesStorage: MMKV | null = null + +const getProcessedMessagesStorage = (): MMKV => { + if (!processedMessagesStorage) { + processedMessagesStorage = new MMKV({ id: 'processed-messages' }) + } + return processedMessagesStorage +} + +/** + * Check if a Firebase message has already been processed and mark it as processed + * @param messageId - The Firebase message ID or fallback identifier + * @param maxStoredMessages - Maximum number of processed messages to keep (default: 50) + * @returns true if message was already processed (should skip), false if new (should process) + */ +export const checkAndMarkMessageProcessed = (messageId: string, maxStoredMessages = 50): boolean => { + const storage = getProcessedMessagesStorage() + const processedKey = `processed_${messageId}` + + // Check if message was already processed + if (storage.getBoolean(processedKey)) { + console.log('[MessageDeduplication] Message already processed, skipping:', messageId) + return true // Already processed - should skip + } + + // Mark message as processed IMMEDIATELY to prevent redelivery + storage.set(processedKey, true) + console.log('[MessageDeduplication] Message marked as processed:', messageId) + + // Cleanup old processed messages to avoid memory bloat + const allKeys = storage.getAllKeys().filter((key) => key.startsWith('processed_')) + if (allKeys.length > maxStoredMessages) { + const oldKeys = allKeys.slice(0, allKeys.length - maxStoredMessages) + oldKeys.forEach((key) => storage.delete(key)) + console.log('[MessageDeduplication] Cleaned up', oldKeys.length, 'old processed messages') + } + + return false // New message - should process +} + +/** + * Clear all processed message records (useful for testing or reset) + */ +export const clearProcessedMessages = (): void => { + const storage = getProcessedMessagesStorage() + const allKeys = storage.getAllKeys().filter((key) => key.startsWith('processed_')) + allKeys.forEach((key) => storage.delete(key)) + console.log('[MessageDeduplication] Cleared all processed messages:', allKeys.length, 'records') +} diff --git a/apps/mobile/src/services/tx/extractTx.ts b/apps/mobile/src/services/tx/extractTx.ts new file mode 100644 index 0000000000..46aa8d8fd3 --- /dev/null +++ b/apps/mobile/src/services/tx/extractTx.ts @@ -0,0 +1,139 @@ +import type { OperationType, SafeTransactionData } from '@safe-global/types-kit' +import { Operation } from '@safe-global/safe-gateway-typescript-sdk' +import { isMultisigDetailedExecutionInfo, isNativeTokenTransfer } from '@/src/utils/transaction-guards' +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' +const EMPTY_DATA = '0x' + +/** + * Convert the CGW tx type to a Safe Core SDK tx + */ +const extractTxInfo = ( + txDetails: TransactionDetails, + safeAddress: string, +): { txParams: SafeTransactionData; signatures: Record<string, string> } => { + // Format signatures into a map + let signatures: Record<string, string> = {} + if (isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo)) { + signatures = txDetails.detailedExecutionInfo.confirmations.reduce((result, item) => { + result[item.signer.value] = item.signature ?? '' + return result + }, signatures) + } + + const data = txDetails.txData?.hexData ?? EMPTY_DATA + + const baseGas = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) + ? txDetails.detailedExecutionInfo.baseGas + : '0' + + const gasPrice = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) + ? txDetails.detailedExecutionInfo.gasPrice + : '0' + + const safeTxGas = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) + ? txDetails.detailedExecutionInfo.safeTxGas + : '0' + + const gasToken = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) + ? txDetails.detailedExecutionInfo.gasToken + : ZERO_ADDRESS + + const nonce = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) + ? txDetails.detailedExecutionInfo.nonce + : 0 + + const refundReceiver = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) + ? txDetails.detailedExecutionInfo.refundReceiver.value + : ZERO_ADDRESS + + const value = (() => { + switch (txDetails.txInfo.type) { + case 'Transfer': + if (isNativeTokenTransfer(txDetails.txInfo.transferInfo)) { + return txDetails.txInfo.transferInfo.value + } else { + return txDetails.txData?.value ?? '0' + } + case 'TwapOrder': + return txDetails.txData?.value ?? '0' + case 'SwapOrder': + return txDetails.txData?.value ?? '0' + case 'SwapAndBridge': + case 'Swap': + return txDetails.txData?.value ?? '0' + case 'NativeStakingDeposit': + case 'NativeStakingValidatorsExit': + case 'NativeStakingWithdraw': + return txDetails.txData?.value ?? '0' + case 'VaultDeposit': + case 'VaultRedeem': + return txDetails.txInfo?.value ?? '0' + case 'Custom': + return txDetails.txInfo.value + case 'Creation': + case 'SettingsChange': + return '0' + default: { + throw new Error(`Unknown transaction type: ${txDetails.txInfo.type}`) + } + } + })() + + const to = (() => { + const type = txDetails.txInfo.type + switch (type) { + case 'Transfer': + if (isNativeTokenTransfer(txDetails.txInfo.transferInfo)) { + return txDetails.txInfo.recipient.value + } else { + return txDetails.txInfo.transferInfo.tokenAddress + } + case 'SwapOrder': + case 'TwapOrder': + case 'SwapAndBridge': + case 'Swap': + case 'NativeStakingDeposit': + case 'NativeStakingValidatorsExit': + case 'NativeStakingWithdraw': + case 'VaultDeposit': + case 'VaultRedeem': { + const toValue = txDetails.txData?.to.value + if (!toValue) { + throw new Error('Tx data does not have a `to` field') + } + return toValue + } + case 'Custom': + return txDetails.txInfo.to.value + case 'Creation': + case 'SettingsChange': + return safeAddress + default: { + // This should never happen as we've handled all possible cases + throw new Error(`Unexpected transaction type: ${type}`) + } + } + })() + + const operation = (txDetails.txData?.operation ?? Operation.CALL) as unknown as OperationType + + return { + txParams: { + data, + baseGas, + gasPrice, + safeTxGas, + gasToken, + nonce, + refundReceiver, + value: value ?? '0', + to, + operation, + }, + signatures, + } +} + +export default extractTxInfo diff --git a/apps/mobile/src/services/tx/tx-sender/create.ts b/apps/mobile/src/services/tx/tx-sender/create.ts new file mode 100644 index 0000000000..0dce8f5274 --- /dev/null +++ b/apps/mobile/src/services/tx/tx-sender/create.ts @@ -0,0 +1,63 @@ +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import { getTransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import extractTxInfo from '@/src/services/tx/extractTx' +import { createConnectedWallet } from '../../web3' +import { SafeInfo } from '@/src/types/address' +import type { SafeTransaction, SafeTransactionDataPartial } from '@safe-global/types-kit' +import { getSafeSDK } from '@/src/hooks/coreSDK/safeCoreSDK' +import { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +interface CreateTxParams { + activeSafe: SafeInfo + txId: string + privateKey: string + txDetails?: TransactionDetails + chain: ChainInfo +} + +export const createTx = async (txParams: SafeTransactionDataPartial, nonce?: number): Promise<SafeTransaction> => { + if (nonce !== undefined) { + txParams = { ...txParams, nonce } + } + const safeSDK = getSafeSDK() + if (!safeSDK) { + console.log('failed to init sdk') + throw new Error( + 'The Safe SDK could not be initialized. Please be aware that we only support v1.0.0 Safe Accounts and up.', + ) + } + return safeSDK.createTransaction({ transactions: [txParams] }) +} + +export const createExistingTx = async ( + txParams: SafeTransactionDataPartial, + signatures: Record<string, string>, +): Promise<SafeTransaction> => { + // Create a tx and add pre-approved signatures + const safeTx = await createTx(txParams, txParams.nonce) + Object.entries(signatures).forEach(([signer, data]) => { + safeTx.addSignature({ + signer, + data, + staticPart: () => data, + dynamicPart: () => '', + isContractSignature: false, + }) + }) + + return safeTx +} + +export const proposeTx = async ({ activeSafe, txId, privateKey, txDetails, chain }: CreateTxParams) => { + // Get the tx details from the backend if not provided + // TODO: fix type - we should use rtk query to get the tx details + txDetails = txDetails || ((await getTransactionDetails(activeSafe.chainId, txId)) as TransactionDetails) + + const { txParams, signatures } = extractTxInfo(txDetails, activeSafe.address) + + const { protocolKit } = await createConnectedWallet(privateKey, activeSafe, chain) + + const safeTx = await protocolKit.createTransaction({ transactions: [txParams] }).catch(console.log) + + return { safeTx, signatures } +} diff --git a/apps/mobile/src/services/tx/tx-sender/index.ts b/apps/mobile/src/services/tx/tx-sender/index.ts new file mode 100644 index 0000000000..f756a8bb30 --- /dev/null +++ b/apps/mobile/src/services/tx/tx-sender/index.ts @@ -0,0 +1 @@ +export * from './create' diff --git a/apps/mobile/src/services/tx/tx-sender/sign.ts b/apps/mobile/src/services/tx/tx-sender/sign.ts new file mode 100644 index 0000000000..a2d4d69447 --- /dev/null +++ b/apps/mobile/src/services/tx/tx-sender/sign.ts @@ -0,0 +1,56 @@ +import { SigningMethod } from '@safe-global/protocol-kit' +import { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { createConnectedWallet } from '@/src/services/web3' +import { proposeTx } from '@/src/services/tx/tx-sender' +import { SafeInfo } from '@/src/types/address' + +export type signTxParams = { + chain: ChainInfo + activeSafe: SafeInfo + txId: string + privateKey?: string +} + +export const signTx = async ({ + chain, + activeSafe, + txId, + privateKey, +}: signTxParams): Promise<{ + signature: string + safeTransactionHash: string +}> => { + if (!chain) { + throw new Error('Active chain not found') + } + if (!privateKey) { + throw new Error('Private key not found') + } + + const { protocolKit, wallet } = await createConnectedWallet(privateKey, activeSafe, chain) + const { safeTx } = await proposeTx({ + activeSafe, + txId, + chain, + privateKey, + }) + + if (!safeTx) { + throw new Error('Safe transaction not found') + } + + const signedSafeTx = await protocolKit.signTransaction(safeTx, SigningMethod.ETH_SIGN_TYPED_DATA_V4) + + const safeTransactionHash = await protocolKit.getTransactionHash(signedSafeTx) + + const signature = signedSafeTx.getSignature(wallet.address)?.data + + if (!signature) { + throw new Error('Signature not found') + } + + return { + signature, + safeTransactionHash, + } +} diff --git a/apps/mobile/src/services/web3/index.ts b/apps/mobile/src/services/web3/index.ts new file mode 100644 index 0000000000..4c9009d0f4 --- /dev/null +++ b/apps/mobile/src/services/web3/index.ts @@ -0,0 +1,64 @@ +import { ethers, JsonRpcProvider } from 'ethers' +import { ChainInfo, RPC_AUTHENTICATION, RpcUri } from '@safe-global/safe-gateway-typescript-sdk' +import Safe from '@safe-global/protocol-kit' +import { SafeInfo } from '@/src/types/address' +import { INFURA_TOKEN } from '@safe-global/utils/config/constants' + +export const createWeb3ReadOnly = (chain: ChainInfo, customRpc?: string): JsonRpcProvider | undefined => { + const url = customRpc || getRpcServiceUrl(chain.rpcUri) + if (!url) { + return + } + + return new JsonRpcProvider(url, Number(chain.chainId), { + staticNetwork: true, + batchMaxCount: 3, + }) +} + +// RPC helpers +const formatRpcServiceUrl = ({ authentication, value }: RpcUri, token?: string): string => { + const needsToken = authentication === RPC_AUTHENTICATION.API_KEY_PATH + + if (needsToken && !token) { + console.warn('Infura token not set in .env') + return '' + } + + return needsToken ? `${value}${token}` : value +} + +export const getRpcServiceUrl = (rpcUri: RpcUri): string => { + return formatRpcServiceUrl(rpcUri, INFURA_TOKEN) +} + +export const createConnectedWallet = async ( + privateKey: string, + activeSafe: SafeInfo, + chain: ChainInfo, +): Promise<{ + wallet: ethers.Wallet + protocolKit: Safe +}> => { + const wallet = new ethers.Wallet(privateKey) + const provider = createWeb3ReadOnly(chain) + + if (!provider) { + throw new Error('Provider not found') + } + + const RPC_URL = provider._getConnection().url + + let protocolKit = await Safe.init({ + provider: RPC_URL, + signer: privateKey, + safeAddress: activeSafe.address, + }) + + protocolKit = await protocolKit.connect({ + provider: RPC_URL, + signer: privateKey, + }) + + return { wallet, protocolKit } +} diff --git a/apps/mobile/src/store/__tests__/activeSafeSlice.test.ts b/apps/mobile/src/store/__tests__/activeSafeSlice.test.ts new file mode 100644 index 0000000000..970da1dd1c --- /dev/null +++ b/apps/mobile/src/store/__tests__/activeSafeSlice.test.ts @@ -0,0 +1,29 @@ +import activeSafeReducer, { setActiveSafe, switchActiveChain, clearActiveSafe } from '../activeSafeSlice' +import type { SafeInfo } from '../../types/address' + +describe('activeSafeSlice', () => { + const safe: SafeInfo = { + address: '0x1234567890abcdef1234567890abcdef12345678', + chainId: '1', + } + + it('should set the active safe', () => { + const state = activeSafeReducer(undefined, setActiveSafe(safe)) + expect(state).toEqual(safe) + }) + + it('should switch chain if a safe is active', () => { + const state = activeSafeReducer(safe, switchActiveChain({ chainId: '5' })) + expect(state).toEqual({ ...safe, chainId: '5' }) + }) + + it('should ignore chain switch if no safe is active', () => { + const state = activeSafeReducer(null, switchActiveChain({ chainId: '5' })) + expect(state).toBeNull() + }) + + it('should clear the active safe', () => { + const state = activeSafeReducer(safe, clearActiveSafe()) + expect(state).toBeNull() + }) +}) diff --git a/apps/mobile/src/store/__tests__/addressBookSlice.selector.test.ts b/apps/mobile/src/store/__tests__/addressBookSlice.selector.test.ts new file mode 100644 index 0000000000..ceb7df6794 --- /dev/null +++ b/apps/mobile/src/store/__tests__/addressBookSlice.selector.test.ts @@ -0,0 +1,65 @@ +import { selectTotalContactCount } from '../addressBookSlice' +import { RootState } from '../index' + +describe('addressBookSlice selectors', () => { + describe('selectTotalContactCount', () => { + it('should return 0 when no contacts exist', () => { + const state = { + addressBook: { + contacts: {}, + selectedContact: null, + }, + } as unknown as RootState + + const result = selectTotalContactCount(state) + expect(result).toBe(0) + }) + + it('should return correct count when contacts exist', () => { + const state = { + addressBook: { + contacts: { + '0x1': { value: '0x1', name: 'Alice', chainIds: ['1'] }, + '0x2': { value: '0x2', name: 'Bob', chainIds: ['137'] }, + '0x3': { value: '0x3', name: 'Charlie', chainIds: ['1', '137'] }, + }, + selectedContact: null, + }, + } as unknown as RootState + + const result = selectTotalContactCount(state) + expect(result).toBe(3) + }) + + it('should return 1 when only one contact exists', () => { + const state = { + addressBook: { + contacts: { + '0x1': { value: '0x1', name: 'Alice', chainIds: ['1'] }, + }, + selectedContact: null, + }, + } as unknown as RootState + + const result = selectTotalContactCount(state) + expect(result).toBe(1) + }) + + it('should handle large number of contacts', () => { + const contacts: Record<string, { value: string; name: string; chainIds: string[] }> = {} + for (let i = 0; i < 100; i++) { + contacts[`0x${i}`] = { value: `0x${i}`, name: `Contact ${i}`, chainIds: ['1'] } + } + + const state = { + addressBook: { + contacts, + selectedContact: null, + }, + } as unknown as RootState + + const result = selectTotalContactCount(state) + expect(result).toBe(100) + }) + }) +}) diff --git a/apps/mobile/src/store/__tests__/addressBookSlice.test.ts b/apps/mobile/src/store/__tests__/addressBookSlice.test.ts new file mode 100644 index 0000000000..7db7dd1f23 --- /dev/null +++ b/apps/mobile/src/store/__tests__/addressBookSlice.test.ts @@ -0,0 +1,135 @@ +import { + addressBookSlice, + upsertContact, + removeContact, + addContact, + selectContact, + updateContact, + addContacts, +} from '../addressBookSlice' +import type { Contact } from '../addressBookSlice' + +describe('addressBookSlice', () => { + it('upserts a contact', () => { + const contact: Contact = { value: '0x1', name: 'Alice', chainIds: [] } + const state = addressBookSlice.reducer(undefined, upsertContact(contact)) + + expect(state.contacts['0x1']).toEqual(contact) + }) + + it('updates an existing contact', () => { + const initial = { + contacts: { '0x1': { value: '0x1', name: 'Alice', chainIds: [] } }, + selectedContact: null, + } + const updated: Contact = { value: '0x1', name: 'Alice B', chainIds: [] } + const state = addressBookSlice.reducer(initial, upsertContact(updated)) + + expect(state.contacts['0x1']).toEqual(updated) + }) + + it('removes a contact', () => { + const initial = { + contacts: { '0x1': { value: '0x1', name: 'Alice', chainIds: [] } }, + selectedContact: null, + } + const state = addressBookSlice.reducer(initial, removeContact('0x1')) + + expect(state.contacts['0x1']).toBeUndefined() + }) + + it('clears selectedContact when removed', () => { + const contact: Contact = { value: '0x1', name: 'Alice', chainIds: [] } + const initial = { contacts: { '0x1': contact }, selectedContact: contact } + const state = addressBookSlice.reducer(initial, removeContact('0x1')) + + expect(state.selectedContact).toBeNull() + }) + + it('adds a contact', () => { + const contact: Contact = { value: '0x1', name: 'Alice', chainIds: [] } + const state = addressBookSlice.reducer(undefined, addContact(contact)) + + expect(state.contacts['0x1']).toEqual(contact) + }) + + it('adds multiple contacts', () => { + const contacts: Contact[] = [ + { value: '0x1', name: 'Alice', chainIds: [] }, + { value: '0x2', name: 'Bob', chainIds: ['1'] }, + { value: '0x3', name: 'Charlie', chainIds: ['1', '137'] }, + ] + const state = addressBookSlice.reducer(undefined, addContacts(contacts)) + + expect(state.contacts['0x1']).toEqual(contacts[0]) + expect(state.contacts['0x2']).toEqual(contacts[1]) + expect(state.contacts['0x3']).toEqual(contacts[2]) + }) + + it('selects a contact by address', () => { + const contact: Contact = { value: '0x1', name: 'Alice', chainIds: [] } + const initial = { + contacts: { '0x1': contact }, + selectedContact: null, + } + const state = addressBookSlice.reducer(initial, selectContact('0x1')) + + expect(state.selectedContact).toEqual(contact) + }) + + it('selects null when contact does not exist', () => { + const initial = { + contacts: {}, + selectedContact: null, + } + const state = addressBookSlice.reducer(initial, selectContact('0x1')) + + expect(state.selectedContact).toBeNull() + }) + + it('deselects contact when selecting null', () => { + const contact: Contact = { value: '0x1', name: 'Alice', chainIds: [] } + const initial = { + contacts: { '0x1': contact }, + selectedContact: contact, + } + const state = addressBookSlice.reducer(initial, selectContact(null)) + + expect(state.selectedContact).toBeNull() + }) + + it('updates an existing contact', () => { + const original: Contact = { value: '0x1', name: 'Alice', chainIds: [] } + const initial = { + contacts: { '0x1': original }, + selectedContact: null, + } + const updated: Contact = { value: '0x1', name: 'Alice Updated', chainIds: ['1'] } + const state = addressBookSlice.reducer(initial, updateContact(updated)) + + expect(state.contacts['0x1']).toEqual({ ...original, ...updated }) + }) + + it('updates selectedContact when updating the selected contact', () => { + const original: Contact = { value: '0x1', name: 'Alice', chainIds: [] } + const initial = { + contacts: { '0x1': original }, + selectedContact: original, + } + const updated: Contact = { value: '0x1', name: 'Alice Updated', chainIds: ['1'] } + const state = addressBookSlice.reducer(initial, updateContact(updated)) + + expect(state.selectedContact).toEqual({ ...original, ...updated }) + }) + + it('does not update non-existing contact', () => { + const initial = { + contacts: {}, + selectedContact: null, + } + const contact: Contact = { value: '0x1', name: 'Alice', chainIds: [] } + const state = addressBookSlice.reducer(initial, updateContact(contact)) + + expect(state.contacts['0x1']).toBeUndefined() + }) +}) diff --git a/apps/mobile/src/store/__tests__/safesSlice.test.ts b/apps/mobile/src/store/__tests__/safesSlice.test.ts new file mode 100644 index 0000000000..ce71b8800f --- /dev/null +++ b/apps/mobile/src/store/__tests__/safesSlice.test.ts @@ -0,0 +1,51 @@ +import reducer, { addSafe, updateSafeInfo, removeSafe } from '../safesSlice' +import { Address } from '@/src/types/address' +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' + +const safe1: SafeOverview = { + address: { value: '0x123', name: null, logoUri: null }, + chainId: '1', + threshold: 1, + owners: [{ value: '0xowner1', name: null, logoUri: null }], + fiatTotal: '100', + queued: 0, + awaitingConfirmation: null, +} + +const safe2: SafeOverview = { + address: { value: '0x123', name: null, logoUri: null }, + chainId: '1', + threshold: 2, + owners: [{ value: '0xowner2', name: null, logoUri: null }], + fiatTotal: '200', + queued: 1, + awaitingConfirmation: 1, +} + +describe('safesSlice reducer', () => { + it('adds a safe', () => { + const state = reducer({}, addSafe({ address: '0x123' as Address, info: { [safe1.chainId]: safe1 } })) + expect(state['0x123']).toEqual({ [safe1.chainId]: safe1 }) + }) + + it('updates a safe info for existing safe', () => { + const initialState = { + '0x123': { [safe1.chainId]: safe1 }, + } + const state = reducer(initialState, updateSafeInfo({ address: '0x123' as Address, chainId: '1', info: safe2 })) + expect(state['0x123']['1']).toEqual(safe2) + }) + + it('creates a safe when updating non-existent safe', () => { + const state = reducer({}, updateSafeInfo({ address: '0xabc' as Address, chainId: '1', info: safe1 })) + expect(state['0xabc']).toEqual({ [safe1.chainId]: safe1 }) + }) + + it('removes a safe', () => { + const initialState = { + '0x123': { [safe1.chainId]: safe1 }, + } + const state = reducer(initialState, removeSafe('0x123' as Address)) + expect(state['0x123']).toBeUndefined() + }) +}) diff --git a/apps/mobile/src/store/__tests__/signersSlice.test.ts b/apps/mobile/src/store/__tests__/signersSlice.test.ts new file mode 100644 index 0000000000..16d099f93a --- /dev/null +++ b/apps/mobile/src/store/__tests__/signersSlice.test.ts @@ -0,0 +1,247 @@ +import signersReducer, { addSigner, addSignerWithEffects, selectSigners, selectTotalSignerCount } from '../signersSlice' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { selectActiveSigner } from '../activeSignerSlice' +import { configureStore } from '@reduxjs/toolkit' +import { rootReducer } from '../index' +import type { RootState } from '../index' +import { faker } from '@faker-js/faker' + +// Helper function to create a test store with proper typing for thunks +const createTestStore = (initialState?: Partial<RootState>) => { + const store = configureStore({ + reducer: rootReducer, + preloadedState: initialState, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: false, // Disable for testing + }), + }) + + // Return store with proper dispatch typing + return store as typeof store & { + dispatch: typeof store.dispatch + } +} + +// Helper function to generate a valid Ethereum address +const generateEthereumAddress = (): `0x${string}` => { + return faker.finance.ethereumAddress() as `0x${string}` +} + +// Helper function to generate AddressInfo +const generateAddressInfo = (overrides?: Partial<AddressInfo>): AddressInfo => ({ + value: generateEthereumAddress(), + name: faker.person.firstName(), + ...overrides, +}) + +describe('signersSlice', () => { + beforeEach(() => { + // Set a seed for consistent test results + faker.seed(123) + }) + + it('adds a signer', () => { + const signer = generateAddressInfo() + const state = signersReducer(undefined, addSigner(signer)) + expect(state[signer.value]).toEqual(signer) + }) + + describe('addSignerWithEffects', () => { + const mockSigner = generateAddressInfo({ name: 'Test Signer' }) + + it('should add signer to the store', async () => { + const store = createTestStore() + + // @ts-ignore: Allow thunk dispatch in test + await store.dispatch(addSignerWithEffects(mockSigner)) + + const state = store.getState() + const signers = selectSigners(state) + + expect(signers[mockSigner.value]).toEqual(mockSigner) + }) + + it('should set active signer when activeSafe exists and no active signer for that safe', async () => { + const safeAddress = generateEthereumAddress() + const mockActiveSafe = { + address: safeAddress, + chainId: faker.number.int({ min: 1, max: 100 }).toString(), + } + + const initialState: Partial<RootState> = { + activeSafe: mockActiveSafe, + activeSigner: {}, // No active signer for this safe + } + + const store = createTestStore(initialState) + + // @ts-ignore: Allow thunk dispatch in test + await store.dispatch(addSignerWithEffects(mockSigner)) + + const state = store.getState() + + // Check that signer was added + const signers = selectSigners(state) + expect(signers[mockSigner.value]).toEqual(mockSigner) + + // Check that active signer was set + const activeSigner = selectActiveSigner(state, safeAddress) + expect(activeSigner).toEqual(mockSigner) + }) + + it('should not set active signer when activeSafe exists but already has an active signer', async () => { + const safeAddress = generateEthereumAddress() + const mockActiveSafe = { + address: safeAddress, + chainId: faker.number.int({ min: 1, max: 100 }).toString(), + } + + const existingActiveSigner = generateAddressInfo({ name: 'Existing Signer' }) + + const initialState: Partial<RootState> = { + activeSafe: mockActiveSafe, + activeSigner: { + [safeAddress]: existingActiveSigner, // Already has an active signer + }, + } + + const store = createTestStore(initialState) + + // @ts-ignore: Allow thunk dispatch in test + await store.dispatch(addSignerWithEffects(mockSigner)) + + const state = store.getState() + + // Check that signer was added + const signers = selectSigners(state) + expect(signers[mockSigner.value]).toEqual(mockSigner) + + // Check that active signer was NOT changed + const activeSigner = selectActiveSigner(state, safeAddress) + expect(activeSigner).toEqual(existingActiveSigner) + expect(activeSigner).not.toEqual(mockSigner) + }) + + it('should not set active signer when activeSafe is null', async () => { + const initialState: Partial<RootState> = { + activeSafe: null, + activeSigner: {}, + } + + const store = createTestStore(initialState) + + // @ts-ignore: Allow thunk dispatch in test + await store.dispatch(addSignerWithEffects(mockSigner)) + + const state = store.getState() + + // Check that signer was added + const signers = selectSigners(state) + expect(signers[mockSigner.value]).toEqual(mockSigner) + + // Check that no active signer was set (activeSigner should remain empty) + expect(state.activeSigner).toEqual({}) + }) + + it('should not set active signer when activeSafe is undefined', async () => { + const initialState: Partial<RootState> = { + activeSafe: undefined, + activeSigner: {}, + } + + const store = createTestStore(initialState) + + // @ts-ignore: Allow thunk dispatch in test + await store.dispatch(addSignerWithEffects(mockSigner)) + + const state = store.getState() + + // Check that signer was added + const signers = selectSigners(state) + expect(signers[mockSigner.value]).toEqual(mockSigner) + + // Check that no active signer was set (activeSigner should remain empty) + expect(state.activeSigner).toEqual({}) + }) + + it('should work correctly with multiple signers and safes', async () => { + const safeAddress1 = generateEthereumAddress() + const safeAddress2 = generateEthereumAddress() + + const mockSafe1 = { + address: safeAddress1, + chainId: faker.number.int({ min: 1, max: 100 }).toString(), + } + + const signer1 = generateAddressInfo({ name: 'Signer 1' }) + const signer2 = generateAddressInfo({ name: 'Signer 2' }) + + const initialState: Partial<RootState> = { + activeSafe: mockSafe1, + activeSigner: {}, + } + + const store = createTestStore(initialState) + + // Add first signer - should become active signer for safe 1 + // @ts-ignore: Allow thunk dispatch in test + await store.dispatch(addSignerWithEffects(signer1)) + + let state = store.getState() + expect(selectSigners(state)[signer1.value]).toEqual(signer1) + expect(selectActiveSigner(state, safeAddress1)).toEqual(signer1) + + // Add second signer - should NOT change active signer for safe 1 + // @ts-ignore: Allow thunk dispatch in test + await store.dispatch(addSignerWithEffects(signer2)) + + state = store.getState() + expect(selectSigners(state)[signer2.value]).toEqual(signer2) + expect(selectActiveSigner(state, safeAddress1)).toEqual(signer1) // Still the first signer + expect(selectActiveSigner(state, safeAddress2)).toBeUndefined() // No active signer for safe 2 + }) + }) + + describe('selectTotalSignerCount', () => { + it('should return 0 for empty signers state', () => { + const state = { signers: {} } as RootState + expect(selectTotalSignerCount(state)).toBe(0) + }) + + it('should return correct count for single signer', () => { + const signer = generateAddressInfo() + const state = { signers: { [signer.value]: signer } } as RootState + expect(selectTotalSignerCount(state)).toBe(1) + }) + + it('should return correct count for multiple signers', () => { + const signer1 = generateAddressInfo() + const signer2 = generateAddressInfo() + const signer3 = generateAddressInfo() + + const state = { + signers: { + [signer1.value]: signer1, + [signer2.value]: signer2, + [signer3.value]: signer3, + }, + } as RootState + + expect(selectTotalSignerCount(state)).toBe(3) + }) + + it('should update count when signers are added via reducer', () => { + let state = signersReducer(undefined, { type: 'INIT' }) + expect(selectTotalSignerCount({ signers: state } as RootState)).toBe(0) + + const signer1 = generateAddressInfo() + state = signersReducer(state, addSigner(signer1)) + expect(selectTotalSignerCount({ signers: state } as RootState)).toBe(1) + + const signer2 = generateAddressInfo() + state = signersReducer(state, addSigner(signer2)) + expect(selectTotalSignerCount({ signers: state } as RootState)).toBe(2) + }) + }) +}) diff --git a/apps/mobile/src/store/activeSafeSlice.ts b/apps/mobile/src/store/activeSafeSlice.ts index 6a1bc19cbc..019c45e767 100644 --- a/apps/mobile/src/store/activeSafeSlice.ts +++ b/apps/mobile/src/store/activeSafeSlice.ts @@ -1,28 +1,27 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { RootState } from '.' -import { mockedActiveAccount } from './constants' import { SafeInfo } from '../types/address' -const initialState: SafeInfo = { - address: mockedActiveAccount.address, - chainId: mockedActiveAccount.chainId, -} +const initialState = null as SafeInfo | null const activeSafeSlice = createSlice({ name: 'activeSafe', initialState, reducers: { - setActiveSafe: (state, action: PayloadAction<SafeInfo>) => { + setActiveSafe: (_, action: PayloadAction<SafeInfo | null>) => { return action.payload }, clearActiveSafe: () => { return initialState }, switchActiveChain: (state, action: PayloadAction<{ chainId: string }>) => { - return { - ...state, - chainId: action.payload.chainId, + if (state !== null) { + return { + ...state, + chainId: action.payload.chainId, + } } + return state }, }, }) diff --git a/apps/mobile/src/store/activeSignerSlice.ts b/apps/mobile/src/store/activeSignerSlice.ts new file mode 100644 index 0000000000..3cb42b6f8b --- /dev/null +++ b/apps/mobile/src/store/activeSignerSlice.ts @@ -0,0 +1,32 @@ +import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit' +import { RootState } from '.' +import { Address, SignerInfo } from '../types/address' + +type ActiveSignerState = Record<Address, SignerInfo> + +const initialState: ActiveSignerState = {} + +const activeSignerSlice = createSlice({ + name: 'activeSigner', + initialState, + reducers: { + setActiveSigner: (state, action: PayloadAction<{ safeAddress: Address; signer: SignerInfo }>) => { + state[action.payload.safeAddress] = action.payload.signer + return state + }, + removeActiveSigner: (state, action: PayloadAction<{ safeAddress: Address }>) => { + const { [action.payload.safeAddress]: _, ...rest } = state + + return rest + }, + }, +}) + +export const { setActiveSigner, removeActiveSigner } = activeSignerSlice.actions + +export const selectActiveSigner = createSelector( + [(state: RootState) => state.activeSigner, (_state: RootState, safeAddress: Address) => safeAddress], + (activeSigner, safeAddress: Address) => activeSigner[safeAddress], +) + +export default activeSignerSlice.reducer diff --git a/apps/mobile/src/store/addressBookSlice.ts b/apps/mobile/src/store/addressBookSlice.ts new file mode 100644 index 0000000000..8086abeb24 --- /dev/null +++ b/apps/mobile/src/store/addressBookSlice.ts @@ -0,0 +1,89 @@ +import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +import { RootState } from '.' + +export type Contact = Omit<AddressInfo, 'name'> & { + name: string + chainIds: string[] +} + +interface AddressBookState { + contacts: Record<string, Contact> + selectedContact: Contact | null +} + +const initialState: AddressBookState = { + contacts: {}, + selectedContact: null, +} + +export const addressBookSlice = createSlice({ + name: 'addressBook', + initialState, + reducers: { + addContact: (state, action: PayloadAction<Contact>) => { + const contact = action.payload + state.contacts[contact.value] = contact + }, + + removeContact: (state, action: PayloadAction<string>) => { + const addressValue = action.payload + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete state.contacts[addressValue] + + if (state.selectedContact?.value === addressValue) { + state.selectedContact = null + } + }, + + selectContact: (state, action: PayloadAction<string | null>) => { + const addressValue = action.payload + state.selectedContact = addressValue ? state.contacts[addressValue] || null : null + }, + + updateContact: (state, action: PayloadAction<Contact>) => { + const contact = action.payload + if (state.contacts[contact.value]) { + state.contacts[contact.value] = { + ...state.contacts[contact.value], + ...contact, + } + + if (state.selectedContact?.value === contact.value) { + state.selectedContact = state.contacts[contact.value] + } + } + }, + + upsertContact: (state, action: PayloadAction<Contact>) => { + const contact = action.payload + state.contacts[contact.value] = contact + }, + + addContacts: (state, action: PayloadAction<Contact[]>) => { + action.payload.forEach((contact) => { + state.contacts[contact.value] = contact + }) + }, + }, +}) + +export const { addContact, removeContact, selectContact, updateContact, addContacts, upsertContact } = + addressBookSlice.actions + +export const selectAddressBookState = (state: RootState) => state.addressBook + +export const selectAllContacts = createSelector(selectAddressBookState, (addressBook) => { + if (!addressBook || !addressBook.contacts) { + return [] + } + return Object.values(addressBook.contacts) +}) + +export const selectContactByAddress = (address: string) => + createSelector(selectAddressBookState, (addressBook): Contact | null => addressBook.contacts[address] || null) + +export const selectTotalContactCount = (state: RootState) => Object.keys(state.addressBook.contacts).length + +export default addressBookSlice.reducer diff --git a/apps/mobile/src/store/biometricsSlice.ts b/apps/mobile/src/store/biometricsSlice.ts new file mode 100644 index 0000000000..8d52fc0116 --- /dev/null +++ b/apps/mobile/src/store/biometricsSlice.ts @@ -0,0 +1,44 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { RootState } from '.' + +interface BiometricsState { + isEnabled: boolean + type: 'FACE_ID' | 'TOUCH_ID' | 'FINGERPRINT' | 'NONE' + isSupported: boolean + userAttempts: number +} + +const initialState: BiometricsState = { + isEnabled: false, + type: 'NONE', + isSupported: false, + userAttempts: 0, +} + +const biometricsSlice = createSlice({ + name: 'biometrics', + initialState, + reducers: { + setBiometricsEnabled: (state, action: PayloadAction<boolean>) => { + state.isEnabled = action.payload + }, + setBiometricsType: (state, action: PayloadAction<BiometricsState['type']>) => { + state.type = action.payload + }, + setBiometricsSupported: (state, action: PayloadAction<boolean>) => { + state.isSupported = action.payload + }, + setUserAttempts: (state, action: PayloadAction<number>) => { + state.userAttempts = action.payload + }, + }, +}) + +export const { setBiometricsEnabled, setBiometricsType, setBiometricsSupported, setUserAttempts } = + biometricsSlice.actions + +export const selectBiometricsEnabled = (state: RootState) => state.biometrics.isEnabled +export const selectBiometricsType = (state: RootState) => state.biometrics.type +export const selectBiometricsSupported = (state: RootState) => state.biometrics.isSupported +export const selectUserAttempts = (state: RootState) => state.biometrics.userAttempts +export default biometricsSlice.reducer diff --git a/apps/mobile/src/store/chains/index.ts b/apps/mobile/src/store/chains/index.ts index f123ccc142..a406d354c7 100644 --- a/apps/mobile/src/store/chains/index.ts +++ b/apps/mobile/src/store/chains/index.ts @@ -13,14 +13,25 @@ const selectChainsData = createSelector(selectChainsResult, (result) => { const { selectAll: selectAllChains, selectById } = chainsAdapter.getSelectors(selectChainsData) export const selectChainById = (state: RootState, chainId: string) => selectById(state, chainId) + +export const selectActiveChain = createSelector( + [selectActiveSafe, (state: RootState) => state], + (activeSafe, state) => { + if (!activeSafe) { + return null + } + return selectChainById(state, activeSafe.chainId) + }, +) + export const selectAllChainsIds = createSelector([selectAllChains], (chains: Chain[]) => - chains.map((chain) => chain.chainId), + (chains || []).map((chain) => chain.chainId), ) + export const selectActiveChainCurrency = createSelector( - [selectActiveSafe, (state: RootState) => state], - (activeSafe, state) => { - const chain = selectChainById(state, activeSafe.chainId) - return chain?.nativeCurrency + [selectActiveChain, (state: RootState) => state], + (activeChain) => { + return activeChain?.nativeCurrency }, ) @@ -30,7 +41,9 @@ export const getChainsByIds = createSelector( (state: RootState) => state, (_state: RootState, chainIds: string[]) => chainIds, ], - (state, chainIds) => chainIds.map((chainId) => selectById(state, chainId)), + (state, chainIds) => { + return (chainIds || []).map((chainId) => selectById(state, chainId)).filter(Boolean) + }, ) export const { useGetChainsConfigQuery } = apiSliceWithChainsConfig diff --git a/apps/mobile/src/store/constants.ts b/apps/mobile/src/store/constants.ts index aa705b94de..67b4839144 100644 --- a/apps/mobile/src/store/constants.ts +++ b/apps/mobile/src/store/constants.ts @@ -57,6 +57,35 @@ export const mockedAccounts = [ queued: 1, threshold: 1, }, + { + address: { + value: '0xF7a47Bf5705572B7EB9cb0F7007C66B770Ea120f', + name: null, + logoUri: null, + }, + chainId: '11155111', + threshold: 2, + owners: [ + { + value: '0x79964FA459D36EbFfc2a2cA66321B689F6E4aC52', + name: null, + logoUri: null, + }, + { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: null, + logoUri: null, + }, + { + value: '0x4cF25c77De50baBAB44c6BcC76D88624DDb3EbBE', + name: null, + logoUri: null, + }, + ], + fiatTotal: '138.558', + queued: 0, + awaitingConfirmation: 0, + }, ] export const mockedChains = [ @@ -307,6 +336,7 @@ export enum STORAGE_IDS { NOTIFICATION_DATE_FORMAT = 'DD/MM/YYYY HH:mm:ss', NOTIFICATIONS_SETTINGS = 'notifications-settings', PN_USER_STORAGE = 'safePnUserStorage', + NOTIFICATION_EXTENSION_DATA = 'notification-extension-data', } export enum STORAGE_TYPES { @@ -324,6 +354,7 @@ export const mapStorageTypeToIds = (id: STORAGE_IDS): STORAGE_TYPES => { case STORAGE_IDS.SAFE_FCM_TOKEN: case STORAGE_IDS.NOTIFICATIONS_SETTINGS: case STORAGE_IDS.PN_USER_STORAGE: + case STORAGE_IDS.NOTIFICATION_EXTENSION_DATA: return STORAGE_TYPES.OBJECT case STORAGE_IDS.PUSH_NOTIFICATIONS_PROMPT_COUNT: return STORAGE_TYPES.NUMBER @@ -342,4 +373,13 @@ export enum PressActionId { OPEN_TRANSACTION_VIEW = 'open-transactions-view-press-action-id', } -export const LAUNCH_ACTIVITY = 'global.safe.mobileapp.ui.MainActivity' +const IS_DEV = process.env.EXPO_PUBLIC_APP_VARIANT === 'development' + +export const LAUNCH_ACTIVITY = IS_DEV ? 'global.safe.mobileapp.dev.MainActivity' : 'global.safe.mobileapp.MainActivity' + +export const ERROR_MSG = 'useDelegateKey: Something went wrong' + +export enum NOTIFICATION_ACCOUNT_TYPE { + REGULAR = 'REGULAR', + OWNER = 'OWNER', +} diff --git a/apps/mobile/src/store/delegatesSlice.ts b/apps/mobile/src/store/delegatesSlice.ts new file mode 100644 index 0000000000..e032a788fc --- /dev/null +++ b/apps/mobile/src/store/delegatesSlice.ts @@ -0,0 +1,136 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { RootState } from '.' +import { selectSafeInfo } from './safesSlice' +import { Address } from '@/src/types/address' + +export interface DelegateInfo { + safe: string | null + delegate: string + delegator: string + label: string +} + +export type DelegatesSliceState = Record<string, Record<string, DelegateInfo>> + +const initialState: DelegatesSliceState = {} + +const delegatesSlice = createSlice({ + name: 'delegates', + initialState, + reducers: { + addDelegate: ( + state, + action: PayloadAction<{ + ownerAddress: string + delegateAddress: string + delegateInfo: DelegateInfo + }>, + ) => { + const { ownerAddress, delegateAddress, delegateInfo } = action.payload + + if (!state[ownerAddress]) { + state[ownerAddress] = {} + } + + state[ownerAddress][delegateAddress] = delegateInfo + }, + + removeDelegate: ( + state, + action: PayloadAction<{ + ownerAddress: string + delegateAddress: string + }>, + ) => { + const { ownerAddress, delegateAddress } = action.payload + + if (state[ownerAddress] && state[ownerAddress][delegateAddress]) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete state[ownerAddress][delegateAddress] + + // Clean up empty owner entries + if (Object.keys(state[ownerAddress]).length === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete state[ownerAddress] + } + } + }, + }, +}) + +export const { addDelegate, removeDelegate } = delegatesSlice.actions + +export const selectDelegates = (state: RootState): DelegatesSliceState => state.delegates +export const selectDelegatesByOwner = (state: Pick<RootState, 'delegates'>, ownerAddress: string) => + state.delegates[ownerAddress] || {} + +/** + * Gets all delegates for all owners of a Safe + * @param state - partial redux state with safes and delegates + * @param safeAddress Safe address to check owners for + * @returns Array of all delegates found for all owners + */ +export const selectAllDelegatesForSafeOwners = ( + state: Pick<RootState, 'safes' | 'delegates'>, + safeAddress: Address, +) => { + const safeInfoItem = selectSafeInfo(state, safeAddress) + if (!safeInfoItem) { + return [] + } + + // Collect all unique owners across all chain deployments + const allOwners = new Set<string>() + Object.values(safeInfoItem).forEach((deployment) => { + deployment.owners.forEach((owner) => allOwners.add(owner.value)) + }) + + // Collect all delegates for all owners + const allDelegates: { owner: string; delegateAddress: string }[] = [] + for (const ownerAddress of allOwners) { + const delegates = selectDelegatesByOwner(state, ownerAddress) + const delegateAddresses = Object.keys(delegates) + + for (const delegateAddress of delegateAddresses) { + allDelegates.push({ owner: ownerAddress, delegateAddress }) + } + } + + return allDelegates +} + +/** + * Finds the first delegate for any owner of a safe + * @param state Redux state (only needs safes and delegates) + * @param safeAddress Safe address to check owners for + * @returns First delegate found for any owner, or null if none found + */ +export const selectFirstDelegateForAnySafeOwner = ( + state: Pick<RootState, 'safes' | 'delegates'>, + safeAddress: Address, +) => { + const safeInfoItem = selectSafeInfo(state, safeAddress) + if (!safeInfoItem) { + return null + } + + // Collect all unique owners across all chain deployments + const allOwners = new Set<string>() + Object.values(safeInfoItem).forEach((deployment) => { + deployment.owners.forEach((owner) => allOwners.add(owner.value)) + }) + + // Check each owner for delegates + for (const ownerAddress of allOwners) { + const delegates = selectDelegatesByOwner(state, ownerAddress) + const delegateAddresses = Object.keys(delegates) + + if (delegateAddresses.length > 0) { + return { owner: ownerAddress, delegateAddress: delegateAddresses[0] } + } + } + + return null +} + +export default delegatesSlice.reducer diff --git a/apps/mobile/src/store/hooks/activeSafe.ts b/apps/mobile/src/store/hooks/activeSafe.ts new file mode 100644 index 0000000000..7cbdd286a2 --- /dev/null +++ b/apps/mobile/src/store/hooks/activeSafe.ts @@ -0,0 +1,11 @@ +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { useAppSelector } from '@/src/store/hooks/index' + +export const useDefinedActiveSafe = () => { + const activeSafe = useAppSelector(selectActiveSafe) + + if (activeSafe === null) { + throw new Error('No active safe selected') + } + return activeSafe +} diff --git a/apps/mobile/src/store/index.ts b/apps/mobile/src/store/index.ts index 0535bf24a7..86d79bd37d 100644 --- a/apps/mobile/src/store/index.ts +++ b/apps/mobile/src/store/index.ts @@ -3,32 +3,70 @@ import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, import { reduxStorage } from './storage' import txHistory from './txHistorySlice' import activeSafe from './activeSafeSlice' +import activeSigner from './activeSignerSlice' import signers from './signersSlice' +import delegates from './delegatesSlice' import myAccounts from './myAccountsSlice' import notifications from './notificationsSlice' +import addressBook from './addressBookSlice' +import settings from './settingsSlice' import safes from './safesSlice' +import safeSubscriptions from './safeSubscriptionsSlice' +import biometrics from './biometricsSlice' import { cgwClient, setBaseUrl } from '@safe-global/store/gateway/cgwClient' import devToolsEnhancer from 'redux-devtools-expo-dev-plugin' import { GATEWAY_URL, isTestingEnv } from '../config/constants' +import { web3API } from './signersBalance' +import { setBaseUrl as setSDKBaseURL } from '@safe-global/safe-gateway-typescript-sdk' +import { createFilter } from '@safe-global/store/utils/persistTransformFilter' +import { setupMobileCookieHandling } from './utils/cookieHandling' +import notificationsMiddleware from './middleware/notifications' +import analyticsMiddleware from './middleware/analytics' +import notificationSyncMiddleware from './middleware/notificationSync' +import { setBackendStore } from '@/src/store/utils/singletonStore' +setSDKBaseURL(GATEWAY_URL) setBaseUrl(GATEWAY_URL) + +// Set up mobile-specific cookie handling +setupMobileCookieHandling() + +const cgwClientFilter = createFilter( + cgwClient.reducerPath, + ['queries.getChainsConfig(undefined)', 'config'], + ['queries.getChainsConfig(undefined)', 'config'], +) + const persistConfig = { key: 'root', version: 1, storage: reduxStorage, - blacklist: [cgwClient.reducerPath, 'myAccounts'], + blacklist: [web3API.reducerPath, 'myAccounts'], + transforms: [cgwClientFilter], } + export const rootReducer = combineReducers({ txHistory, safes, + activeSigner, activeSafe, notifications, + addressBook, myAccounts, signers, + delegates, + settings, + safeSubscriptions, + biometrics, + [web3API.reducerPath]: web3API.reducer, [cgwClient.reducerPath]: cgwClient.reducer, }) -const persistedReducer = persistReducer(persistConfig, rootReducer) +// Define the type for the root reducer +export type RootReducerState = ReturnType<typeof rootReducer> + +// Use the persistReducer with the correct types +const persistedReducer = persistReducer<RootReducerState>(persistConfig, rootReducer) export const makeStore = () => configureStore({ @@ -39,7 +77,13 @@ export const makeStore = () => serializableCheck: { ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], }, - }).concat(cgwClient.middleware), + }).concat( + cgwClient.middleware, + web3API.middleware, + notificationsMiddleware, + analyticsMiddleware, + notificationSyncMiddleware, + ), enhancers: (getDefaultEnhancers) => { if (isTestingEnv) { return getDefaultEnhancers() @@ -50,8 +94,11 @@ export const makeStore = () => }) export const store = makeStore() +// we are going around a circular dependency here +setBackendStore(store) export const persistor = persistStore(store) -export type RootState = ReturnType<typeof store.getState> +export type RootState = ReturnType<typeof rootReducer> export type AppDispatch = typeof store.dispatch +export type AppStore = typeof store diff --git a/apps/mobile/src/store/middleware/__tests__/notificationSync.test.ts b/apps/mobile/src/store/middleware/__tests__/notificationSync.test.ts new file mode 100644 index 0000000000..f2b449c5ae --- /dev/null +++ b/apps/mobile/src/store/middleware/__tests__/notificationSync.test.ts @@ -0,0 +1,325 @@ +import notificationSyncMiddleware from '../notificationSync' +import { addressBookSlice } from '@/src/store/addressBookSlice' +import { apiSliceWithChainsConfig } from '@safe-global/store/gateway/chains' +import { configureStore, Action } from '@reduxjs/toolkit' +import { server } from '@/src/tests/server' +import { http, HttpResponse } from 'msw' +import { setBaseUrl } from '@safe-global/store/gateway/cgwClient' + +jest.mock('@/src/services/notifications/store-sync/sync', () => ({ + syncNotificationExtensionData: jest.fn(), +})) + +import { syncNotificationExtensionData } from '@/src/services/notifications/store-sync/sync' + +const mockSyncNotificationExtensionData = syncNotificationExtensionData as jest.MockedFunction< + typeof syncNotificationExtensionData +> + +// Define test gateway URL +const TEST_GATEWAY_URL = 'https://safe-client.staging.5afe.dev' + +describe('notificationSyncMiddleware', () => { + let store: { getState: jest.Mock; dispatch: jest.Mock } + let next: jest.Mock + let middleware: (action: unknown) => unknown + + beforeAll(() => { + // Set up the base URL like the real app does + setBaseUrl(TEST_GATEWAY_URL) + }) + + beforeEach(() => { + jest.clearAllMocks() + store = { + getState: jest.fn(), + dispatch: jest.fn(), + } + next = jest.fn((action) => action) + middleware = notificationSyncMiddleware(store)(next) + }) + + describe('addressBook actions', () => { + it('should sync when addContact action is dispatched', () => { + const action = addressBookSlice.actions.addContact({ + value: '0x123', + name: 'Test Contact', + chainIds: ['1'], + }) + + middleware(action) + + expect(next).toHaveBeenCalledWith(action) + expect(mockSyncNotificationExtensionData).toHaveBeenCalledTimes(1) + expect(mockSyncNotificationExtensionData).toHaveBeenCalledWith(store) + }) + + it('should sync when removeContact action is dispatched', () => { + const action = addressBookSlice.actions.removeContact('0x123') + + middleware(action) + + expect(next).toHaveBeenCalledWith(action) + expect(mockSyncNotificationExtensionData).toHaveBeenCalledTimes(1) + expect(mockSyncNotificationExtensionData).toHaveBeenCalledWith(store) + }) + + it('should sync when updateContact action is dispatched', () => { + const action = addressBookSlice.actions.updateContact({ + value: '0x123', + name: 'Updated Contact', + chainIds: ['1'], + }) + + middleware(action) + + expect(next).toHaveBeenCalledWith(action) + expect(mockSyncNotificationExtensionData).toHaveBeenCalledTimes(1) + expect(mockSyncNotificationExtensionData).toHaveBeenCalledWith(store) + }) + + it('should NOT sync when selectContact action is dispatched', () => { + const action = addressBookSlice.actions.selectContact('0x123') + + middleware(action) + + expect(next).toHaveBeenCalledWith(action) + expect(mockSyncNotificationExtensionData).not.toHaveBeenCalled() + }) + + it('should sync when addContacts action is dispatched', () => { + const action = addressBookSlice.actions.addContacts([ + { value: '0x123', name: 'Contact 1', chainIds: ['1'] }, + { value: '0x456', name: 'Contact 2', chainIds: ['1'] }, + ]) + + middleware(action) + + expect(next).toHaveBeenCalledWith(action) + expect(mockSyncNotificationExtensionData).toHaveBeenCalledTimes(1) + expect(mockSyncNotificationExtensionData).toHaveBeenCalledWith(store) + }) + + it('should sync when upsertContact action is dispatched', () => { + const action = addressBookSlice.actions.upsertContact({ + value: '0x123', + name: 'Upserted Contact', + chainIds: ['1'], + }) + + middleware(action) + + expect(next).toHaveBeenCalledWith(action) + expect(mockSyncNotificationExtensionData).toHaveBeenCalledTimes(1) + expect(mockSyncNotificationExtensionData).toHaveBeenCalledWith(store) + }) + }) + + describe('chain configuration actions', () => { + let testStore: ReturnType<typeof configureStore> + + beforeEach(() => { + // Ensure base URL is set for RTK Query + setBaseUrl(TEST_GATEWAY_URL) + + // Create a test store with the middleware and RTK Query + testStore = configureStore({ + reducer: { + addressBook: addressBookSlice.reducer, + [apiSliceWithChainsConfig.reducerPath]: apiSliceWithChainsConfig.reducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(apiSliceWithChainsConfig.middleware).concat(notificationSyncMiddleware), + }) + }) + + it('should sync when real getChainsConfig fulfilled action is dispatched', async () => { + // Mock the chains endpoint to return test data + server.use( + http.get(`${TEST_GATEWAY_URL}/v1/chains`, () => { + return HttpResponse.json({ + count: 1, + next: null, + previous: null, + results: [ + { + chainId: '1', + chainName: 'Ethereum', + shortName: 'eth', + l2: false, + description: 'Ethereum Mainnet', + chainLogoUri: null, + rpcUri: { authentication: 'API_KEY_PATH', value: 'https://mainnet.infura.io' }, + safeAppsRpcUri: { authentication: 'API_KEY_PATH', value: 'https://mainnet.infura.io' }, + publicRpcUri: { authentication: 'NO_AUTHENTICATION', value: 'https://rpc.ankr.com/eth' }, + blockExplorerUriTemplate: { + address: 'https://etherscan.io/address/{{address}}', + txHash: 'https://etherscan.io/tx/{{txHash}}', + api: 'https://api.etherscan.io/api?module={{module}}&action={{action}}&address={{address}}&apiKey={{apiKey}}', + }, + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + logoUri: 'https://safe-transaction-assets.staging.5afe.dev/chains/1/currency_logo.png', + }, + transactionService: 'https://safe-transaction-mainnet.staging.5afe.dev', + vpcTransactionService: 'https://safe-transaction-mainnet.staging.5afe.dev', + theme: { textColor: '#001428', backgroundColor: '#DDDDDD' }, + ensRegistryAddress: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', + gasPrice: [ + { + type: 'ORACLE', + uri: 'https://api.etherscan.io/api?module=gastracker&action=gasoracle&apikey=YourApiKeyToken', + gasParameter: 'SafeGasPrice', + gweiFactor: '1000000000.000000000', + }, + ], + disabledWallets: [], + features: [], + }, + ], + }) + }), + ) + + // Dispatch the real RTK Query thunk + await testStore.dispatch(apiSliceWithChainsConfig.endpoints.getChainsConfig.initiate() as unknown as Action) + + // The middleware should have been triggered by the fulfilled action + expect(mockSyncNotificationExtensionData).toHaveBeenCalledTimes(1) + }) + + it('should NOT sync when getChainsConfig fails', async () => { + // Mock the chains endpoint to return an error + server.use( + http.get(`${TEST_GATEWAY_URL}/v1/chains`, () => { + return HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + }), + ) + + // Dispatch the real RTK Query thunk + await testStore.dispatch(apiSliceWithChainsConfig.endpoints.getChainsConfig.initiate() as unknown as Action) + + // The middleware should NOT have been triggered by the rejected action + expect(mockSyncNotificationExtensionData).not.toHaveBeenCalled() + }) + }) + + describe('unrelated actions', () => { + it('should NOT sync for txHistory actions', () => { + const action = { + type: 'txHistory/addTransaction', + payload: { txId: '0x456' }, + } + + middleware(action) + + expect(next).toHaveBeenCalledWith(action) + expect(mockSyncNotificationExtensionData).not.toHaveBeenCalled() + }) + + it('should NOT sync for activeSafe actions', () => { + const action = { + type: 'activeSafe/setActiveSafe', + payload: { address: '0x789', chainId: '1' }, + } + + middleware(action) + + expect(next).toHaveBeenCalledWith(action) + expect(mockSyncNotificationExtensionData).not.toHaveBeenCalled() + }) + + it('should NOT sync for signers actions', () => { + const action = { + type: 'signers/addSigner', + payload: { address: '0xabc' }, + } + + middleware(action) + + expect(next).toHaveBeenCalledWith(action) + expect(mockSyncNotificationExtensionData).not.toHaveBeenCalled() + }) + }) + + describe('middleware flow', () => { + it('should always call next() regardless of sync decision', () => { + const relevantAction = { type: 'addressBook/addContact' } + const irrelevantAction = { type: 'unrelated/action' } + + middleware(relevantAction) + middleware(irrelevantAction) + + expect(next).toHaveBeenCalledTimes(2) + expect(next).toHaveBeenNthCalledWith(1, relevantAction) + expect(next).toHaveBeenNthCalledWith(2, irrelevantAction) + }) + + it('should return the result from next()', () => { + const action = { type: 'test/action' } + const expectedResult = { ...action, processed: true } + next.mockReturnValue(expectedResult) + + const result = middleware(action) + + expect(result).toBe(expectedResult) + }) + + it('should handle actions without type property gracefully', () => { + const action = { payload: 'test' } + + expect(() => middleware(action)).not.toThrow() + expect(next).toHaveBeenCalledWith(action) + expect(mockSyncNotificationExtensionData).not.toHaveBeenCalled() + }) + }) + + describe('performance considerations', () => { + it('should only call syncNotificationExtensionData once per relevant action', () => { + const action = addressBookSlice.actions.addContact({ value: '0x123', name: 'Contact', chainIds: ['1'] }) + + middleware(action) + + expect(mockSyncNotificationExtensionData).toHaveBeenCalledTimes(1) + expect(mockSyncNotificationExtensionData).toHaveBeenCalledWith(store) + }) + + it('should process multiple relevant actions independently', async () => { + // Mock a successful chains response for this test + server.use( + http.get(`${TEST_GATEWAY_URL}/v1/chains`, () => { + return HttpResponse.json({ + count: 1, + next: null, + previous: null, + results: [{ chainId: '1', chainName: 'Ethereum', nativeCurrency: { symbol: 'ETH', decimals: 18 } }], + }) + }), + ) + + // Ensure base URL is set + setBaseUrl(TEST_GATEWAY_URL) + + // Create a test store + const testStore = configureStore({ + reducer: { + addressBook: addressBookSlice.reducer, + [apiSliceWithChainsConfig.reducerPath]: apiSliceWithChainsConfig.reducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(apiSliceWithChainsConfig.middleware).concat(notificationSyncMiddleware), + }) + + // Dispatch addressBook actions (these will be processed by our original middleware) + testStore.dispatch(addressBookSlice.actions.addContact({ value: '0x123', name: 'Contact', chainIds: ['1'] })) + testStore.dispatch(addressBookSlice.actions.removeContact('0x456')) + + // Dispatch RTK Query action + await testStore.dispatch(apiSliceWithChainsConfig.endpoints.getChainsConfig.initiate() as unknown as Action) + + expect(mockSyncNotificationExtensionData).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/apps/mobile/src/store/middleware/analytics.ts b/apps/mobile/src/store/middleware/analytics.ts new file mode 100644 index 0000000000..d8120ad129 --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics.ts @@ -0,0 +1,19 @@ +import type { Middleware } from '@reduxjs/toolkit' +import type { RootState } from '@/src/store' +import { AnalyticsStrategyManager } from '@/src/store/middleware/analytics/AnalyticsStrategyManager' +import { ActionWithPayload } from '@/src/store/utils/strategy/Strategy' +const strategyManager = new AnalyticsStrategyManager() + +const analyticsMiddleware: Middleware = (store) => (next) => (action) => { + const typedAction = action as ActionWithPayload + const prevState = store.getState() as RootState + + const result = next(typedAction) + + // Execute analytics strategies after the action has been processed + strategyManager.executeStrategy(store, typedAction, prevState) + + return result +} + +export default analyticsMiddleware diff --git a/apps/mobile/src/store/middleware/analytics/AnalyticsStrategyManager.ts b/apps/mobile/src/store/middleware/analytics/AnalyticsStrategyManager.ts new file mode 100644 index 0000000000..e0c8d6d025 --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/AnalyticsStrategyManager.ts @@ -0,0 +1,40 @@ +import { MiddlewareAPI, Dispatch } from 'redux' +import { RootState } from '@/src/store' +import { StrategyManager } from '@/src/store/utils/strategy/StrategyManager' +import { TransactionConfirmationStrategy } from '@/src/store/middleware/analytics/strategies/TransactionConfirmationStrategy' +import { SafeViewedStrategy } from '@/src/store/middleware/analytics/strategies/SafeViewedStrategy' +import { SettingsStrategy } from '@/src/store/middleware/analytics/strategies/SettingsStrategy' +import { SafeManagementStrategy } from '@/src/store/middleware/analytics/strategies/SafeManagementStrategy' +import { SignerTrackingStrategy } from '@/src/store/middleware/analytics/strategies/SignerTrackingStrategy' +import { AddressBookTrackingStrategy } from '@/src/store/middleware/analytics/strategies/AddressBookTrackingStrategy' + +export class AnalyticsStrategyManager extends StrategyManager<RootState, MiddlewareAPI<Dispatch, RootState>> { + constructor() { + super() + this.registerDefaultStrategies() + } + + private registerDefaultStrategies(): void { + // Intercept successful addConfirmation mutations + this.registerStrategy('gateway/transactionsAddConfirmationV1/fulfilled', new TransactionConfirmationStrategy()) + + // Intercept activeSafe changes for safe_viewed tracking + this.registerStrategy('activeSafe/setActiveSafe', new SafeViewedStrategy()) + this.registerStrategy('activeSafe/switchActiveChain', new SafeViewedStrategy()) + + // Intercept settings changes for comprehensive settings tracking + this.registerStrategy('settings/updateSettings', new SettingsStrategy()) + + // Intercept safe management actions for safe creation/removal tracking + this.registerStrategy('safes/addSafe', new SafeManagementStrategy()) + this.registerStrategy('safes/removeSafe', new SafeManagementStrategy()) + + // Intercept signer addition actions for signer tracking + this.registerStrategy('signers/addSigner', new SignerTrackingStrategy()) + + // Intercept address book actions for contact tracking + this.registerStrategy('addressBook/addContact', new AddressBookTrackingStrategy()) + this.registerStrategy('addressBook/updateContact', new AddressBookTrackingStrategy()) + this.registerStrategy('addressBook/removeContact', new AddressBookTrackingStrategy()) + } +} diff --git a/apps/mobile/src/store/middleware/analytics/strategies/AddressBookTrackingStrategy.ts b/apps/mobile/src/store/middleware/analytics/strategies/AddressBookTrackingStrategy.ts new file mode 100644 index 0000000000..27487b22b5 --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/strategies/AddressBookTrackingStrategy.ts @@ -0,0 +1,61 @@ +import { RootState } from '@/src/store' +import { MiddlewareAPI, Dispatch } from 'redux' +import { Strategy, ActionWithPayload } from '@/src/store/utils/strategy/Strategy' +import { trackEvent } from '@/src/services/analytics' +import { selectTotalContactCount } from '@/src/store/addressBookSlice' +import { + createContactAddedEvent, + createContactEditedEvent, + createContactRemovedEvent, +} from '@/src/services/analytics/events/addressBook' + +/** + * Strategy to track address book CRUD operations + * Tracks when contacts are added, edited, or removed with total contact counts + */ +export class AddressBookTrackingStrategy implements Strategy<RootState, MiddlewareAPI<Dispatch, RootState>> { + execute(store: MiddlewareAPI<Dispatch, RootState>, action: ActionWithPayload): void { + const actionType = action.type + + if (actionType === 'addressBook/addContact') { + this.trackContactAdded(store.getState()) + } else if (actionType === 'addressBook/updateContact') { + this.trackContactEdited(store.getState()) + } else if (actionType === 'addressBook/removeContact') { + this.trackContactRemoved(store.getState()) + } + } + + private trackContactAdded(state: RootState): void { + try { + const totalContactCount = selectTotalContactCount(state) + trackEvent(createContactAddedEvent(totalContactCount)).catch((error) => { + console.error('[AddressBookTrackingStrategy] Error tracking contact added event:', error) + }) + } catch (error) { + console.error('[AddressBookTrackingStrategy] Error in trackContactAdded:', error) + } + } + + private trackContactEdited(state: RootState): void { + try { + const totalContactCount = selectTotalContactCount(state) + trackEvent(createContactEditedEvent(totalContactCount)).catch((error) => { + console.error('[AddressBookTrackingStrategy] Error tracking contact edited event:', error) + }) + } catch (error) { + console.error('[AddressBookTrackingStrategy] Error in trackContactEdited:', error) + } + } + + private trackContactRemoved(state: RootState): void { + try { + const totalContactCount = selectTotalContactCount(state) + trackEvent(createContactRemovedEvent(totalContactCount)).catch((error) => { + console.error('[AddressBookTrackingStrategy] Error tracking contact removed event:', error) + }) + } catch (error) { + console.error('[AddressBookTrackingStrategy] Error in trackContactRemoved:', error) + } + } +} diff --git a/apps/mobile/src/store/middleware/analytics/strategies/SafeManagementStrategy.ts b/apps/mobile/src/store/middleware/analytics/strategies/SafeManagementStrategy.ts new file mode 100644 index 0000000000..8d063fe40f --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/strategies/SafeManagementStrategy.ts @@ -0,0 +1,31 @@ +import { AnyAction } from '@reduxjs/toolkit' +import { RootState } from '@/src/store' +import { MiddlewareAPI, Dispatch } from 'redux' +import { Strategy } from '@/src/store/utils/strategy/Strategy' +import { createSafeAddedEvent, createSafeRemovedEvent } from '@/src/services/analytics/events/safes' +import { trackEvent } from '@/src/services/analytics/firebaseAnalytics' +import { selectTotalSafeCount } from '@/src/store/safesSlice' + +export class SafeManagementStrategy implements Strategy<RootState, MiddlewareAPI<Dispatch, RootState>> { + execute(store: MiddlewareAPI<Dispatch, RootState>, action: AnyAction, _prevState: RootState): void { + try { + if (action.type === 'safes/addSafe') { + // Calculate total safe count after the action is applied + const currentState = store.getState() + const totalSafeCount = selectTotalSafeCount(currentState) + + const event = createSafeAddedEvent(totalSafeCount) + trackEvent(event) + } else if (action.type === 'safes/removeSafe') { + // Calculate total safe count after the action is applied + const currentState = store.getState() + const totalSafeCount = selectTotalSafeCount(currentState) + + const event = createSafeRemovedEvent(totalSafeCount) + trackEvent(event) + } + } catch (error) { + console.error('Error tracking safe management event:', error) + } + } +} diff --git a/apps/mobile/src/store/middleware/analytics/strategies/SafeViewedStrategy.ts b/apps/mobile/src/store/middleware/analytics/strategies/SafeViewedStrategy.ts new file mode 100644 index 0000000000..734cc2d6fd --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/strategies/SafeViewedStrategy.ts @@ -0,0 +1,44 @@ +import { MiddlewareAPI, Dispatch } from '@reduxjs/toolkit' +import { SafeInfo } from '@/src/types/address' +import { trackEvent } from '@/src/services/analytics' +import { EventType } from '@/src/services/analytics/types' +import type { RootState } from '@/src/store' +import { Strategy, ActionWithPayload } from '@/src/store/utils/strategy/Strategy' + +export class SafeViewedStrategy implements Strategy<RootState> { + execute(store: MiddlewareAPI<Dispatch, RootState>, action: ActionWithPayload, _prevState: RootState): void { + // Track safe_viewed when activeSafe is set with a safe (not null/cleared) + if (action.type === 'activeSafe/setActiveSafe' && action.payload !== null) { + const safeInfo = action.payload as SafeInfo + + trackEvent({ + eventName: EventType.SAFE_OPENED, + eventCategory: 'safe', + eventAction: 'opened', + eventLabel: 'safe_viewed', + chainId: safeInfo.chainId, + }).catch((error) => { + console.error('[SafeViewedStrategy] Error tracking safe_viewed event:', error) + }) + } + + // Track safe_viewed when switching chains (since this changes the safe context) + if (action.type === 'activeSafe/switchActiveChain') { + // Get the current state (after the action has been applied) + const currentState = store.getState() + const safeInfo = currentState.activeSafe + + if (safeInfo) { + trackEvent({ + eventName: EventType.SAFE_OPENED, + eventCategory: 'safe', + eventAction: 'opened', + eventLabel: 'safe_viewed', + chainId: safeInfo.chainId, + }).catch((error) => { + console.error('[SafeViewedStrategy] Error tracking safe_viewed event on chain switch:', error) + }) + } + } + } +} diff --git a/apps/mobile/src/store/middleware/analytics/strategies/SettingsStrategy.ts b/apps/mobile/src/store/middleware/analytics/strategies/SettingsStrategy.ts new file mode 100644 index 0000000000..ad18131797 --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/strategies/SettingsStrategy.ts @@ -0,0 +1,43 @@ +import { RootState } from '@/src/store' +import { MiddlewareAPI, Dispatch } from 'redux' +import { Strategy, ActionWithPayload } from '@/src/store/utils/strategy/Strategy' +import { trackEvent } from '@/src/services/analytics' +import { + createThemeChangeEvent, + createNotificationToggleEvent, + createBiometricsToggleEvent, +} from '@/src/services/analytics/events/settings' +import { ThemePreference } from '@/src/types/theme' + +export class SettingsStrategy implements Strategy<RootState, MiddlewareAPI<Dispatch, RootState>> { + execute(_store: MiddlewareAPI<Dispatch, RootState>, action: ActionWithPayload): void { + // Check if this is a settings update action + if (action.type === 'settings/updateSettings' && action.payload && typeof action.payload === 'object') { + this.trackSettingsChanges(action.payload as Record<string, unknown>) + } + } + + private trackSettingsChanges(payload: Record<string, unknown>): void { + // Track theme preference changes + if ('themePreference' in payload && payload.themePreference) { + const themePreference = payload.themePreference as ThemePreference + trackEvent(createThemeChangeEvent(themePreference)).catch((error) => { + console.error('[SettingsStrategy] Error tracking theme change event:', error) + }) + } + + // Track notification settings changes + if ('notificationsEnabled' in payload && typeof payload.notificationsEnabled === 'boolean') { + trackEvent(createNotificationToggleEvent(payload.notificationsEnabled)).catch((error) => { + console.error('[SettingsStrategy] Error tracking notification toggle event:', error) + }) + } + + // Track biometrics settings changes + if ('biometricsEnabled' in payload && typeof payload.biometricsEnabled === 'boolean') { + trackEvent(createBiometricsToggleEvent(payload.biometricsEnabled)).catch((error) => { + console.error('[SettingsStrategy] Error tracking biometrics toggle event:', error) + }) + } + } +} diff --git a/apps/mobile/src/store/middleware/analytics/strategies/SignerTrackingStrategy.ts b/apps/mobile/src/store/middleware/analytics/strategies/SignerTrackingStrategy.ts new file mode 100644 index 0000000000..0f775424a7 --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/strategies/SignerTrackingStrategy.ts @@ -0,0 +1,30 @@ +import { RootState } from '@/src/store' +import { MiddlewareAPI, Dispatch } from 'redux' +import { Strategy, ActionWithPayload } from '@/src/store/utils/strategy/Strategy' +import { trackEvent } from '@/src/services/analytics' +import { createSignerAddedEvent } from '@/src/services/analytics/events/signers' +import { selectTotalSignerCount } from '@/src/store/signersSlice' + +/** + * Strategy to track signer addition events + * Tracks when signers are added to the app and includes total signer count + */ +export class SignerTrackingStrategy implements Strategy<RootState, MiddlewareAPI<Dispatch, RootState>> { + execute(store: MiddlewareAPI<Dispatch, RootState>, action: ActionWithPayload): void { + // Check if this is a signer addition action + if (action.type === 'signers/addSigner') { + this.trackSignerAddition(store.getState()) + } + } + + private trackSignerAddition(state: RootState): void { + try { + const totalSignerCount = selectTotalSignerCount(state) + trackEvent(createSignerAddedEvent(totalSignerCount)).catch((error) => { + console.error('[SignerTrackingStrategy] Error tracking signer addition event:', error) + }) + } catch (error) { + console.error('[SignerTrackingStrategy] Error in trackSignerAddition:', error) + } + } +} diff --git a/apps/mobile/src/store/middleware/analytics/strategies/TransactionConfirmationStrategy.ts b/apps/mobile/src/store/middleware/analytics/strategies/TransactionConfirmationStrategy.ts new file mode 100644 index 0000000000..a979b045e6 --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/strategies/TransactionConfirmationStrategy.ts @@ -0,0 +1,23 @@ +import { RootState } from '@/src/store' +import { MiddlewareAPI, Dispatch } from 'redux' +import { Strategy, ActionWithPayload } from '@/src/store/utils/strategy/Strategy' +import { trackEvent } from '@/src/services/analytics' +import { getTransactionAnalyticsLabel } from '@/src/services/analytics/utils' +import { createTxConfirmEvent } from '@/src/services/analytics/events/transactions' +import type { Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +export class TransactionConfirmationStrategy implements Strategy<RootState, MiddlewareAPI<Dispatch, RootState>> { + execute(_store: MiddlewareAPI<Dispatch, RootState>, action: ActionWithPayload): void { + const confirmedTransaction = action.payload + + if ( + confirmedTransaction && + typeof confirmedTransaction === 'object' && + 'txInfo' in confirmedTransaction && + confirmedTransaction.txInfo + ) { + const analyticsLabel = getTransactionAnalyticsLabel(confirmedTransaction.txInfo as Transaction['txInfo']) + trackEvent(createTxConfirmEvent(analyticsLabel)) + } + } +} diff --git a/apps/mobile/src/store/middleware/analytics/strategies/__tests__/AddressBookTrackingStrategy.test.ts b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/AddressBookTrackingStrategy.test.ts new file mode 100644 index 0000000000..28659cab49 --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/AddressBookTrackingStrategy.test.ts @@ -0,0 +1,100 @@ +import { AddressBookTrackingStrategy } from '../AddressBookTrackingStrategy' +import { trackEvent } from '@/src/services/analytics' +import { selectTotalContactCount } from '@/src/store/addressBookSlice' +import { RootState } from '@/src/store' + +jest.mock('@/src/services/analytics', () => ({ + trackEvent: jest.fn(), +})) + +jest.mock('@/src/store/addressBookSlice', () => ({ + selectTotalContactCount: jest.fn(), +})) + +const mockTrackEvent = trackEvent as jest.MockedFunction<typeof trackEvent> +const mockSelectTotalContactCount = selectTotalContactCount as jest.MockedFunction<typeof selectTotalContactCount> + +describe('AddressBookTrackingStrategy', () => { + let strategy: AddressBookTrackingStrategy + let mockStore: { getState: jest.Mock; dispatch: jest.Mock } + let mockState: RootState + + beforeEach(() => { + strategy = new AddressBookTrackingStrategy() + mockState = { + addressBook: { + contacts: { + '0x1': { value: '0x1', name: 'Alice', chainIds: ['1'] }, + '0x2': { value: '0x2', name: 'Bob', chainIds: ['1'] }, + }, + selectedContact: null, + }, + } as unknown as RootState + + mockStore = { + getState: jest.fn().mockReturnValue(mockState), + dispatch: jest.fn(), + } + + jest.clearAllMocks() + mockSelectTotalContactCount.mockReturnValue(2) + mockTrackEvent.mockResolvedValue(undefined) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should track contact added event', () => { + const action = { type: 'addressBook/addContact', payload: { value: '0x3', name: 'Charlie', chainIds: ['1'] } } + + strategy.execute(mockStore, action) + + expect(mockSelectTotalContactCount).toHaveBeenCalledWith(mockState) + expect(mockTrackEvent).toHaveBeenCalled() + }) + + it('should track contact edited event', () => { + const action = { + type: 'addressBook/updateContact', + payload: { value: '0x1', name: 'Alice Updated', chainIds: ['1'] }, + } + + strategy.execute(mockStore, action) + + expect(mockSelectTotalContactCount).toHaveBeenCalledWith(mockState) + expect(mockTrackEvent).toHaveBeenCalled() + }) + + it('should track contact removed event', () => { + const action = { type: 'addressBook/removeContact', payload: '0x1' } + + strategy.execute(mockStore, action) + + expect(mockSelectTotalContactCount).toHaveBeenCalledWith(mockState) + expect(mockTrackEvent).toHaveBeenCalled() + }) + + it('should not track events for unrelated actions', () => { + const action = { type: 'some/otherAction', payload: {} } + + strategy.execute(mockStore, action) + + expect(mockSelectTotalContactCount).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should handle errors gracefully', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation() + mockSelectTotalContactCount.mockImplementation(() => { + throw new Error('Selector error') + }) + + const action = { type: 'addressBook/addContact', payload: { value: '0x3', name: 'Charlie', chainIds: ['1'] } } + + expect(() => strategy.execute(mockStore, action)).not.toThrow() + expect(consoleSpy).toHaveBeenCalled() + + consoleSpy.mockRestore() + }) +}) diff --git a/apps/mobile/src/store/middleware/analytics/strategies/__tests__/SafeManagementStrategy.test.ts b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/SafeManagementStrategy.test.ts new file mode 100644 index 0000000000..6697326203 --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/SafeManagementStrategy.test.ts @@ -0,0 +1,310 @@ +import { SafeManagementStrategy } from '../SafeManagementStrategy' +import { RootState } from '@/src/store' +import { MiddlewareAPI, Dispatch } from 'redux' +import * as firebaseAnalytics from '@/src/services/analytics/firebaseAnalytics' +import { createSafeAddedEvent, createSafeRemovedEvent } from '@/src/services/analytics/events/safes' +import { Address } from '@/src/types/address' +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' + +// Mock Firebase Analytics +jest.mock('@/src/services/analytics/firebaseAnalytics') +const mockTrackEvent = firebaseAnalytics.trackEvent as jest.MockedFunction<typeof firebaseAnalytics.trackEvent> + +// Mock the event creation functions +jest.mock('@/src/services/analytics/events/safes') +const mockCreateSafeAddedEvent = createSafeAddedEvent as jest.MockedFunction<typeof createSafeAddedEvent> +const mockCreateSafeRemovedEvent = createSafeRemovedEvent as jest.MockedFunction<typeof createSafeRemovedEvent> + +describe('SafeManagementStrategy', () => { + let strategy: SafeManagementStrategy + let mockStore: MiddlewareAPI<Dispatch, RootState> + let mockGetState: jest.Mock + + const mockSafeOverview: SafeOverview = { + address: { value: '0x123', name: null, logoUri: null }, + chainId: '1', + threshold: 1, + owners: [{ value: '0xowner1', name: null, logoUri: null }], + fiatTotal: '100', + queued: 0, + awaitingConfirmation: null, + } + + beforeEach(() => { + strategy = new SafeManagementStrategy() + mockGetState = jest.fn() + mockStore = { + getState: mockGetState, + dispatch: jest.fn(), + } + + // Reset all mocks + jest.clearAllMocks() + }) + + describe('addSafe action tracking', () => { + it('should track safe addition with correct total count', () => { + const mockState: Partial<RootState> = { + safes: { + '0x123': { '1': mockSafeOverview }, + '0x456': { '1': mockSafeOverview }, + }, + } + mockGetState.mockReturnValue(mockState) + + const mockEvent = { eventName: 'metadata', eventCategory: 'safes', eventAction: 'Safe added', eventLabel: 2 } + mockCreateSafeAddedEvent.mockReturnValue(mockEvent) + + const action = { + type: 'safes/addSafe', + payload: { address: '0x789' as Address, info: { '1': mockSafeOverview } }, + } + + strategy.execute(mockStore, action, {} as RootState) + + expect(mockCreateSafeAddedEvent).toHaveBeenCalledWith(2) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEvent) + }) + + it('should track safe addition with count 1 when first safe is added', () => { + const mockState: Partial<RootState> = { + safes: { + '0x123': { '1': mockSafeOverview }, + }, + } + mockGetState.mockReturnValue(mockState) + + const mockEvent = { eventName: 'metadata', eventCategory: 'safes', eventAction: 'Safe added', eventLabel: 1 } + mockCreateSafeAddedEvent.mockReturnValue(mockEvent) + + const action = { + type: 'safes/addSafe', + payload: { address: '0x123' as Address, info: { '1': mockSafeOverview } }, + } + + strategy.execute(mockStore, action, {} as RootState) + + expect(mockCreateSafeAddedEvent).toHaveBeenCalledWith(1) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEvent) + }) + + it('should track safe addition with count 0 when state is empty', () => { + const mockState: Partial<RootState> = { + safes: {}, + } + mockGetState.mockReturnValue(mockState) + + const mockEvent = { eventName: 'metadata', eventCategory: 'safes', eventAction: 'Safe added', eventLabel: 0 } + mockCreateSafeAddedEvent.mockReturnValue(mockEvent) + + const action = { + type: 'safes/addSafe', + payload: { address: '0x123' as Address, info: { '1': mockSafeOverview } }, + } + + strategy.execute(mockStore, action, {} as RootState) + + expect(mockCreateSafeAddedEvent).toHaveBeenCalledWith(0) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEvent) + }) + }) + + describe('removeSafe action tracking', () => { + it('should track safe removal with correct total count', () => { + const mockState: Partial<RootState> = { + safes: { + '0x456': { '1': mockSafeOverview }, + }, + } + mockGetState.mockReturnValue(mockState) + + const mockEvent = { eventName: 'metadata', eventCategory: 'safes', eventAction: 'Safe removed', eventLabel: 1 } + mockCreateSafeRemovedEvent.mockReturnValue(mockEvent) + + const action = { + type: 'safes/removeSafe', + payload: '0x123' as Address, + } + + strategy.execute(mockStore, action, {} as RootState) + + expect(mockCreateSafeRemovedEvent).toHaveBeenCalledWith(1) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEvent) + }) + + it('should track safe removal with count 0 when all safes are removed', () => { + const mockState: Partial<RootState> = { + safes: {}, + } + mockGetState.mockReturnValue(mockState) + + const mockEvent = { eventName: 'metadata', eventCategory: 'safes', eventAction: 'Safe removed', eventLabel: 0 } + mockCreateSafeRemovedEvent.mockReturnValue(mockEvent) + + const action = { + type: 'safes/removeSafe', + payload: '0x123' as Address, + } + + strategy.execute(mockStore, action, {} as RootState) + + expect(mockCreateSafeRemovedEvent).toHaveBeenCalledWith(0) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEvent) + }) + + it('should track safe removal correctly with multiple remaining safes', () => { + const mockState: Partial<RootState> = { + safes: { + '0x123': { '1': mockSafeOverview }, + '0x456': { '1': mockSafeOverview }, + '0x789': { '1': mockSafeOverview }, + }, + } + mockGetState.mockReturnValue(mockState) + + const mockEvent = { eventName: 'metadata', eventCategory: 'safes', eventAction: 'Safe removed', eventLabel: 3 } + mockCreateSafeRemovedEvent.mockReturnValue(mockEvent) + + const action = { + type: 'safes/removeSafe', + payload: '0xabc' as Address, + } + + strategy.execute(mockStore, action, {} as RootState) + + expect(mockCreateSafeRemovedEvent).toHaveBeenCalledWith(3) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEvent) + }) + }) + + describe('irrelevant action handling', () => { + it('should not track events for unrelated actions', () => { + const mockState: Partial<RootState> = { + safes: { '0x123': { '1': mockSafeOverview } }, + } + mockGetState.mockReturnValue(mockState) + + const action = { + type: 'some/other/action', + payload: {}, + } + + strategy.execute(mockStore, action, {} as RootState) + + expect(mockCreateSafeAddedEvent).not.toHaveBeenCalled() + expect(mockCreateSafeRemovedEvent).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should not track events for updateSafeInfo actions', () => { + const mockState: Partial<RootState> = { + safes: { '0x123': { '1': mockSafeOverview } }, + } + mockGetState.mockReturnValue(mockState) + + const action = { + type: 'safes/updateSafeInfo', + payload: { address: '0x123' as Address, chainId: '1', info: mockSafeOverview }, + } + + strategy.execute(mockStore, action, {} as RootState) + + expect(mockCreateSafeAddedEvent).not.toHaveBeenCalled() + expect(mockCreateSafeRemovedEvent).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should not track events for setSafes actions', () => { + const mockState: Partial<RootState> = { + safes: { '0x123': { '1': mockSafeOverview } }, + } + mockGetState.mockReturnValue(mockState) + + const action = { + type: 'safes/setSafes', + payload: { '0x123': { '1': mockSafeOverview } }, + } + + strategy.execute(mockStore, action, {} as RootState) + + expect(mockCreateSafeAddedEvent).not.toHaveBeenCalled() + expect(mockCreateSafeRemovedEvent).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + it('should handle errors gracefully and not throw', () => { + const mockState: Partial<RootState> = { + safes: { '0x123': { '1': mockSafeOverview } }, + } + mockGetState.mockReturnValue(mockState) + + // Mock trackEvent to throw an error + mockTrackEvent.mockImplementation(() => { + throw new Error('Firebase error') + }) + + const mockEvent = { eventName: 'metadata', eventCategory: 'safes', eventAction: 'Safe added', eventLabel: 1 } + mockCreateSafeAddedEvent.mockReturnValue(mockEvent) + + const action = { + type: 'safes/addSafe', + payload: { address: '0x789' as Address, info: { '1': mockSafeOverview } }, + } + + // Should not throw + expect(() => { + strategy.execute(mockStore, action, {} as RootState) + }).not.toThrow() + + expect(mockCreateSafeAddedEvent).toHaveBeenCalled() + expect(mockTrackEvent).toHaveBeenCalled() + }) + + it('should handle errors when event creation fails', () => { + const mockState: Partial<RootState> = { + safes: { '0x123': { '1': mockSafeOverview } }, + } + mockGetState.mockReturnValue(mockState) + + // Mock event creation to throw an error + mockCreateSafeAddedEvent.mockImplementation(() => { + throw new Error('Event creation error') + }) + + const action = { + type: 'safes/addSafe', + payload: { address: '0x789' as Address, info: { '1': mockSafeOverview } }, + } + + // Should not throw + expect(() => { + strategy.execute(mockStore, action, {} as RootState) + }).not.toThrow() + + expect(mockCreateSafeAddedEvent).toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should handle errors when getState fails', () => { + // Mock getState to throw an error + mockGetState.mockImplementation(() => { + throw new Error('State access error') + }) + + const action = { + type: 'safes/addSafe', + payload: { address: '0x789' as Address, info: { '1': mockSafeOverview } }, + } + + // Should not throw + expect(() => { + strategy.execute(mockStore, action, {} as RootState) + }).not.toThrow() + + expect(mockCreateSafeAddedEvent).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/mobile/src/store/middleware/analytics/strategies/__tests__/SafeViewedStrategy.test.ts b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/SafeViewedStrategy.test.ts new file mode 100644 index 0000000000..7b3f2f30b1 --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/SafeViewedStrategy.test.ts @@ -0,0 +1,186 @@ +import { SafeViewedStrategy } from '../SafeViewedStrategy' +import { trackEvent } from '@/src/services/analytics' +import { EventType } from '@/src/services/analytics/types' +import type { RootState } from '@/src/store' +import type { ActionWithPayload } from '@/src/store/utils/strategy/Strategy' +import type { SafeInfo } from '@/src/types/address' +import { MiddlewareAPI, Dispatch } from '@reduxjs/toolkit' + +jest.mock('@/src/services/analytics', () => ({ + trackEvent: jest.fn(), +})) + +const mockTrackEvent = jest.mocked(trackEvent) + +describe('SafeViewedStrategy', () => { + let strategy: SafeViewedStrategy + let mockStore: MiddlewareAPI<Dispatch, RootState> + let mockGetState: jest.Mock + let prevState: RootState + + beforeEach(() => { + strategy = new SafeViewedStrategy() + mockGetState = jest.fn() + mockStore = { + dispatch: jest.fn(), + getState: mockGetState, + } as unknown as MiddlewareAPI<Dispatch, RootState> + + prevState = {} as RootState + + mockTrackEvent.mockResolvedValue(undefined) + jest.clearAllMocks() + }) + + describe('setActiveSafe action', () => { + it('should track safe_viewed event when activeSafe is set with a safe', () => { + const mockSafeInfo: SafeInfo = { + address: '0x123', + chainId: '1', + } + + const action: ActionWithPayload = { + type: 'activeSafe/setActiveSafe', + payload: mockSafeInfo, + } + + strategy.execute(mockStore, action, prevState) + + expect(mockTrackEvent).toHaveBeenCalledWith({ + eventName: EventType.SAFE_OPENED, + eventCategory: 'safe', + eventAction: 'opened', + eventLabel: 'safe_viewed', + chainId: '1', + }) + }) + + it('should not track event when activeSafe is set to null', () => { + const action: ActionWithPayload = { + type: 'activeSafe/setActiveSafe', + payload: null, + } + + strategy.execute(mockStore, action, prevState) + + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should handle tracking errors gracefully', () => { + const trackingError = new Error('Analytics service unavailable') + mockTrackEvent.mockRejectedValueOnce(trackingError) + + const mockSafeInfo: SafeInfo = { + address: '0x123', + chainId: '1', + } + + const action: ActionWithPayload = { + type: 'activeSafe/setActiveSafe', + payload: mockSafeInfo, + } + + // Should not throw even if trackEvent rejects + expect(() => strategy.execute(mockStore, action, prevState)).not.toThrow() + + expect(mockTrackEvent).toHaveBeenCalledWith({ + eventName: EventType.SAFE_OPENED, + eventCategory: 'safe', + eventAction: 'opened', + eventLabel: 'safe_viewed', + chainId: '1', + }) + }) + }) + + describe('switchActiveChain action', () => { + it('should track safe_viewed event when switching chains with active safe', () => { + const mockSafeInfo: SafeInfo = { + address: '0x456', + chainId: '137', + } + + const mockState = { + activeSafe: mockSafeInfo, + } as RootState + + mockGetState.mockReturnValue(mockState) + + const action: ActionWithPayload = { + type: 'activeSafe/switchActiveChain', + payload: '137', + } + + strategy.execute(mockStore, action, prevState) + + expect(mockTrackEvent).toHaveBeenCalledWith({ + eventName: EventType.SAFE_OPENED, + eventCategory: 'safe', + eventAction: 'opened', + eventLabel: 'safe_viewed', + chainId: '137', + }) + }) + + it('should not track event when switching chains with no active safe', () => { + const mockState = { + activeSafe: null, + } as RootState + + mockGetState.mockReturnValue(mockState) + + const action: ActionWithPayload = { + type: 'activeSafe/switchActiveChain', + payload: '137', + } + + strategy.execute(mockStore, action, prevState) + + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should handle tracking errors gracefully on chain switch', () => { + const trackingError = new Error('Network error') + mockTrackEvent.mockRejectedValueOnce(trackingError) + + const mockSafeInfo: SafeInfo = { + address: '0x789', + chainId: '100', + } + + const mockState = { + activeSafe: mockSafeInfo, + } as RootState + + mockGetState.mockReturnValue(mockState) + + const action: ActionWithPayload = { + type: 'activeSafe/switchActiveChain', + payload: '100', + } + + expect(() => strategy.execute(mockStore, action, prevState)).not.toThrow() + + expect(mockTrackEvent).toHaveBeenCalledWith({ + eventName: EventType.SAFE_OPENED, + eventCategory: 'safe', + eventAction: 'opened', + eventLabel: 'safe_viewed', + chainId: '100', + }) + }) + }) + + describe('unrelated actions', () => { + it('should not track events for unrelated actions', () => { + const action: ActionWithPayload = { + type: 'someOther/action', + payload: { data: 'test' }, + } + + strategy.execute(mockStore, action, prevState) + + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/mobile/src/store/middleware/analytics/strategies/__tests__/SettingsStrategy.test.ts b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/SettingsStrategy.test.ts new file mode 100644 index 0000000000..1ba052f0c6 --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/SettingsStrategy.test.ts @@ -0,0 +1,383 @@ +import { SettingsStrategy } from '../SettingsStrategy' +import { trackEvent } from '@/src/services/analytics' +import { + createThemeChangeEvent, + createNotificationToggleEvent, + createBiometricsToggleEvent, +} from '@/src/services/analytics/events/settings' +import { EventType } from '@/src/services/analytics/types' +import type { RootState } from '@/src/store' +import type { ActionWithPayload } from '@/src/store/utils/strategy/Strategy' +import { MiddlewareAPI, Dispatch } from '@reduxjs/toolkit' + +// Mock the dependencies +jest.mock('@/src/services/analytics', () => ({ + trackEvent: jest.fn(), +})) + +jest.mock('@/src/services/analytics/events/settings', () => ({ + createThemeChangeEvent: jest.fn(), + createNotificationToggleEvent: jest.fn(), + createBiometricsToggleEvent: jest.fn(), +})) + +const mockTrackEvent = jest.mocked(trackEvent) +const mockCreateThemeChangeEvent = jest.mocked(createThemeChangeEvent) +const mockCreateNotificationToggleEvent = jest.mocked(createNotificationToggleEvent) +const mockCreateBiometricsToggleEvent = jest.mocked(createBiometricsToggleEvent) + +describe('SettingsStrategy', () => { + let strategy: SettingsStrategy + let mockStore: MiddlewareAPI<Dispatch, RootState> + + beforeEach(() => { + strategy = new SettingsStrategy() + mockStore = { + dispatch: jest.fn(), + getState: jest.fn(), + } + + jest.clearAllMocks() + }) + + describe('theme change tracking', () => { + it('should track theme change event when themePreference is updated', () => { + const action: ActionWithPayload = { + type: 'settings/updateSettings', + payload: { + themePreference: 'dark', + }, + } + + const mockEventData = { + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Theme preference changed', + eventLabel: 'dark', + } + + mockCreateThemeChangeEvent.mockReturnValue(mockEventData) + mockTrackEvent.mockResolvedValue(undefined) + + strategy.execute(mockStore, action) + + expect(mockCreateThemeChangeEvent).toHaveBeenCalledWith('dark') + expect(mockTrackEvent).toHaveBeenCalledWith(mockEventData) + }) + + it('should track all theme preferences', () => { + const themes = ['light', 'dark', 'auto'] + + themes.forEach((theme) => { + const action: ActionWithPayload = { + type: 'settings/updateSettings', + payload: { + themePreference: theme, + }, + } + + const mockEventData = { + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Theme preference changed', + eventLabel: theme, + } + + mockCreateThemeChangeEvent.mockReturnValue(mockEventData) + + strategy.execute(mockStore, action) + + expect(mockCreateThemeChangeEvent).toHaveBeenCalledWith(theme) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEventData) + }) + }) + }) + + describe('notification tracking', () => { + it('should track notification enable event', () => { + const action: ActionWithPayload = { + type: 'settings/updateSettings', + payload: { + notificationsEnabled: true, + }, + } + + const mockEventData = { + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Notifications toggled', + eventLabel: true, + } + + mockCreateNotificationToggleEvent.mockReturnValue(mockEventData) + + strategy.execute(mockStore, action) + + expect(mockCreateNotificationToggleEvent).toHaveBeenCalledWith(true) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEventData) + }) + + it('should track notification disable event', () => { + const action: ActionWithPayload = { + type: 'settings/updateSettings', + payload: { + notificationsEnabled: false, + }, + } + + const mockEventData = { + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Notifications toggled', + eventLabel: false, + } + + mockCreateNotificationToggleEvent.mockReturnValue(mockEventData) + + strategy.execute(mockStore, action) + + expect(mockCreateNotificationToggleEvent).toHaveBeenCalledWith(false) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEventData) + }) + }) + + describe('biometrics tracking', () => { + it('should track biometrics enable event', () => { + const action: ActionWithPayload = { + type: 'settings/updateSettings', + payload: { + biometricsEnabled: true, + }, + } + + const mockEventData = { + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Biometrics toggled', + eventLabel: true, + } + + mockCreateBiometricsToggleEvent.mockReturnValue(mockEventData) + + strategy.execute(mockStore, action) + + expect(mockCreateBiometricsToggleEvent).toHaveBeenCalledWith(true) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEventData) + }) + + it('should track biometrics disable event', () => { + const action: ActionWithPayload = { + type: 'settings/updateSettings', + payload: { + biometricsEnabled: false, + }, + } + + const mockEventData = { + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Biometrics toggled', + eventLabel: false, + } + + mockCreateBiometricsToggleEvent.mockReturnValue(mockEventData) + + strategy.execute(mockStore, action) + + expect(mockCreateBiometricsToggleEvent).toHaveBeenCalledWith(false) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEventData) + }) + }) + + describe('multiple settings tracking', () => { + it('should track multiple settings changes in one action', () => { + const action: ActionWithPayload = { + type: 'settings/updateSettings', + payload: { + themePreference: 'dark', + notificationsEnabled: true, + biometricsEnabled: false, + }, + } + + const mockThemeEvent = { + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Theme preference changed', + eventLabel: 'dark', + } + + const mockNotificationEvent = { + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Notifications toggled', + eventLabel: true, + } + + const mockBiometricsEvent = { + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Biometrics toggled', + eventLabel: false, + } + + mockCreateThemeChangeEvent.mockReturnValue(mockThemeEvent) + mockCreateNotificationToggleEvent.mockReturnValue(mockNotificationEvent) + mockCreateBiometricsToggleEvent.mockReturnValue(mockBiometricsEvent) + + strategy.execute(mockStore, action) + + expect(mockCreateThemeChangeEvent).toHaveBeenCalledWith('dark') + expect(mockCreateNotificationToggleEvent).toHaveBeenCalledWith(true) + expect(mockCreateBiometricsToggleEvent).toHaveBeenCalledWith(false) + expect(mockTrackEvent).toHaveBeenCalledTimes(3) + expect(mockTrackEvent).toHaveBeenCalledWith(mockThemeEvent) + expect(mockTrackEvent).toHaveBeenCalledWith(mockNotificationEvent) + expect(mockTrackEvent).toHaveBeenCalledWith(mockBiometricsEvent) + }) + + it('should handle settings update with non-tracked properties without tracking', () => { + const action: ActionWithPayload = { + type: 'settings/updateSettings', + payload: { + onboardingVersionSeen: '1.0.0', + someOtherSetting: 'value', + }, + } + + strategy.execute(mockStore, action) + + expect(mockCreateThemeChangeEvent).not.toHaveBeenCalled() + expect(mockCreateNotificationToggleEvent).not.toHaveBeenCalled() + expect(mockCreateBiometricsToggleEvent).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should handle mixed tracked and non-tracked settings', () => { + const action: ActionWithPayload = { + type: 'settings/updateSettings', + payload: { + themePreference: 'light', + onboardingVersionSeen: '1.0.0', + notificationsEnabled: true, + someOtherSetting: 'value', + }, + } + + const mockThemeEvent = { + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Theme preference changed', + eventLabel: 'light', + } + + const mockNotificationEvent = { + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Notifications toggled', + eventLabel: true, + } + + mockCreateThemeChangeEvent.mockReturnValue(mockThemeEvent) + mockCreateNotificationToggleEvent.mockReturnValue(mockNotificationEvent) + + strategy.execute(mockStore, action) + + expect(mockCreateThemeChangeEvent).toHaveBeenCalledWith('light') + expect(mockCreateNotificationToggleEvent).toHaveBeenCalledWith(true) + expect(mockCreateBiometricsToggleEvent).not.toHaveBeenCalled() + expect(mockTrackEvent).toHaveBeenCalledTimes(2) + }) + }) + + describe('edge cases', () => { + it('should not track event for non-settings actions', () => { + const action: ActionWithPayload = { + type: 'some/other/action', + payload: { + themePreference: 'dark', + }, + } + + strategy.execute(mockStore, action) + + expect(mockCreateThemeChangeEvent).not.toHaveBeenCalled() + expect(mockCreateNotificationToggleEvent).not.toHaveBeenCalled() + expect(mockCreateBiometricsToggleEvent).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should not track event when payload is null', () => { + const action: ActionWithPayload = { + type: 'settings/updateSettings', + payload: null, + } + + strategy.execute(mockStore, action) + + expect(mockCreateThemeChangeEvent).not.toHaveBeenCalled() + expect(mockCreateNotificationToggleEvent).not.toHaveBeenCalled() + expect(mockCreateBiometricsToggleEvent).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should not track event when payload is not an object', () => { + const action: ActionWithPayload = { + type: 'settings/updateSettings', + payload: 'invalid payload', + } + + strategy.execute(mockStore, action) + + expect(mockCreateThemeChangeEvent).not.toHaveBeenCalled() + expect(mockCreateNotificationToggleEvent).not.toHaveBeenCalled() + expect(mockCreateBiometricsToggleEvent).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should handle trackEvent errors gracefully', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() + + const action: ActionWithPayload = { + type: 'settings/updateSettings', + payload: { + themePreference: 'dark', + notificationsEnabled: true, + biometricsEnabled: false, + }, + } + + mockCreateThemeChangeEvent.mockReturnValue({ + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Theme preference changed', + eventLabel: 'dark', + }) + + mockCreateNotificationToggleEvent.mockReturnValue({ + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Notifications toggled', + eventLabel: true, + }) + + mockCreateBiometricsToggleEvent.mockReturnValue({ + eventName: EventType.META, + eventCategory: 'settings', + eventAction: 'Biometrics toggled', + eventLabel: false, + }) + + mockTrackEvent.mockRejectedValue(new Error('Analytics service unavailable')) + + // Should not throw even if trackEvent fails + expect(() => { + strategy.execute(mockStore, action) + }).not.toThrow() + + expect(mockTrackEvent).toHaveBeenCalledTimes(3) + + consoleErrorSpy.mockRestore() + }) + }) +}) diff --git a/apps/mobile/src/store/middleware/analytics/strategies/__tests__/SignerTrackingStrategy.test.ts b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/SignerTrackingStrategy.test.ts new file mode 100644 index 0000000000..4017a41c7c --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/SignerTrackingStrategy.test.ts @@ -0,0 +1,210 @@ +import { SignerTrackingStrategy } from '../SignerTrackingStrategy' +import { trackEvent } from '@/src/services/analytics' +import { createSignerAddedEvent } from '@/src/services/analytics/events/signers' +import { selectTotalSignerCount } from '@/src/store/signersSlice' +import { EventType } from '@/src/services/analytics/types' +import { ActionWithPayload } from '@/src/store/utils/strategy/Strategy' +import { MiddlewareAPI, Dispatch } from 'redux' +import { RootState } from '@/src/store' + +jest.mock('@/src/services/analytics', () => ({ + trackEvent: jest.fn(), +})) + +jest.mock('@/src/services/analytics/events/signers', () => ({ + createSignerAddedEvent: jest.fn(), +})) + +jest.mock('@/src/store/signersSlice', () => ({ + selectTotalSignerCount: jest.fn(), +})) + +describe('SignerTrackingStrategy', () => { + let strategy: SignerTrackingStrategy + let mockStore: MiddlewareAPI<Dispatch, RootState> + let mockTrackEvent: jest.MockedFunction<typeof trackEvent> + let mockCreateSignerAddedEvent: jest.MockedFunction<typeof createSignerAddedEvent> + let mockSelectTotalSignerCount: jest.MockedFunction<typeof selectTotalSignerCount> + + const mockState = { + signers: { + '0x123': { value: '0x123', name: 'Signer 1' }, + '0x456': { value: '0x456', name: 'Signer 2' }, + }, + } as unknown as RootState + + beforeEach(() => { + strategy = new SignerTrackingStrategy() + mockStore = { + getState: jest.fn().mockReturnValue(mockState), + dispatch: jest.fn(), + } + mockTrackEvent = trackEvent as jest.MockedFunction<typeof trackEvent> + mockCreateSignerAddedEvent = createSignerAddedEvent as jest.MockedFunction<typeof createSignerAddedEvent> + mockSelectTotalSignerCount = selectTotalSignerCount as jest.MockedFunction<typeof selectTotalSignerCount> + + jest.clearAllMocks() + + // Setup default mock returns + mockSelectTotalSignerCount.mockReturnValue(2) + mockCreateSignerAddedEvent.mockReturnValue({ + eventName: EventType.META, + eventCategory: 'signers', + eventAction: 'Signer added', + eventLabel: '2', + }) + mockTrackEvent.mockResolvedValue(undefined) + }) + + describe('execute', () => { + it('should track signer addition when action type is signers/addSigner', () => { + const action: ActionWithPayload = { + type: 'signers/addSigner', + payload: { value: '0x789', name: 'New Signer' }, + } + + strategy.execute(mockStore, action) + + expect(mockSelectTotalSignerCount).toHaveBeenCalledWith(mockState) + expect(mockCreateSignerAddedEvent).toHaveBeenCalledWith(2) + expect(mockTrackEvent).toHaveBeenCalledWith({ + eventName: EventType.META, + eventCategory: 'signers', + eventAction: 'Signer added', + eventLabel: '2', + }) + }) + + it('should not track when action type is not signers/addSigner', () => { + const action: ActionWithPayload = { + type: 'some/otherAction', + payload: {}, + } + + strategy.execute(mockStore, action) + + expect(mockSelectTotalSignerCount).not.toHaveBeenCalled() + expect(mockCreateSignerAddedEvent).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should handle different signer counts correctly', () => { + // Test with 1 signer + mockSelectTotalSignerCount.mockReturnValue(1) + mockCreateSignerAddedEvent.mockReturnValue({ + eventName: EventType.META, + eventCategory: 'signers', + eventAction: 'Signer added', + eventLabel: '1', + }) + + const action: ActionWithPayload = { + type: 'signers/addSigner', + payload: { value: '0x123', name: 'First Signer' }, + } + + strategy.execute(mockStore, action) + + expect(mockCreateSignerAddedEvent).toHaveBeenCalledWith(1) + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventLabel: '1', + }), + ) + }) + + it('should handle tracking errors gracefully', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation() + mockTrackEvent.mockRejectedValue(new Error('Tracking failed')) + + const action: ActionWithPayload = { + type: 'signers/addSigner', + payload: { value: '0x789', name: 'New Signer' }, + } + + strategy.execute(mockStore, action) + + expect(mockTrackEvent).toHaveBeenCalled() + // Wait for the async error handling + setImmediate(() => { + expect(consoleSpy).toHaveBeenCalledWith( + '[SignerTrackingStrategy] Error tracking signer addition event:', + expect.any(Error), + ) + }) + + consoleSpy.mockRestore() + }) + + it('should handle selector errors gracefully', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation() + mockSelectTotalSignerCount.mockImplementation(() => { + throw new Error('Selector failed') + }) + + const action: ActionWithPayload = { + type: 'signers/addSigner', + payload: { value: '0x789', name: 'New Signer' }, + } + + strategy.execute(mockStore, action) + + expect(consoleSpy).toHaveBeenCalledWith( + '[SignerTrackingStrategy] Error in trackSignerAddition:', + expect.any(Error), + ) + expect(mockTrackEvent).not.toHaveBeenCalled() + + consoleSpy.mockRestore() + }) + + it('should handle zero signers correctly', () => { + mockSelectTotalSignerCount.mockReturnValue(0) + mockCreateSignerAddedEvent.mockReturnValue({ + eventName: EventType.META, + eventCategory: 'signers', + eventAction: 'Signer added', + eventLabel: '0', + }) + + const action: ActionWithPayload = { + type: 'signers/addSigner', + payload: { value: '0x123', name: 'First Signer' }, + } + + strategy.execute(mockStore, action) + + expect(mockCreateSignerAddedEvent).toHaveBeenCalledWith(0) + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventLabel: '0', + }), + ) + }) + + it('should handle large signer counts correctly', () => { + const largeCount = 150 + mockSelectTotalSignerCount.mockReturnValue(largeCount) + mockCreateSignerAddedEvent.mockReturnValue({ + eventName: EventType.META, + eventCategory: 'signers', + eventAction: 'Signer added', + eventLabel: largeCount.toString(), + }) + + const action: ActionWithPayload = { + type: 'signers/addSigner', + payload: { value: '0x789', name: 'Signer 150' }, + } + + strategy.execute(mockStore, action) + + expect(mockCreateSignerAddedEvent).toHaveBeenCalledWith(largeCount) + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventLabel: largeCount.toString(), + }), + ) + }) + }) +}) diff --git a/apps/mobile/src/store/middleware/analytics/strategies/__tests__/TransactionConfirmationStrategy.test.ts b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/TransactionConfirmationStrategy.test.ts new file mode 100644 index 0000000000..29d3bab7af --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/TransactionConfirmationStrategy.test.ts @@ -0,0 +1,265 @@ +import { TransactionConfirmationStrategy } from '../TransactionConfirmationStrategy' +import { trackEvent } from '@/src/services/analytics' +import { createTxConfirmEvent } from '@/src/services/analytics/events/transactions' +import { ANALYTICS_LABELS } from '@/src/services/analytics/constants' +import { EventType } from '@/src/services/analytics/types' +import type { RootState } from '@/src/store' +import type { ActionWithPayload } from '@/src/store/utils/strategy/Strategy' +import { MiddlewareAPI, Dispatch } from '@reduxjs/toolkit' + +jest.mock('@/src/services/analytics', () => ({ + trackEvent: jest.fn(), +})) + +jest.mock('@/src/services/analytics/events/transactions', () => ({ + createTxConfirmEvent: jest.fn(), +})) + +jest.mock('@/src/services/analytics/types', () => ({ + ...jest.requireActual('@/src/services/analytics/types'), +})) + +const mockTrackEvent = jest.mocked(trackEvent) +const mockCreateTxConfirmEvent = jest.mocked(createTxConfirmEvent) + +describe('TransactionConfirmationStrategy', () => { + let strategy: TransactionConfirmationStrategy + let mockStore: MiddlewareAPI<Dispatch, RootState> + + beforeEach(() => { + strategy = new TransactionConfirmationStrategy() + mockStore = { + dispatch: jest.fn(), + getState: jest.fn(), + } as unknown as MiddlewareAPI<Dispatch, RootState> + + jest.clearAllMocks() + }) + + describe('transaction confirmation', () => { + it('should track transaction confirmation event with correct transaction type', () => { + const mockTransaction = { + txInfo: { + type: 'Transfer', + sender: { value: '0xSender' }, + recipient: { value: '0xRecipient' }, + direction: 'OUTGOING', + transferInfo: { + type: 'ERC20', + tokenAddress: '0xToken', + value: '1000000000000000000', + imitation: false, + }, + }, + id: 'tx123', + timestamp: Date.now(), + txStatus: 'SUCCESS', + } + + const action: ActionWithPayload = { + type: 'someAction/fulfilled', + payload: mockTransaction, + } + + const mockEventData = { + eventName: EventType.TX_CONFIRMED, + eventCategory: 'transactions', + eventAction: 'Confirm transaction', + eventLabel: ANALYTICS_LABELS.TRANSFER_TYPES.ERC20, + } + + // Mock the function calls + mockCreateTxConfirmEvent.mockReturnValue(mockEventData) + + strategy.execute(mockStore, action) + + expect(mockCreateTxConfirmEvent).toHaveBeenCalledWith(ANALYTICS_LABELS.TRANSFER_TYPES.ERC20) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEventData) + }) + + it('should handle custom transaction types', () => { + const mockTransaction = { + txInfo: { + type: 'Custom', + to: { value: '0xContract' }, + dataSize: '68', + value: '0', + isCancellation: false, + }, + id: 'tx456', + timestamp: Date.now(), + txStatus: 'SUCCESS', + } + + const action: ActionWithPayload = { + type: 'transactions/confirm/fulfilled', + payload: mockTransaction, + } + + const mockEventData = { + eventName: EventType.TX_CONFIRMED, + eventCategory: 'transactions', + eventAction: 'Confirm transaction', + eventLabel: ANALYTICS_LABELS.BASE_TYPES.Custom, + } + + mockCreateTxConfirmEvent.mockReturnValue(mockEventData) + + strategy.execute(mockStore, action) + + expect(mockCreateTxConfirmEvent).toHaveBeenCalledWith(ANALYTICS_LABELS.BASE_TYPES.Custom) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEventData) + }) + + it('should not track event when payload does not have txInfo', () => { + const action: ActionWithPayload = { + type: 'someAction/fulfilled', + payload: { + id: 'tx789', + timestamp: Date.now(), + // missing txInfo + }, + } + + strategy.execute(mockStore, action) + + expect(mockCreateTxConfirmEvent).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should not track event when payload is null', () => { + const action: ActionWithPayload = { + type: 'someAction/fulfilled', + payload: null, + } + + strategy.execute(mockStore, action) + + expect(mockCreateTxConfirmEvent).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should not track event when payload is not an object', () => { + const action: ActionWithPayload = { + type: 'someAction/fulfilled', + payload: 'string payload', + } + + strategy.execute(mockStore, action) + + expect(mockCreateTxConfirmEvent).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('should handle NFT transfer transactions', () => { + const mockTransaction = { + txInfo: { + type: 'Transfer', + sender: { value: '0xSender' }, + recipient: { value: '0xRecipient' }, + direction: 'OUTGOING', + transferInfo: { + type: 'ERC721', + tokenAddress: '0xNFTContract', + tokenId: '123', + }, + }, + id: 'nft_tx', + timestamp: Date.now(), + txStatus: 'SUCCESS', + } + + const action: ActionWithPayload = { + type: 'nft/transfer/fulfilled', + payload: mockTransaction, + } + + const mockEventData = { + eventName: EventType.TX_CONFIRMED, + eventCategory: 'transactions', + eventAction: 'Confirm transaction', + eventLabel: ANALYTICS_LABELS.TRANSFER_TYPES.ERC721, + } + + mockCreateTxConfirmEvent.mockReturnValue(mockEventData) + + strategy.execute(mockStore, action) + + expect(mockCreateTxConfirmEvent).toHaveBeenCalledWith(ANALYTICS_LABELS.TRANSFER_TYPES.ERC721) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEventData) + }) + + it('should handle settings change transactions', () => { + const mockTransaction = { + txInfo: { + type: 'SettingsChange', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [], + }, + settingsInfo: { + type: 'ADD_OWNER', + owner: { value: '0xNewOwner' }, + threshold: 2, + }, + }, + id: 'settings_tx', + timestamp: Date.now(), + txStatus: 'SUCCESS', + } + + const action: ActionWithPayload = { + type: 'settings/change/fulfilled', + payload: mockTransaction, + } + + const mockEventData = { + eventName: EventType.TX_CONFIRMED, + eventCategory: 'transactions', + eventAction: 'Confirm transaction', + eventLabel: ANALYTICS_LABELS.SETTINGS_TYPES.ADD_OWNER, + } + + mockCreateTxConfirmEvent.mockReturnValue(mockEventData) + + strategy.execute(mockStore, action) + + expect(mockCreateTxConfirmEvent).toHaveBeenCalledWith(ANALYTICS_LABELS.SETTINGS_TYPES.ADD_OWNER) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEventData) + }) + + it('should handle rejection transactions', () => { + const mockTransaction = { + txInfo: { + type: 'Custom', + to: { value: '0xContract' }, + dataSize: '0', + value: '0', + isCancellation: true, + }, + id: 'rejection_tx', + timestamp: Date.now(), + txStatus: 'SUCCESS', + } + + const action: ActionWithPayload = { + type: 'rejection/fulfilled', + payload: mockTransaction, + } + + const mockEventData = { + eventName: EventType.TX_CONFIRMED, + eventCategory: 'transactions', + eventAction: 'Confirm transaction', + eventLabel: ANALYTICS_LABELS.ENHANCED.rejection, + } + + mockCreateTxConfirmEvent.mockReturnValue(mockEventData) + + strategy.execute(mockStore, action) + + expect(mockCreateTxConfirmEvent).toHaveBeenCalledWith(ANALYTICS_LABELS.ENHANCED.rejection) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEventData) + }) + }) +}) diff --git a/apps/mobile/src/store/middleware/analytics/strategies/index.ts b/apps/mobile/src/store/middleware/analytics/strategies/index.ts new file mode 100644 index 0000000000..65447c4b47 --- /dev/null +++ b/apps/mobile/src/store/middleware/analytics/strategies/index.ts @@ -0,0 +1,2 @@ +export * from '@/src/store/middleware/analytics/strategies/TransactionConfirmationStrategy' +export * from '@/src/store/middleware/analytics/strategies/SafeViewedStrategy' diff --git a/apps/mobile/src/store/middleware/notificationSync.ts b/apps/mobile/src/store/middleware/notificationSync.ts new file mode 100644 index 0000000000..f46a3a2c89 --- /dev/null +++ b/apps/mobile/src/store/middleware/notificationSync.ts @@ -0,0 +1,30 @@ +import type { Middleware } from '@reduxjs/toolkit' +import { syncNotificationExtensionData } from '@/src/services/notifications/store-sync/sync' +import { apiSliceWithChainsConfig } from '@safe-global/store/gateway/chains' +import { addressBookSlice } from '@/src/store/addressBookSlice' +import type { AppStore } from '@/src/store' + +const notificationSyncMiddleware: Middleware = (store) => (next) => (action) => { + const result = next(action) + + if (shouldSyncNotificationData(action)) { + syncNotificationExtensionData(store as AppStore) + } + + return result +} + +function shouldSyncNotificationData(action: unknown): boolean { + return ( + // AddressBook slice actions that modify contacts data + addressBookSlice.actions.addContact.match(action) || + addressBookSlice.actions.removeContact.match(action) || + addressBookSlice.actions.updateContact.match(action) || + addressBookSlice.actions.addContacts.match(action) || + addressBookSlice.actions.upsertContact.match(action) || + // Chain configuration from RTK Query + apiSliceWithChainsConfig.endpoints.getChainsConfig.matchFulfilled(action) + ) +} + +export default notificationSyncMiddleware diff --git a/apps/mobile/src/store/middleware/notifications.ts b/apps/mobile/src/store/middleware/notifications.ts new file mode 100644 index 0000000000..bdd0a8ec4f --- /dev/null +++ b/apps/mobile/src/store/middleware/notifications.ts @@ -0,0 +1,18 @@ +import type { Middleware, AnyAction } from '@reduxjs/toolkit' +import type { RootState } from '@/src/store' +import { NotificationStrategyManager } from '@/src/store/middleware/notifications/NotificationStrategyManager' + +const strategyManager = new NotificationStrategyManager() + +const notificationsMiddleware: Middleware = (store) => (next) => (action) => { + const typedAction = action as AnyAction + const prevState = store.getState() as RootState + + const result = next(typedAction) + + strategyManager.executeStrategy(store, typedAction, prevState) + + return result +} + +export default notificationsMiddleware diff --git a/apps/mobile/src/store/middleware/notifications/NotificationStrategyManager.ts b/apps/mobile/src/store/middleware/notifications/NotificationStrategyManager.ts new file mode 100644 index 0000000000..45ab7e6e45 --- /dev/null +++ b/apps/mobile/src/store/middleware/notifications/NotificationStrategyManager.ts @@ -0,0 +1,26 @@ +import { MiddlewareAPI, Dispatch } from 'redux' +import { RootState } from '@/src/store' +import { addSafe, removeSafe } from '@/src/store/safesSlice' +import { addDelegate } from '@/src/store/delegatesSlice' +import { toggleAppNotifications } from '@/src/store/notificationsSlice' +import { StrategyManager } from '@/src/store/utils/strategy/StrategyManager' +import { + AddSafeStrategy, + RemoveSafeStrategy, + AddDelegateStrategy, + ToggleAppNotificationsStrategy, +} from '@/src/store/middleware/notifications/strategies' + +export class NotificationStrategyManager extends StrategyManager<RootState, MiddlewareAPI<Dispatch, RootState>> { + constructor() { + super() + this.registerDefaultStrategies() + } + + private registerDefaultStrategies(): void { + this.registerStrategy(addSafe.type, new AddSafeStrategy()) + this.registerStrategy(removeSafe.type, new RemoveSafeStrategy()) + this.registerStrategy(addDelegate.type, new AddDelegateStrategy()) + this.registerStrategy(toggleAppNotifications.type, new ToggleAppNotificationsStrategy()) + } +} diff --git a/apps/mobile/src/store/middleware/notifications/strategies/AddDelegateStrategy.ts b/apps/mobile/src/store/middleware/notifications/strategies/AddDelegateStrategy.ts new file mode 100644 index 0000000000..b378452a51 --- /dev/null +++ b/apps/mobile/src/store/middleware/notifications/strategies/AddDelegateStrategy.ts @@ -0,0 +1,42 @@ +import { AnyAction } from '@reduxjs/toolkit' +import { RootState } from '@/src/store' +import { MiddlewareAPI, Dispatch } from 'redux' +import { subscribeSafe } from '@/src/services/notifications/SubscriptionManager' +import { selectAllChainsIds } from '@/src/store/chains' +import { selectAllSafes } from '@/src/store/safesSlice' +import { selectSafeSubscriptionStatus } from '@/src/store/safeSubscriptionsSlice' +import { Strategy } from '@/src/store/utils/strategy/Strategy' + +export class AddDelegateStrategy implements Strategy<RootState, MiddlewareAPI<Dispatch, RootState>> { + execute(store: MiddlewareAPI<Dispatch, RootState>, action: AnyAction): void { + const { ownerAddress, delegateInfo } = action.payload + const notificationsEnabled = store.getState().notifications.isAppNotificationsEnabled + + if (notificationsEnabled) { + const chainIds = selectAllChainsIds(store.getState()) + const safes = selectAllSafes(store.getState()) + const state = store.getState() + + Object.entries(safes).forEach(([safeAddress, chainDeployments]) => { + // Get all owners across all chain deployments + const allOwners = new Set<string>() + Object.values(chainDeployments).forEach((deployment) => { + deployment.owners.forEach((owner) => allOwners.add(owner.value)) + }) + + const isTargetSafe = delegateInfo.safe ? delegateInfo.safe === safeAddress : allOwners.has(ownerAddress) + + if (isTargetSafe) { + // Only subscribe if the Safe is already subscribed for notifications on at least one chain + const isSafeSubscribedOnAnyChain = chainIds.some( + (chainId) => selectSafeSubscriptionStatus(state, safeAddress, chainId) !== false, + ) + + if (isSafeSubscribedOnAnyChain) { + subscribeSafe(store, safeAddress, chainIds) + } + } + }) + } + } +} diff --git a/apps/mobile/src/store/middleware/notifications/strategies/AddSafeStrategy.ts b/apps/mobile/src/store/middleware/notifications/strategies/AddSafeStrategy.ts new file mode 100644 index 0000000000..788259ce74 --- /dev/null +++ b/apps/mobile/src/store/middleware/notifications/strategies/AddSafeStrategy.ts @@ -0,0 +1,17 @@ +import { AnyAction } from '@reduxjs/toolkit' +import { RootState } from '@/src/store' +import { MiddlewareAPI, Dispatch } from 'redux' +import { subscribeSafe } from '@/src/services/notifications/SubscriptionManager' +import { selectAllChainsIds } from '@/src/store/chains' +import { Strategy } from '@/src/store/utils/strategy/Strategy' + +export class AddSafeStrategy implements Strategy<RootState, MiddlewareAPI<Dispatch, RootState>> { + execute(store: MiddlewareAPI<Dispatch, RootState>, action: AnyAction): void { + const { address } = action.payload + const notificationsEnabled = store.getState().notifications.isAppNotificationsEnabled + if (notificationsEnabled) { + const chainIds = selectAllChainsIds(store.getState()) + subscribeSafe(store, address, chainIds) + } + } +} diff --git a/apps/mobile/src/store/middleware/notifications/strategies/RemoveSafeStrategy.ts b/apps/mobile/src/store/middleware/notifications/strategies/RemoveSafeStrategy.ts new file mode 100644 index 0000000000..dd13abc440 --- /dev/null +++ b/apps/mobile/src/store/middleware/notifications/strategies/RemoveSafeStrategy.ts @@ -0,0 +1,18 @@ +import { AnyAction } from '@reduxjs/toolkit' +import { RootState } from '@/src/store' +import { MiddlewareAPI, Dispatch } from 'redux' +import { unsubscribeSafe } from '@/src/services/notifications/SubscriptionManager' +import { selectAllChainsIds } from '@/src/store/chains' +import { Strategy } from '@/src/store/utils/strategy/Strategy' + +export class RemoveSafeStrategy implements Strategy<RootState, MiddlewareAPI<Dispatch, RootState>> { + execute(store: MiddlewareAPI<Dispatch, RootState>, action: AnyAction, prevState: RootState): void { + const address = action.payload + const safeInfo = prevState.safes[address] + const chainIds = selectAllChainsIds(store.getState()) + + if (safeInfo) { + unsubscribeSafe(store, address, chainIds) + } + } +} diff --git a/apps/mobile/src/store/middleware/notifications/strategies/ToggleAppNotificationsStrategy.ts b/apps/mobile/src/store/middleware/notifications/strategies/ToggleAppNotificationsStrategy.ts new file mode 100644 index 0000000000..d628db1401 --- /dev/null +++ b/apps/mobile/src/store/middleware/notifications/strategies/ToggleAppNotificationsStrategy.ts @@ -0,0 +1,30 @@ +import { AnyAction } from '@reduxjs/toolkit' +import { RootState } from '@/src/store' +import { MiddlewareAPI, Dispatch } from 'redux' +import { subscribeSafe, unsubscribeSafe } from '@/src/services/notifications/SubscriptionManager' +import { selectAllChainsIds } from '@/src/store/chains' +import { selectAllSafes } from '@/src/store/safesSlice' +import { Strategy } from '@/src/store/utils/strategy/Strategy' + +export class ToggleAppNotificationsStrategy implements Strategy<RootState, MiddlewareAPI<Dispatch, RootState>> { + execute(store: MiddlewareAPI<Dispatch, RootState>, _action: AnyAction, prevState: RootState): void { + const prevEnabled = prevState.notifications.isAppNotificationsEnabled + const nextEnabled = store.getState().notifications.isAppNotificationsEnabled + + if (prevEnabled === nextEnabled) { + return + } + + const safes = Object.values(selectAllSafes(store.getState())) + const chainIds = selectAllChainsIds(store.getState()) + + safes.forEach((safe) => { + const safeAddress = Object.values(safe)[0].address.value + if (nextEnabled) { + subscribeSafe(store, safeAddress, chainIds) + } else { + unsubscribeSafe(store, safeAddress, chainIds) + } + }) + } +} diff --git a/apps/mobile/src/store/middleware/notifications/strategies/index.ts b/apps/mobile/src/store/middleware/notifications/strategies/index.ts new file mode 100644 index 0000000000..c9acf1df11 --- /dev/null +++ b/apps/mobile/src/store/middleware/notifications/strategies/index.ts @@ -0,0 +1,4 @@ +export * from './AddSafeStrategy' +export * from './RemoveSafeStrategy' +export * from './AddDelegateStrategy' +export * from './ToggleAppNotificationsStrategy' diff --git a/apps/mobile/src/store/myAccountsSlice.ts b/apps/mobile/src/store/myAccountsSlice.ts index 53de4ffa06..9253acc50c 100644 --- a/apps/mobile/src/store/myAccountsSlice.ts +++ b/apps/mobile/src/store/myAccountsSlice.ts @@ -1,4 +1,4 @@ -import { createSlice } from '@reduxjs/toolkit' +import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { RootState } from '.' const initialState = { @@ -12,10 +12,13 @@ const myAccountsSlice = createSlice({ toggleMode: (state) => { state.isEdit = !state.isEdit }, + setEditMode: (state, action: PayloadAction<boolean>) => { + state.isEdit = action.payload + }, }, }) -export const { toggleMode } = myAccountsSlice.actions +export const { toggleMode, setEditMode } = myAccountsSlice.actions export const selectMyAccountsMode = (state: RootState) => state.myAccounts.isEdit diff --git a/apps/mobile/src/store/notificationsSlice.ts b/apps/mobile/src/store/notificationsSlice.ts index b9564ea0b4..a81aca2afb 100644 --- a/apps/mobile/src/store/notificationsSlice.ts +++ b/apps/mobile/src/store/notificationsSlice.ts @@ -1,7 +1,16 @@ import { createSlice } from '@reduxjs/toolkit' import { RootState } from '.' -const initialState = { +export interface NotificationsSliceItem { + isDeviceNotificationsEnabled: boolean + isAppNotificationsEnabled: boolean + fcmToken: string | null + remoteMessages: string[] + promptAttempts: number + lastTimePromptAttempted: number | null +} + +const initialState: NotificationsSliceItem = { isDeviceNotificationsEnabled: false, isAppNotificationsEnabled: false, fcmToken: null, diff --git a/apps/mobile/src/store/safeSubscriptionsSlice.ts b/apps/mobile/src/store/safeSubscriptionsSlice.ts new file mode 100644 index 0000000000..2e5a631eac --- /dev/null +++ b/apps/mobile/src/store/safeSubscriptionsSlice.ts @@ -0,0 +1,36 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { RootState } from '.' + +type SafeAddress = string +type ChainId = string + +export type SafeSubscriptionsState = Record<SafeAddress, Record<ChainId, boolean>> + +const initialState: SafeSubscriptionsState = {} + +const safeSubscriptionsSlice = createSlice({ + name: 'safeSubscriptions', + initialState, + reducers: { + setSafeSubscriptionStatus: ( + state, + action: PayloadAction<{ safeAddress: string; chainId: string; subscribed: boolean }>, + ) => { + const { safeAddress, chainId, subscribed } = action.payload + if (!state[safeAddress]) { + state[safeAddress] = {} + } + state[safeAddress][chainId] = subscribed + }, + }, +}) + +export const { setSafeSubscriptionStatus } = safeSubscriptionsSlice.actions + +export const selectSafeSubscriptionStatus = ( + state: RootState, + safeAddress: string, + chainId: string, +): boolean | undefined => state.safeSubscriptions[safeAddress]?.[chainId] + +export default safeSubscriptionsSlice.reducer diff --git a/apps/mobile/src/store/safesSlice.ts b/apps/mobile/src/store/safesSlice.ts index 4e62999aa7..a7af2d6b08 100644 --- a/apps/mobile/src/store/safesSlice.ts +++ b/apps/mobile/src/store/safesSlice.ts @@ -1,61 +1,81 @@ import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit' import { RootState } from '.' -import { mockedAccounts, mockedActiveAccount, mockedActiveSafeInfo } from './constants' import { Address } from '@/src/types/address' import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { additionalSafesRtkApi } from '@safe-global/store/gateway/safes' -export type SafesSliceItem = { - SafeInfo: SafeOverview - chains: string[] -} - +export type SafesSliceItem = Record<string, SafeOverview> export type SafesSlice = Record<Address, SafesSliceItem> -const initialState: SafesSlice = { - [mockedActiveAccount.address]: { - SafeInfo: mockedActiveSafeInfo, - chains: [mockedActiveAccount.chainId], - }, - [mockedAccounts[1].address.value]: { - SafeInfo: mockedAccounts[1], - chains: [mockedAccounts[1].chainId], - }, - [mockedAccounts[2].address.value]: { - SafeInfo: mockedAccounts[2], - chains: [mockedAccounts[2].chainId], - }, - [mockedAccounts[3].address.value]: { - SafeInfo: mockedAccounts[3], - chains: [mockedAccounts[3].chainId], - }, -} +const initialState: SafesSlice = {} -const activeSafeSlice = createSlice({ +const safesSlice = createSlice({ name: 'safes', initialState, reducers: { - updateSafeInfo: (state, action: PayloadAction<{ address: Address; item: SafesSliceItem }>) => { - state[action.payload.address] = action.payload.item - return state + addSafe: (state, action: PayloadAction<{ address: Address; info: SafesSliceItem }>) => { + state[action.payload.address] = action.payload.info + }, + updateSafeInfo: (state, action: PayloadAction<{ address: Address; chainId: string; info: SafeOverview }>) => { + if (!state[action.payload.address]) { + state[action.payload.address] = {} + } + state[action.payload.address][action.payload.chainId] = action.payload.info }, - setSafes: (_state, action: PayloadAction<Record<Address, SafesSliceItem>>) => { + setSafes: (_state, action: PayloadAction<SafesSlice>) => { return action.payload }, removeSafe: (state, action: PayloadAction<Address>) => { - const filteredSafes = Object.values(state).filter((safe) => safe.SafeInfo.address.value !== action.payload) - const newState = filteredSafes.reduce((acc, safe) => ({ ...acc, [safe.SafeInfo.address.value]: safe }), {}) - - return newState + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete state[action.payload] }, }, + extraReducers: (builder) => { + builder.addMatcher(additionalSafesRtkApi.endpoints.safesGetOverviewForMany.matchFulfilled, (state, action) => { + const data = action.payload + if (!data?.length) { + return + } + + // Process each safe in the response individually + for (const safeOverview of data) { + const address = safeOverview.address.value as Address + + if (!state[address]) { + continue // Skip if safe doesn't exist in state + } + + const current = state[address] || {} + // Update the specific chain for this safe + state[address] = { + ...current, + [safeOverview.chainId]: safeOverview, + } + } + }) + }, }) -export const { updateSafeInfo, setSafes, removeSafe } = activeSafeSlice.actions +export const { addSafe, updateSafeInfo, setSafes, removeSafe } = safesSlice.actions export const selectAllSafes = (state: RootState) => state.safes export const selectSafeInfo = createSelector( - [selectAllSafes, (_state, activeSafeAddress: Address) => activeSafeAddress], - (safes: SafesSlice, activeSafeAddress: Address) => safes[activeSafeAddress], + [selectAllSafes, (_state, address: Address) => address], + (safes: SafesSlice, address: Address): SafesSliceItem | undefined => safes[address], ) -export default activeSafeSlice.reducer +export const selectSafeChains = createSelector([selectSafeInfo], (safe): string[] => (safe ? Object.keys(safe) : [])) + +export const selectSafeFiatTotal = createSelector([selectSafeInfo], (safe) => { + if (!safe) { + return '0' + } + const total = Object.values(safe).reduce((sum, info) => sum + parseFloat(info.fiatTotal), 0) + return total.toString() +}) + +export const selectTotalSafeCount = createSelector([selectAllSafes], (safes): number => { + return Object.keys(safes).length +}) + +export default safesSlice.reducer diff --git a/apps/mobile/src/store/settingsSlice.ts b/apps/mobile/src/store/settingsSlice.ts new file mode 100644 index 0000000000..2fc8f212d8 --- /dev/null +++ b/apps/mobile/src/store/settingsSlice.ts @@ -0,0 +1,72 @@ +import { ThemePreference } from '@/src/types/theme' +import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit' +import { RootState } from '.' +import merge from 'lodash/merge' + +import type { EnvState } from '@safe-global/store/settingsSlice' + +export interface SettingsState { + onboardingVersionSeen: string + themePreference: ThemePreference + currency: string + env: EnvState +} + +const initialState: SettingsState = { + onboardingVersionSeen: '', + themePreference: 'auto' as ThemePreference, + currency: 'usd', + env: { + rpc: {}, + tenderly: { + url: '', + accessToken: '', + }, + }, +} + +const settingsSlice = createSlice({ + name: 'settings', + initialState, + reducers: { + updateSettings(state, action: PayloadAction<Partial<SettingsState>>) { + return { ...state, ...action.payload } + }, + resetSettings() { + return initialState + }, + setCurrency: (state, { payload }: PayloadAction<SettingsState['currency']>) => { + state.currency = payload + }, + setRpc: (state, { payload }: PayloadAction<{ chainId: string; rpc: string }>) => { + const { chainId, rpc } = payload + if (rpc) { + state.env.rpc[chainId] = rpc + } else { + const { [chainId]: _, ...rest } = state.env.rpc + state.env.rpc = rest + } + }, + setTenderly: (state, { payload }: PayloadAction<EnvState['tenderly']>) => { + state.env.tenderly = merge({}, state.env.tenderly, payload) + }, + }, +}) + +export const selectSettings = (state: RootState, setting: keyof SettingsState) => state.settings[setting] + +export const selectSettingsState = (state: RootState) => state.settings + +export const selectCurrency = createSelector( + selectSettingsState, + (settings) => settings.currency || initialState.currency, +) + +export const selectRpc = createSelector(selectSettingsState, (settings) => { + return settings?.env?.rpc +}) + +export const selectTenderly = createSelector(selectSettingsState, (settings) => settings?.env?.tenderly) + +export const { updateSettings, resetSettings, setCurrency } = settingsSlice.actions +export default settingsSlice.reducer diff --git a/apps/mobile/src/store/signersBalance.ts b/apps/mobile/src/store/signersBalance.ts new file mode 100644 index 0000000000..b7e3b5e8bd --- /dev/null +++ b/apps/mobile/src/store/signersBalance.ts @@ -0,0 +1,44 @@ +import { createApi } from '@reduxjs/toolkit/query/react' +import { createWeb3ReadOnly } from '../services/web3' +import { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +const noopBaseQuery = async () => ({ data: null }) + +const createBadRequestError = (message: string) => ({ + error: { status: 400, statusText: 'Bad Request', data: message }, +}) + +export const web3API = createApi({ + reducerPath: 'web3API', + baseQuery: noopBaseQuery, + endpoints: (builder) => ({ + getBalances: builder.query<Record<string, string>, { addresses: string[]; chain: ChainInfo }>({ + async queryFn({ addresses, chain }) { + try { + const provider = createWeb3ReadOnly(chain) + + if (!provider) { + return createBadRequestError('Failed to create web3 provider') + } + + const balances = await Promise.all( + addresses.map(async (address) => { + const balance = await provider.getBalance(address) + return [address, balance.toString()] + }), + ) + + return { data: Object.fromEntries(balances) } + } catch (error) { + return createBadRequestError( + `Failed to fetch balances: ${error instanceof Error ? error.message : 'Unknown error'}`, + ) + } + }, + }), + }), +}) + +// Export hooks for usage in functional components, which are +// auto-generated based on the defined endpoints +export const { useGetBalancesQuery } = web3API diff --git a/apps/mobile/src/store/signersSlice.ts b/apps/mobile/src/store/signersSlice.ts index f3a6e316d4..f6c2b40099 100644 --- a/apps/mobile/src/store/signersSlice.ts +++ b/apps/mobile/src/store/signersSlice.ts @@ -1,7 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' -import { RootState } from '.' +import { AppDispatch, RootState } from '.' +import { setActiveSigner } from './activeSignerSlice' const initialState: Record<string, AddressInfo> = {} @@ -14,11 +15,34 @@ const signersSlice = createSlice({ return state }, + removeSigner: (state, action: PayloadAction<string>) => { + const { [action.payload]: _, ...newState } = state + return newState + }, }, }) -export const { addSigner } = signersSlice.actions +export const addSignerWithEffects = + (signerInfo: AddressInfo) => async (dispatch: AppDispatch, getState: () => RootState) => { + const { activeSafe, activeSigner } = getState() + + dispatch(addSigner(signerInfo)) + + if (activeSafe && !activeSigner[activeSafe.address]) { + dispatch(setActiveSigner({ safeAddress: activeSafe.address, signer: signerInfo })) + } + } + +export const { addSigner, removeSigner } = signersSlice.actions export const selectSigners = (state: RootState) => state.signers +export const selectSignersByAddress = (state: RootState) => state.signers + +export const selectSignerHasPrivateKey = (address: string) => (state: RootState) => { + return !!state.signers[address] +} + +export const selectTotalSignerCount = (state: RootState) => Object.keys(state.signers).length + export default signersSlice.reducer diff --git a/apps/mobile/src/store/utils/cookieHandling.test.ts b/apps/mobile/src/store/utils/cookieHandling.test.ts new file mode 100644 index 0000000000..16adf1dad1 --- /dev/null +++ b/apps/mobile/src/store/utils/cookieHandling.test.ts @@ -0,0 +1,253 @@ +import { isAuthVerifyEndpoint, parseCookies, formatCookieHeader } from './cookieHandling' + +describe('isAuthVerifyEndpoint', () => { + it('returns true for URLs containing /v1/auth/verify', () => { + expect(isAuthVerifyEndpoint('https://example.com/v1/auth/verify')).toBe(true) + expect(isAuthVerifyEndpoint('/v1/auth/verify')).toBe(true) + expect(isAuthVerifyEndpoint('/v1/auth/verify?token=abc123')).toBe(true) + }) + + it('returns false for URLs not containing /v1/auth/verify', () => { + expect(isAuthVerifyEndpoint('https://example.com/v1/auth')).toBe(false) + expect(isAuthVerifyEndpoint('/v1/auth/verified')).toBe(false) + expect(isAuthVerifyEndpoint('/auth/verify')).toBe(false) + expect(isAuthVerifyEndpoint('')).toBe(false) + }) +}) + +describe('parseCookies', () => { + it('parses a single cookie correctly', () => { + const cookieString = 'sessionId=abc123' + expect(parseCookies(cookieString)).toEqual({ sessionId: 'abc123' }) + }) + + it('parses a cookie with attributes correctly', () => { + const cookieString = 'sessionId=abc123; Path=/; HttpOnly' + expect(parseCookies(cookieString)).toEqual({ sessionId: 'abc123' }) + }) + + it('parses multiple cookies correctly', () => { + const cookieString = 'sessionId=abc123, token=xyz456' + expect(parseCookies(cookieString)).toEqual({ + sessionId: 'abc123', + token: 'xyz456', + }) + }) + + it('parses multiple cookies with attributes correctly', () => { + const cookieString = 'sessionId=abc123; Path=/; HttpOnly, token=xyz456; Secure; SameSite=Strict' + expect(parseCookies(cookieString)).toEqual({ + sessionId: 'abc123', + token: 'xyz456', + }) + }) + + it('returns an empty object for invalid cookies', () => { + expect(parseCookies('')).toEqual({}) + expect(parseCookies('invalid')).toEqual({}) + expect(parseCookies('invalid=;')).toEqual({}) + }) +}) + +describe('formatCookieHeader', () => { + it('formats a single cookie correctly', () => { + const cookies = { sessionId: 'abc123' } + expect(formatCookieHeader(cookies)).toBe('sessionId=abc123') + }) + + it('formats multiple cookies correctly', () => { + const cookies = { sessionId: 'abc123', token: 'xyz456' } + expect(formatCookieHeader(cookies)).toBe('sessionId=abc123; token=xyz456') + }) + + it('returns empty string for empty cookie object', () => { + expect(formatCookieHeader({})).toBe('') + }) +}) + +describe('Cookie Handling', () => { + beforeEach(() => { + jest.resetModules() + jest.doMock('@safe-global/store/gateway/cgwClient', () => { + const originalModule = jest.requireActual('@safe-global/store/gateway/cgwClient') + return { + __esModule: true, + ...originalModule, + isCredentialRoute: (url: string) => { + return url.includes('/api/') + }, + } + }) + }) + describe('prepareCookieHeaders', () => { + it('adds cookies to credential routes', () => { + const { prepareCookieHeaders } = require('./cookieHandling') + + const headers = new Headers() + const url = 'https://example.com/api/endpoint' + const cookieStorage = { sessionId: 'abc123', token: 'xyz456' } + + const result = prepareCookieHeaders(headers, url, cookieStorage) + expect(result.get('Cookie')).toBe('sessionId=abc123; token=xyz456') + }) + + it('does not add cookies to non-credential routes', () => { + const { prepareCookieHeaders } = require('./cookieHandling') + + const headers = new Headers() + const url = 'https://example.com/public' + const cookieStorage = { sessionId: 'abc123', token: 'xyz456' } + + const result = prepareCookieHeaders(headers, url, cookieStorage) + + expect(result.has('Cookie')).toBe(false) + }) + + it('does not add cookies to auth/verify endpoint even if it is a credential route', () => { + const { prepareCookieHeaders } = require('./cookieHandling') + + const headers = new Headers() + const url = 'https://example.com/api/v1/auth/verify' + const cookieStorage = { sessionId: 'abc123', token: 'xyz456' } + + const result = prepareCookieHeaders(headers, url, cookieStorage) + + expect(result.has('Cookie')).toBe(false) + }) + + it('does not add cookies when cookie storage is empty', () => { + const { prepareCookieHeaders } = require('./cookieHandling') + + const headers = new Headers() + const url = 'https://example.com/api/endpoint' + const cookieStorage = {} + + const result = prepareCookieHeaders(headers, url, cookieStorage) + + expect(result.has('Cookie')).toBe(false) + }) + + it('preserves existing headers', () => { + const { prepareCookieHeaders } = require('./cookieHandling') + + const headers = new Headers() + headers.set('Content-Type', 'application/json') + headers.set('Authorization', 'Bearer token123') + + const url = 'https://example.com/api/endpoint' + const cookieStorage = { sessionId: 'abc123' } + + const result = prepareCookieHeaders(headers, url, cookieStorage) + + expect(result.get('Content-Type')).toBe('application/json') + expect(result.get('Authorization')).toBe('Bearer token123') + expect(result.get('Cookie')).toBe('sessionId=abc123') + }) + }) + + describe('handleCookieResponse', () => { + it('updates cookieStorage with cookies from credential routes', () => { + const { handleCookieResponse } = require('./cookieHandling') + + const responseHeaders = new Headers() + responseHeaders.set('set-cookie', 'sessionId=abc123; Path=/; HttpOnly') + const response = { headers: responseHeaders } as Response + + const url = 'https://example.com/api/endpoint' + const cookieStorage = {} + + const result = handleCookieResponse(response, url, cookieStorage) + + expect(result).toEqual({ sessionId: 'abc123' }) + }) + + it('merges new cookies with existing ones from credential routes', () => { + const { handleCookieResponse } = require('./cookieHandling') + + const responseHeaders = new Headers() + responseHeaders.set('set-cookie', 'token=xyz456; Path=/; HttpOnly') + const response = { headers: responseHeaders } as Response + + const url = 'https://example.com/api/endpoint' + const cookieStorage = { sessionId: 'abc123' } + + const result = handleCookieResponse(response, url, cookieStorage) + + expect(result).toEqual({ sessionId: 'abc123', token: 'xyz456' }) + }) + + it('updates existing cookies with new values from credential routes', () => { + const { handleCookieResponse } = require('./cookieHandling') + + const responseHeaders = new Headers() + responseHeaders.set('set-cookie', 'sessionId=newvalue; Path=/; HttpOnly') + const response = { headers: responseHeaders } as Response + + const url = 'https://example.com/api/endpoint' + const cookieStorage = { sessionId: 'oldvalue', token: 'xyz456' } + + const result = handleCookieResponse(response, url, cookieStorage) + + expect(result).toEqual({ sessionId: 'newvalue', token: 'xyz456' }) + }) + + it('does not update cookieStorage from non-credential routes', () => { + const { handleCookieResponse } = require('./cookieHandling') + + const responseHeaders = new Headers() + responseHeaders.set('set-cookie', 'sessionId=abc123; Path=/; HttpOnly') + const response = { headers: responseHeaders } as Response + + const url = 'https://example.com/public' + const cookieStorage = { token: 'xyz456' } + + const result = handleCookieResponse(response, url, cookieStorage) + + expect(result).toEqual({ token: 'xyz456' }) + }) + + it('handles responses without set-cookie headers', () => { + const { handleCookieResponse } = require('./cookieHandling') + + const responseHeaders = new Headers() + const response = { headers: responseHeaders } as Response + + const url = 'https://example.com/api/endpoint' + const cookieStorage = { sessionId: 'abc123', token: 'xyz456' } + + const result = handleCookieResponse(response, url, cookieStorage) + + expect(result).toEqual({ sessionId: 'abc123', token: 'xyz456' }) + }) + + it('works with multiple cookies in set-cookie header', () => { + const { handleCookieResponse } = require('./cookieHandling') + + const responseHeaders = new Headers() + responseHeaders.set('set-cookie', 'sessionId=abc123; Path=/; HttpOnly, token=xyz456; Secure; SameSite=Strict') + const response = { headers: responseHeaders } as Response + + const url = 'https://example.com/api/endpoint' + const cookieStorage = {} + + const result = handleCookieResponse(response, url, cookieStorage) + + expect(result).toEqual({ sessionId: 'abc123', token: 'xyz456' }) + }) + + it('handles empty cookieStorage correctly', () => { + const { handleCookieResponse } = require('./cookieHandling') + + const responseHeaders = new Headers() + responseHeaders.set('set-cookie', 'sessionId=abc123; Path=/; HttpOnly') + const response = { headers: responseHeaders } as Response + + const url = 'https://example.com/api/endpoint' + const cookieStorage = {} + + const result = handleCookieResponse(response, url, cookieStorage) + + expect(result).toEqual({ sessionId: 'abc123' }) + }) + }) +}) diff --git a/apps/mobile/src/store/utils/cookieHandling.ts b/apps/mobile/src/store/utils/cookieHandling.ts new file mode 100644 index 0000000000..084814d5c9 --- /dev/null +++ b/apps/mobile/src/store/utils/cookieHandling.ts @@ -0,0 +1,103 @@ +import { setPrepareHeadersHook, setHandleResponseHook, isCredentialRoute } from '@safe-global/store/gateway/cgwClient' +import { GATEWAY_URL } from '@/src/config/constants' +import { isIpOrLocalhostUrl, isHttpsUrl } from '@/src/utils/url' +// Store for parsed cookies +export let cookies: Record<string, string> = {} + +// Check if the URL is the auth/verify endpoint +// it's part of the auth endpoints, but it is the one that generates the cookie +export function isAuthVerifyEndpoint(url: string): boolean { + return url.includes('/v1/auth/verify') +} + +// Parse Set-Cookie header to extract cookie name-value pairs +export const parseCookies = (cookieString: string): Record<string, string> => { + const result: Record<string, string> = {} + // Split multiple cookies if present + const cookieParts = cookieString.split(',').map((part) => part.trim()) + + for (const cookiePart of cookieParts) { + // The first part of the cookie string is the name=value, followed by attributes + const [nameValuePair] = cookiePart.split(';') + + if (nameValuePair) { + const [name, value] = nameValuePair.split('=').map((part) => part.trim()) + if (name && value) { + result[name] = value + } + } + } + + return result +} + +// Format cookies for the Cookie header +export const formatCookieHeader = (cookieObj: Record<string, string>): string => { + return Object.entries(cookieObj) + .map(([name, value]) => `${name}=${value}`) + .join('; ') +} + +// Prepare headers hook implementation - exported for testing +export const prepareCookieHeaders = ( + headers: Headers, + url: string, + cookieStorage: Record<string, string> = cookies, +): Headers => { + // Add cookies to credential routes, but NOT to auth/verify endpoint + if (Object.keys(cookieStorage).length > 0 && isCredentialRoute(url) && !isAuthVerifyEndpoint(url)) { + const cookieHeader = formatCookieHeader(cookieStorage) + headers.set('Cookie', cookieHeader) + } + return headers +} + +// Handle response hook implementation - exported for testing +export const handleCookieResponse = ( + response: Response, + url: string, + cookieStorage: Record<string, string> = cookies, +): Record<string, string> => { + let updatedCookies = { ...cookieStorage } + + // Save cookies from credential routes + if (isCredentialRoute(url)) { + const setCookieHeader = response.headers.get('set-cookie') + if (setCookieHeader) { + // Parse and store the cookies + const parsedCookies = parseCookies(setCookieHeader) + updatedCookies = { ...updatedCookies, ...parsedCookies } + } + } + + return updatedCookies +} + +/** + * Sets up mobile-specific cookie handling for API requests. + * This ensures cookies are properly stored and forwarded for credential routes. + */ +export const setupMobileCookieHandling = () => { + // When working locally, we sometimes run CGW and connect to it. + // This connection is not done over https and because of this + // we need to manually forward the cookie to our local server. + // In production, we don't need to do this because the connection is over https + // and the cookie is automatically attached to the request. + // + if (!isIpOrLocalhostUrl(GATEWAY_URL) || isHttpsUrl(GATEWAY_URL)) { + return + } + + // Reset cookies object + cookies = {} + + // Set up the custom header hook + setPrepareHeadersHook((headers, url) => { + return prepareCookieHeaders(headers, url, cookies) + }) + + // Set up the custom response hook + setHandleResponseHook((response, url) => { + cookies = handleCookieResponse(response, url, cookies) + }) +} diff --git a/apps/mobile/src/store/utils/singletonStore.ts b/apps/mobile/src/store/utils/singletonStore.ts new file mode 100644 index 0000000000..4ad1f1651d --- /dev/null +++ b/apps/mobile/src/store/utils/singletonStore.ts @@ -0,0 +1,15 @@ +import { RootState, AppDispatch } from '@/src/store' + +type StoreLike = { dispatch: AppDispatch; getState: () => RootState } +let store: StoreLike | undefined + +export const setBackendStore = (newStore: StoreLike): void => { + store = newStore +} + +export const getStore = () => { + if (!store) { + throw new Error('Backend notification store not initialized') + } + return store +} diff --git a/apps/mobile/src/store/utils/strategy/Strategy.ts b/apps/mobile/src/store/utils/strategy/Strategy.ts new file mode 100644 index 0000000000..0715009c0e --- /dev/null +++ b/apps/mobile/src/store/utils/strategy/Strategy.ts @@ -0,0 +1,10 @@ +import { Action } from '@reduxjs/toolkit' +import { MiddlewareAPI, Dispatch } from 'redux' + +export interface ActionWithPayload extends Action<string> { + payload?: unknown +} + +export interface Strategy<TState, TStore extends MiddlewareAPI<Dispatch, TState> = MiddlewareAPI<Dispatch, TState>> { + execute(store: TStore, action: ActionWithPayload, prevState: TState): void +} diff --git a/apps/mobile/src/store/utils/strategy/StrategyManager.ts b/apps/mobile/src/store/utils/strategy/StrategyManager.ts new file mode 100644 index 0000000000..9085603592 --- /dev/null +++ b/apps/mobile/src/store/utils/strategy/StrategyManager.ts @@ -0,0 +1,22 @@ +import { AnyAction } from '@reduxjs/toolkit' +import { MiddlewareAPI, Dispatch } from 'redux' +import { Strategy } from '@/src/store/utils/strategy/Strategy' + +export class StrategyManager<TState, TStore extends MiddlewareAPI<Dispatch, TState> = MiddlewareAPI<Dispatch, TState>> { + private strategies: Map<string, Strategy<TState, TStore>> + + constructor() { + this.strategies = new Map() + } + + protected registerStrategy(actionType: string, strategy: Strategy<TState, TStore>): void { + this.strategies.set(actionType, strategy) + } + + public executeStrategy(store: TStore, action: AnyAction, prevState: TState): void { + const strategy = this.strategies.get(action.type) + if (strategy) { + strategy.execute(store, action, prevState) + } + } +} diff --git a/apps/mobile/src/tests/e2e-maestro/components/TestCtrls.e2e.tsx b/apps/mobile/src/tests/e2e-maestro/components/TestCtrls.e2e.tsx new file mode 100644 index 0000000000..a0e15472e3 --- /dev/null +++ b/apps/mobile/src/tests/e2e-maestro/components/TestCtrls.e2e.tsx @@ -0,0 +1,66 @@ +import { LogBox, Pressable } from 'react-native' +import { View } from 'tamagui' +import { useDispatch } from 'react-redux' +import { addSafe } from '@/src/store/safesSlice' +import { setActiveSafe } from '@/src/store/activeSafeSlice' +import { useRouter } from 'expo-router' +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { SafeInfo, Address } from '@/src/types/address' + +LogBox.ignoreAllLogs() + +const mockedActiveAccount: SafeInfo = { + address: '0x2f3e600a3F38b66aDcbe6530B191F2BE55c2Fbb6', + chainId: '11155111', +} +const mockedActiveSafeInfo: SafeOverview = { + address: { value: '0x2f3e600a3F38b66aDcbe6530B191F2BE55c2Fbb6', name: null, logoUri: null }, + awaitingConfirmation: null, + chainId: mockedActiveAccount.chainId, + fiatTotal: '0', + owners: [ + { value: '0x3336745b7EA628F5134Bd9d08aa68b4979fA3472', name: null, logoUri: null }, + { value: '0x81BdB0a66065363F704A105D67D53d090aD14fec', name: null, logoUri: null }, + { value: '0x4d5CF9E6df9a95F4c1F5398706cA27218add5949', name: null, logoUri: null }, + ], + queued: 1, + threshold: 1, +} + +/** + * This utility component is only included in the test simulator + * build. It gives some quick triggers which help improve the pace + * of the tests dramatically. + */ + +const BTN = { height: 1, width: 1, backgroundColor: 'red' } + +export function TestCtrls() { + const dispatch = useDispatch() + const router = useRouter() + const onPressOnboardedAccount = async () => { + dispatch( + addSafe({ + address: mockedActiveSafeInfo.address.value as Address, + info: { [mockedActiveSafeInfo.chainId]: mockedActiveSafeInfo }, + }), + ) + dispatch(setActiveSafe(mockedActiveAccount)) + router.replace('/(tabs)') + } + const onPressTestOnboarding = async () => { + router.replace('/onboarding') + } + + return ( + <View position={'absolute'} top={100} right={0} zIndex={99999}> + <Pressable + testID="e2eOnboardedAccount" + onPress={onPressOnboardedAccount} + accessibilityRole="button" + style={BTN} + /> + <Pressable testID="e2eTestOnboarding" onPress={onPressTestOnboarding} accessibilityRole="button" style={BTN} /> + </View> + ) +} diff --git a/apps/mobile/src/tests/e2e-maestro/components/TestCtrls.tsx b/apps/mobile/src/tests/e2e-maestro/components/TestCtrls.tsx new file mode 100644 index 0000000000..36fc48327e --- /dev/null +++ b/apps/mobile/src/tests/e2e-maestro/components/TestCtrls.tsx @@ -0,0 +1,3 @@ +export function TestCtrls() { + return null +} diff --git a/apps/mobile/src/tests/jest.setup.tsx b/apps/mobile/src/tests/jest.setup.tsx index 6fb4a05561..b2ebe2d318 100644 --- a/apps/mobile/src/tests/jest.setup.tsx +++ b/apps/mobile/src/tests/jest.setup.tsx @@ -1,7 +1,8 @@ import React from 'react' -import '@testing-library/react-native/extend-expect' +import '@testing-library/react-native' import mockRNDeviceInfo from 'react-native-device-info/jest/react-native-device-info-mock' +import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock' import { server } from './server' @@ -16,6 +17,12 @@ jest.mock('expo-font', () => ({ isLoaded: () => true, })) +jest.mock('@/src/navigation/useScrollableHeader', () => ({ + useScrollableHeader: () => ({ + handleScroll: jest.fn(), + }), +})) + jest.mock('react-native-mmkv', () => ({ MMKV: function () { // @ts-ignore @@ -27,6 +34,14 @@ jest.mock('react-native-mmkv', () => ({ }, })) +// Mock Image.getSize globally as it's used by `useValidLogoUri` +import { Image } from 'react-native' +jest.spyOn(Image, 'getSize').mockImplementation((_uri, success) => { + if (typeof success === 'function') { + success(1, 1) + } +}) + jest.mock('react-native-device-info', () => mockRNDeviceInfo) jest.mock('react-native-device-crypto', () => { return { @@ -58,6 +73,12 @@ jest.mock('react-native-keychain', () => { password = null Promise.resolve(null) }), + ACCESS_CONTROL: { + BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE: 'BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE', + }, + ACCESSIBLE: { + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + }, } }) @@ -71,7 +92,7 @@ jest.mock('redux-persist', () => { const real = jest.requireActual('redux-persist') return { ...real, - persistReducer: jest.fn().mockImplementation((config, reducers) => reducers), + persistReducer: jest.fn().mockImplementation((_, reducers) => reducers), } }) jest.mock('redux-devtools-expo-dev-plugin', () => ({ @@ -127,6 +148,11 @@ jest.mock('@notifee/react-native', () => { DEFAULT: 3, HIGH: 4, }, + AndroidVisibility: { + SECRET: -1, + PRIVATE: 0, + PUBLIC: 1, + }, } }) @@ -188,6 +214,95 @@ jest.mock('@react-native-clipboard/clipboard', () => ({ getString: jest.fn(), })) +jest.mock('react-native-quick-crypto', () => ({ + default: { + randomBytes: jest.fn((size) => Buffer.alloc(size)), + createHash: jest.fn(() => ({ + update: jest.fn().mockReturnThis(), + digest: jest.fn(() => Buffer.from('mockedHash')), + })), + pbkdf2Sync: jest.fn(() => Buffer.alloc(32)), + createCipheriv: jest.fn(() => ({ + update: jest.fn(() => Buffer.from([])), + final: jest.fn(() => Buffer.from([])), + getAuthTag: jest.fn(() => Buffer.alloc(16)), + setAuthTag: jest.fn(), + })), + createDecipheriv: jest.fn(() => ({ + update: jest.fn(() => Buffer.from([])), + final: jest.fn(() => Buffer.from([])), + setAuthTag: jest.fn(), + })), + }, + randomBytes: jest.fn((size) => Buffer.alloc(size)), + createHash: jest.fn(() => ({ + update: jest.fn().mockReturnThis(), + digest: jest.fn(() => Buffer.from('mockedHash')), + })), + pbkdf2Sync: jest.fn(() => Buffer.alloc(32)), + createCipheriv: jest.fn(() => ({ + update: jest.fn(() => Buffer.from([])), + final: jest.fn(() => Buffer.from([])), + getAuthTag: jest.fn(() => Buffer.alloc(16)), + setAuthTag: jest.fn(), + })), + createDecipheriv: jest.fn(() => ({ + update: jest.fn(() => Buffer.from([])), + final: jest.fn(() => Buffer.from([])), + setAuthTag: jest.fn(), + })), +})) + +jest.mock('react-native-safe-area-context', () => mockSafeAreaContext) + +jest.mock('@react-native-firebase/analytics', () => { + const mockAnalytics = { + logEvent: jest.fn(() => Promise.resolve()), + setAnalyticsCollectionEnabled: jest.fn(() => Promise.resolve()), + setUserId: jest.fn(() => Promise.resolve()), + setUserProperty: jest.fn(() => Promise.resolve()), + setUserProperties: jest.fn(() => Promise.resolve()), + resetAnalyticsData: jest.fn(() => Promise.resolve()), + setDefaultEventParameters: jest.fn(() => Promise.resolve()), + setSessionTimeoutDuration: jest.fn(() => Promise.resolve()), + } + + return { + __esModule: true, + default: () => mockAnalytics, + getAnalytics: jest.fn(() => mockAnalytics), + firebase: { + analytics: jest.fn(() => mockAnalytics), + }, + } +}) + +jest.mock('@react-native-firebase/crashlytics', () => { + const mockCrashlytics = { + crash: jest.fn(() => Promise.resolve()), + log: jest.fn(() => Promise.resolve()), + recordError: jest.fn(() => Promise.resolve()), + setAttribute: jest.fn(() => Promise.resolve()), + setAttributes: jest.fn(() => Promise.resolve()), + setUserId: jest.fn(() => Promise.resolve()), + setCrashlyticsCollectionEnabled: jest.fn(() => Promise.resolve()), + checkForUnsentReports: jest.fn(() => Promise.resolve(false)), + deleteUnsentReports: jest.fn(() => Promise.resolve()), + didCrashOnPreviousExecution: jest.fn(() => Promise.resolve(false)), + sendUnsentReports: jest.fn(() => Promise.resolve()), + setCustomKey: jest.fn(() => Promise.resolve()), + } + + return { + __esModule: true, + default: () => mockCrashlytics, + getCrashlytics: jest.fn(() => mockCrashlytics), + firebase: { + crashlytics: jest.fn(() => mockCrashlytics), + }, + } +}) + beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) diff --git a/apps/mobile/src/tests/mocks.ts b/apps/mobile/src/tests/mocks.ts index 7a3aa42010..26f60d3c34 100644 --- a/apps/mobile/src/tests/mocks.ts +++ b/apps/mobile/src/tests/mocks.ts @@ -159,7 +159,6 @@ export const mockSwapTransfer: SwapTransferTransactionInfo = { sellToken: fakeToken2, buyToken: fakeToken, explorerUrl: 'http://google.com', - executedSurplusFee: '', receiver: '0xbob', owner: '0xalice', } @@ -263,3 +262,95 @@ export const mockListItemByType = (type: TransactionListItemType): PendingTransa conflictType: ConflictType.NONE, } as TransactionQueuedItem } + +export const mockTwapOrder = { + type: TransactionInfoType.TWAP_ORDER, + sender: { + value: '0x000000', + name: 'something', + }, + direction: TransferDirection.INCOMING, + recipient: { + value: '0x0ab', + name: 'something', + }, + transferInfo: { + type: TransactionTokenType.ERC20, + tokenAddress: '0x000000', + value: '50000000000000000', + trusted: false, + imitation: true, + }, + uid: '232', + humanDescription: 'a twap order', + status: 'fulfilled', + kind: 'buy', + validUntil: 11902381293, + sellAmount: '50000000000000000', + buyAmount: '50000000000000000', + executedSellAmount: '50000000000000000', + executedBuyAmount: '50000000000000000', + executedFee: '1000000000000000', + executedFeeToken: fakeToken, + sellToken: fakeToken2, + buyToken: fakeToken, + explorerUrl: 'http://google.com', + receiver: '0xbob', + owner: '0xalice', +} + +export const mockSwapOrder = { + type: TransactionInfoType.SWAP_ORDER, + sender: { + value: '0x000000', + name: 'something', + }, + direction: TransferDirection.INCOMING, + recipient: { + value: '0x0ab', + name: 'something', + }, + transferInfo: { + type: TransactionTokenType.ERC20, + tokenAddress: '0x000000', + value: '50000000000000000', + trusted: false, + imitation: true, + }, + uid: '233', + humanDescription: 'a swap order', + status: 'fulfilled', + kind: 'buy', + fullAppData: { + metadata: { + orderClass: { + orderClass: 'market', + }, + }, + }, + validUntil: 11902381293, + sellAmount: '50000000000000000', + buyAmount: '50000000000000000', + executedSellAmount: '50000000000000000', + executedBuyAmount: '50000000000000000', + executedFee: '1000000000000000', + executedFeeToken: fakeToken, + sellToken: fakeToken2, + buyToken: fakeToken, + explorerUrl: 'http://google.com', + receiver: '0xbob', + owner: '0xalice', +} + +export const mockLimitOrder = { + ...mockSwapOrder, + uid: '234', + humanDescription: 'a limit order', + fullAppData: { + metadata: { + orderClass: { + orderClass: 'limit', + }, + }, + }, +} diff --git a/apps/mobile/src/tests/test-utils.tsx b/apps/mobile/src/tests/test-utils.tsx index 806c6bc1d2..cc1662489a 100644 --- a/apps/mobile/src/tests/test-utils.tsx +++ b/apps/mobile/src/tests/test-utils.tsx @@ -5,16 +5,50 @@ import { makeStore, rootReducer } from '../store' import { PortalProvider } from 'tamagui' import { BottomSheetModalProvider } from '@gorhom/bottom-sheet' import { configureStore } from '@reduxjs/toolkit' +import { FLUSH, PAUSE, PERSIST, PURGE, REGISTER, REHYDRATE } from 'redux-persist' +import { cgwClient } from '@safe-global/store/gateway/cgwClient' +import { web3API } from '@/src/store/signersBalance' +import type { SettingsState } from '@/src/store/settingsSlice' export type RootState = ReturnType<typeof rootReducer> type getProvidersArgs = (initialStoreState?: Partial<RootState>) => React.FC<{ children: React.ReactNode }> +// Default settings slice for tests +const defaultSettings: SettingsState = { + onboardingVersionSeen: '', + themePreference: 'auto', + currency: 'usd', + env: { + rpc: {}, + tenderly: { + url: '', + accessToken: '', + }, + }, +} + const getProviders: getProvidersArgs = (initialStoreState) => function ProviderComponent({ children }: { children: React.ReactNode }) { - const store = initialStoreState + // Inject default settings if not provided + const storeWithDefaults = initialStoreState + ? ({ + ...initialStoreState, + settings: { + ...defaultSettings, + ...(initialStoreState.settings || {}), + }, + } as Partial<RootState>) + : undefined + const store = storeWithDefaults ? configureStore({ reducer: rootReducer, - preloadedState: initialStoreState, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + }, + }).concat(cgwClient.middleware, web3API.middleware), + preloadedState: storeWithDefaults, }) : makeStore() @@ -48,8 +82,8 @@ const customRender = ( return nativeRender(ui, { wrapper: WrapperWithCustom }) } -function customRenderHook<Result, Props>(render: (initialProps: Props) => Result) { - const wrapper = getProviders() +function customRenderHook<Result, Props>(render: (initialProps: Props) => Result, initialStore?: Partial<RootState>) { + const wrapper = getProviders(initialStore) return renderHook(render, { wrapper }) } diff --git a/apps/mobile/src/theme/SafeStatusBar.tsx b/apps/mobile/src/theme/SafeStatusBar.tsx new file mode 100644 index 0000000000..1a9c16401e --- /dev/null +++ b/apps/mobile/src/theme/SafeStatusBar.tsx @@ -0,0 +1,29 @@ +import { StatusBar } from 'expo-status-bar' +import { useSegments } from 'expo-router' +import { useTheme } from '@/src/theme/hooks/useTheme' + +const DARK_SCREENS = [ + 'onboarding', + 'enter-password', + 'file-selection', + 'help-import', + 'import-error', + 'import-progress', + 'import-success', + 'import-data', + 'review-data', +] + +export const SafeStatusBar = () => { + const { currentTheme } = useTheme() + const segments = useSegments() + const currentRoute = segments[segments.length - 1] + + const isDarkScreen = DARK_SCREENS.includes(currentRoute) + + if (isDarkScreen) { + return <StatusBar style="light" /> + } + + return <StatusBar style={currentTheme === 'dark' ? 'light' : 'dark'} /> +} diff --git a/apps/mobile/src/theme/__tests__/SafeStatusBar.test.tsx b/apps/mobile/src/theme/__tests__/SafeStatusBar.test.tsx new file mode 100644 index 0000000000..c9286229a7 --- /dev/null +++ b/apps/mobile/src/theme/__tests__/SafeStatusBar.test.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { render } from '@testing-library/react-native' +import { StatusBar } from 'expo-status-bar' +import { SafeStatusBar } from '../SafeStatusBar' + +const mockUseTheme = jest.fn() +jest.mock('@/src/theme/hooks/useTheme', () => ({ + useTheme: () => ({ currentTheme: mockUseTheme() }), +})) + +const mockUseSegments = jest.fn() +jest.mock('expo-router', () => ({ + useSegments: () => mockUseSegments(), +})) + +describe('SafeStatusBar', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders light style for dark screens regardless of theme', () => { + mockUseTheme.mockReturnValue('dark') + mockUseSegments.mockReturnValue(['root', 'onboarding']) + const { UNSAFE_getByType } = render(<SafeStatusBar />) + + expect(UNSAFE_getByType(StatusBar).props.style).toBe('light') + }) + + it('renders light style when theme is dark and not dark screen', () => { + mockUseTheme.mockReturnValue('dark') + mockUseSegments.mockReturnValue(['home']) + const { UNSAFE_getByType } = render(<SafeStatusBar />) + + expect(UNSAFE_getByType(StatusBar).props.style).toBe('light') + }) + + it('renders dark style when theme is light and not dark screen', () => { + mockUseTheme.mockReturnValue('light') + mockUseSegments.mockReturnValue(['home']) + const { UNSAFE_getByType } = render(<SafeStatusBar />) + + expect(UNSAFE_getByType(StatusBar).props.style).toBe('dark') + }) +}) diff --git a/apps/mobile/src/theme/helpers/tabBarStyles.ts b/apps/mobile/src/theme/helpers/tabBarStyles.ts new file mode 100644 index 0000000000..50487b5b62 --- /dev/null +++ b/apps/mobile/src/theme/helpers/tabBarStyles.ts @@ -0,0 +1,45 @@ +import { useTheme } from 'tamagui' +import { TextStyle } from 'react-native' + +export interface TabBarStylesOptions { + theme: ReturnType<typeof useTheme> + tabBarItemWidth?: number + tabBarIndicatorWidth?: number + tabBarLabelFontSize?: number + tabBarLabelFontWeight?: TextStyle['fontWeight'] + tabBarItemLeft?: number +} + +export const getMaterialTopTabBarScreenOptions = ({ + theme, + tabBarItemWidth = 104, + tabBarIndicatorWidth = 78, + tabBarLabelFontSize = 14, + tabBarLabelFontWeight = '600', + tabBarItemLeft = -6, +}: TabBarStylesOptions) => ({ + tabBarButtonTestID: 'tab-bar-buttons', + tabBarStyle: { + backgroundColor: 'transparent', + shadowColor: 'transparent', + }, + tabBarItemStyle: { + backgroundColor: 'transparent', + shadowColor: 'transparent', + width: tabBarItemWidth, + alignSelf: 'center' as const, + borderBottomWidth: 0, + left: tabBarItemLeft, + }, + tabBarIndicatorStyle: { + backgroundColor: theme?.color?.get(), + width: tabBarIndicatorWidth, + marginLeft: 16, + alignItems: 'center' as const, + }, + tabBarLabelStyle: { + color: theme?.color?.get(), + fontSize: tabBarLabelFontSize, + fontWeight: tabBarLabelFontWeight, + } as TextStyle, +}) diff --git a/apps/mobile/src/theme/hooks/useSafeAreaPaddingBottom.tsx b/apps/mobile/src/theme/hooks/useSafeAreaPaddingBottom.tsx new file mode 100644 index 0000000000..828dbda8a7 --- /dev/null +++ b/apps/mobile/src/theme/hooks/useSafeAreaPaddingBottom.tsx @@ -0,0 +1,7 @@ +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { getVariable } from 'tamagui' + +export const useSafeAreaPaddingBottom = (): number => { + const insets = useSafeAreaInsets() + return insets.bottom + insets.top + Number(getVariable('$4')) +} diff --git a/apps/mobile/src/theme/hooks/useTheme.tsx b/apps/mobile/src/theme/hooks/useTheme.tsx new file mode 100644 index 0000000000..555927d020 --- /dev/null +++ b/apps/mobile/src/theme/hooks/useTheme.tsx @@ -0,0 +1,43 @@ +import { useCallback, useEffect } from 'react' +import { AppState, useColorScheme } from 'react-native' +import { updateSettings } from '@/src/store/settingsSlice' +import { selectSettings } from '@/src/store/settingsSlice' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { ThemePreference } from '@/src/types/theme' + +export const useTheme = () => { + const dispatch = useAppDispatch() + const colorScheme = useColorScheme() + + const themePreference = useAppSelector( + (state) => selectSettings(state, 'themePreference') ?? 'auto', + ) as ThemePreference + + const setThemePreference = useCallback( + (theme: ThemePreference) => { + dispatch(updateSettings({ themePreference: theme })) + }, + [dispatch], + ) + + // Prevent theme from changing when the app is in the background + // Issue: https://github.com/facebook/react-native/issues/35972 + // They closed but issue still exists. Check the link for more details. + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => { + if (nextAppState === 'active') { + dispatch(updateSettings({ themePreference })) + } + }) + + return () => { + subscription.remove() + } + }, [themePreference]) + + return { + themePreference, + setThemePreference, + currentTheme: themePreference === 'auto' ? colorScheme : themePreference, + } +} diff --git a/apps/mobile/src/theme/navigation.ts b/apps/mobile/src/theme/navigation.ts index b24f8944e0..4c69163ea0 100644 --- a/apps/mobile/src/theme/navigation.ts +++ b/apps/mobile/src/theme/navigation.ts @@ -1,5 +1,5 @@ import { getTokenValue } from 'tamagui' -import type { Theme } from '@react-navigation/native/src/types' +import type { Theme } from '@react-navigation/native' import { DarkTheme, DefaultTheme } from '@react-navigation/native' export const NavDarkTheme: Theme = { ...DarkTheme, @@ -9,7 +9,7 @@ export const NavDarkTheme: Theme = { background: getTokenValue('$color.backgroundMainDark'), card: getTokenValue('$color.backgroundMainDark'), text: getTokenValue('$color.textPrimaryDark'), - border: getTokenValue('$color.backgroundMainDark'), + border: getTokenValue('$color.borderLightDark'), notification: getTokenValue('$color.warningBackgroundDark'), }, } @@ -22,7 +22,7 @@ export const NavLightTheme: Theme = { background: getTokenValue('$color.backgroundMainLight'), card: getTokenValue('$color.backgroundMainLight'), text: getTokenValue('$color.textPrimaryLight'), - border: getTokenValue('$color.backgroundMainLight'), + border: getTokenValue('$color.borderLightLight'), notification: getTokenValue('$color.warningBackgroundLight'), }, } diff --git a/apps/mobile/src/theme/palettes/darkPalette.ts b/apps/mobile/src/theme/palettes/darkPalette.ts index 565d44ec91..b601bece3a 100644 --- a/apps/mobile/src/theme/palettes/darkPalette.ts +++ b/apps/mobile/src/theme/palettes/darkPalette.ts @@ -3,6 +3,7 @@ const darkPalette = { primary: '#FFFFFF', secondary: '#636669', disabled: '#636669', + contrast: '#000000', }, primary: { dark: '#0cb259', @@ -21,35 +22,36 @@ const darkPalette = { background: '#121312', }, error: { - dark: '#411C20', + dark: '#FFE0E6', main: '#FF5F72', - light: '#FFB4BD', - background: '#2F2527', + light: '#4A2125', + background: '#4A2125', }, error1: { - main: '#49191F', - contrastText: '#FF5F72', + main: '#4A2125', + contrastText: '#FFE0E6', }, success: { - dark: '#1D3D28', + dark: '#DEFDEA', main: '#00B460', - light: '#81C784', - background: '#1F2920', + light: '#3B7A54', + background: '#173026', }, info: { - dark: '#52BFDC', - main: '#5FDDFF', - light: '#B7F0FF', - background: '#19252C', + dark: '#D9F4FB', + main: '#00BFE5', + light: '#458898', + background: '#203339', }, warning: { - dark: '#432F18', - main: '#FF8061', - light: '#FFBC9F', - background: '#2F2318', + dark: '#FFE4CB', + main: '#FF8C00', + light: '#A65F34', + background: '#4A3621', }, warning1: { - main: '#322211', + main: '#4A3621', + text: '#FFE4CB', contrastText: '#FF8C00', }, background: { @@ -57,7 +59,9 @@ const darkPalette = { main: '#121312', paper: '#1C1C1C', light: '#1B2A22', + secondary: '#303033', skeleton: 'rgba(255, 255, 255, 0.04)', + disabled: '#7878801F', }, backdrop: { main: '#636669', diff --git a/apps/mobile/src/theme/palettes/lightPalette.ts b/apps/mobile/src/theme/palettes/lightPalette.ts index a8f1140c6c..3c7f24549b 100644 --- a/apps/mobile/src/theme/palettes/lightPalette.ts +++ b/apps/mobile/src/theme/palettes/lightPalette.ts @@ -3,6 +3,7 @@ const lightPalette = { primary: '#121312', secondary: '#A1A3A7', disabled: '#DDDEE0', + contrast: '#FFFFFF', }, primary: { dark: '#3c3c3c', @@ -21,43 +22,46 @@ const lightPalette = { background: '#F4F4F4', }, error: { - dark: '#411C20', + dark: '#8A1C27', main: '#FF5F72', - light: '#FFB4BD', - background: '#FFE6EA', + light: '#F79BA7', + background: '#FFE0E6', }, error1: { - main: '#49191F', - contrastText: '#FF5F72', + main: '#FFE0E6', + contrastText: '#8A1C27', }, success: { - dark: '#2D3D28', + dark: '#1C5538', main: '#00B460', - light: '#72F5B8', - background: '#EFFAF1', + light: '#84D9A0', + background: '#CBF2DB', }, info: { - dark: '#553B1E', - main: '#5FDDFF', - light: '#B7F0FF', - background: '#EFFCFF', + dark: '#15566A', + main: '#00BFE5', + light: '#78D2E7', + background: '#CEF0FD', }, warning: { - dark: '#C04C32', - main: '#FF8061', - light: '#FFBC9F', - background: '#FFF1E0', + dark: '#6C2D19', + main: '#FF8C00', + light: '#F9B37C', + background: '#FFECC2', }, warning1: { - main: '#fff0e0', + main: '#FFECC2', + text: '#6C2D19', contrastText: '#FF8C00', }, background: { default: '#FFFFFF', - main: '#FFFFFF', + main: '#F4F4F4', paper: '#F4F4F4', light: '#EFFFF4', - skeleton: 'rgba(255, 255, 255, 0.04)', + secondary: '#DDDEE0', + skeleton: 'rgba(0, 0, 0, 0.04)', + disabled: '#7878801F', }, backdrop: { main: '#636669', diff --git a/apps/mobile/src/theme/provider/DataFetchProvider.tsx b/apps/mobile/src/theme/provider/DataFetchProvider.tsx new file mode 100644 index 0000000000..617de0777f --- /dev/null +++ b/apps/mobile/src/theme/provider/DataFetchProvider.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { useAppSelector } from '@/src/store/hooks' +import { selectAllChains } from '@/src/store/chains' +import { makeSafeId } from '@/src/utils/formatters' +import { useSafesGetOverviewForManyQuery } from '@safe-global/store/gateway/safes' +import { SafeOverviewResult } from '@safe-global/store/gateway/types' +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { selectCurrency } from '@/src/store/settingsSlice' + +export const DataFetchProvider = ({ children }: { children: React.ReactNode }) => { + const chains = useAppSelector(selectAllChains) + const activeSafe = useAppSelector(selectActiveSafe) + const currency = useAppSelector(selectCurrency) + useSafesGetOverviewForManyQuery<SafeOverviewResult>( + { + safes: activeSafe ? chains.map((chain) => makeSafeId(chain.chainId, activeSafe.address)) : [], + currency, + trusted: true, + excludeSpam: true, + }, + { + skip: chains.length === 0 && !!activeSafe, + }, + ) + + return children +} diff --git a/apps/mobile/src/theme/provider/font.tsx b/apps/mobile/src/theme/provider/font.tsx index 20210d7c4d..4a831f6b94 100644 --- a/apps/mobile/src/theme/provider/font.tsx +++ b/apps/mobile/src/theme/provider/font.tsx @@ -1,9 +1,14 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { useFonts } from 'expo-font' import DmSansSemiBold from '@tamagui/font-dm-sans/fonts/static/DMSans-SemiBold.ttf' import DmSansRegular from '@tamagui/font-dm-sans/fonts/static/DMSans-Regular.ttf' import DmSansMedium from '@tamagui/font-dm-sans/fonts/static/DMSans-Medium.ttf' +import DmSansMediumItalic from '@tamagui/font-dm-sans/fonts/static/DMSans-MediumItalic.ttf' +import DmSansSemiBoldItalic from '@tamagui/font-dm-sans/fonts/static/DMSans-SemiBoldItalic.ttf' +import DmSansBold from '@tamagui/font-dm-sans/fonts/static/DMSans-Bold.ttf' +import DmSansBoldItalic from '@tamagui/font-dm-sans/fonts/static/DMSans-BoldItalic.ttf' import * as SplashScreen from 'expo-splash-screen' +import { Animated, StyleSheet, Image, Platform } from 'react-native' interface SafeThemeProviderProps { children: React.ReactNode @@ -19,20 +24,66 @@ SplashScreen.setOptions({ export const FontProvider = ({ children }: SafeThemeProviderProps) => { const [loaded] = useFonts({ - 'DmSans-SemiBold': DmSansSemiBold, - 'DmSans-Regular': DmSansRegular, - 'DmSans-Medium': DmSansMedium, + 'DMSans-SemiBold': DmSansSemiBold, + 'DMSans-Regular': DmSansRegular, + 'DMSans-Medium': DmSansMedium, + 'DMSans-MediumItalic': DmSansMediumItalic, + 'DMSans-SemiBoldItalic': DmSansSemiBoldItalic, + 'DMSans-Bold': DmSansBold, + 'DMSans-BoldItalic': DmSansBoldItalic, }) + const [showCustomSplash, setShowCustomSplash] = useState(true) + const fadeAnim = React.useRef(new Animated.Value(1)).current + useEffect(() => { if (loaded) { - SplashScreen.hideAsync() + if (Platform.OS === 'android') { + // On Android, fade out our custom splash and then hide the native one + Animated.timing(fadeAnim, { + toValue: 0, + duration: 500, + useNativeDriver: true, + }).start(() => { + setShowCustomSplash(false) + SplashScreen.hideAsync() + }) + } else { + // On iOS, just hide the native splash screen + SplashScreen.hideAsync() + } } - }, [loaded]) + }, [loaded, fadeAnim]) if (!loaded) { return null } - return children + return ( + <> + {Platform.OS === 'android' && showCustomSplash && ( + <Animated.View style={[StyleSheet.absoluteFill, styles.customSplash, { opacity: fadeAnim }]}> + <Image + source={require('../../../assets/images/splash.png')} + style={styles.splashImage} + resizeMode="contain" + /> + </Animated.View> + )} + {children} + </> + ) } + +const styles = StyleSheet.create({ + customSplash: { + backgroundColor: '#000000', + justifyContent: 'center', + alignItems: 'center', + zIndex: 999, + }, + splashImage: { + width: '100%', + height: '100%', + }, +}) diff --git a/apps/mobile/src/theme/provider/safeTheme.tsx b/apps/mobile/src/theme/provider/safeTheme.tsx index 7340b83e68..9c5c369d06 100644 --- a/apps/mobile/src/theme/provider/safeTheme.tsx +++ b/apps/mobile/src/theme/provider/safeTheme.tsx @@ -1,5 +1,4 @@ import React from 'react' -import { StatusBar, useColorScheme } from 'react-native' import { ThemeProvider } from '@react-navigation/native' import { TamaguiProvider } from '@tamagui/core' @@ -8,30 +7,29 @@ import { NavDarkTheme, NavLightTheme } from '@/src/theme/navigation' import { FontProvider } from '@/src/theme/provider/font' import { isStorybookEnv } from '@/src/config/constants' import { View } from 'tamagui' +import { useTheme } from '../hooks/useTheme' interface SafeThemeProviderProps { children: React.ReactNode } export const SafeThemeProvider = ({ children }: SafeThemeProviderProps) => { - const colorScheme = useColorScheme() + const { currentTheme } = useTheme() const themeProvider = isStorybookEnv ? ( <View - backgroundColor={colorScheme === 'dark' ? NavDarkTheme.colors.background : NavLightTheme.colors.background} + backgroundColor={currentTheme === 'dark' ? NavDarkTheme.colors.background : NavLightTheme.colors.background} style={{ flex: 1 }} > {children} </View> ) : ( - <ThemeProvider value={colorScheme === 'dark' ? NavDarkTheme : NavLightTheme}>{children}</ThemeProvider> + <ThemeProvider value={currentTheme === 'dark' ? NavDarkTheme : NavLightTheme}>{children}</ThemeProvider> ) return ( <FontProvider> - <StatusBar animated={true} barStyle="light-content" backgroundColor="transparent" translucent={true} /> - - <TamaguiProvider config={config} defaultTheme={colorScheme ? colorScheme : 'dark'}> + <TamaguiProvider config={config} defaultTheme={currentTheme ?? 'light'}> {themeProvider} </TamaguiProvider> </FontProvider> diff --git a/apps/mobile/src/theme/provider/storybookTheme.tsx b/apps/mobile/src/theme/provider/storybookTheme.tsx new file mode 100644 index 0000000000..84890f42ae --- /dev/null +++ b/apps/mobile/src/theme/provider/storybookTheme.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { ThemeProvider } from '@react-navigation/native' +import { TamaguiProvider } from '@tamagui/core' +import { config } from '@/src/theme/tamagui.config' +import { NavDarkTheme, NavLightTheme } from '@/src/theme/navigation' +import { FontProvider } from '@/src/theme/provider/font' +import { View } from 'tamagui' +import { useColorScheme } from 'react-native' + +interface StorybookThemeProviderProps { + children: React.ReactNode +} + +export const StorybookThemeProvider = ({ children }: StorybookThemeProviderProps) => { + const colorScheme = useColorScheme() + + return ( + <FontProvider> + <TamaguiProvider config={config} defaultTheme={colorScheme ?? 'light'}> + <ThemeProvider value={colorScheme === 'dark' ? NavDarkTheme : NavLightTheme}> + <View + backgroundColor={colorScheme === 'dark' ? NavDarkTheme.colors.background : NavLightTheme.colors.background} + style={{ flex: 1 }} + > + {children} + </View> + </ThemeProvider> + </TamaguiProvider> + </FontProvider> + ) +} diff --git a/apps/mobile/src/theme/tamagui.config.ts b/apps/mobile/src/theme/tamagui.config.ts index 9212b91d42..720c464723 100644 --- a/apps/mobile/src/theme/tamagui.config.ts +++ b/apps/mobile/src/theme/tamagui.config.ts @@ -1,9 +1,14 @@ import { createTamagui } from 'tamagui' import { createDmSansFont } from '@tamagui/font-dm-sans' import { badgeTheme } from '@/src/components/Badge/theme' -import { tokens } from '@/src/theme/tokens' +import { badgeTheme as NetworkBadgeTheme } from '@/src/components/NetworkBadge/theme' +import { navbarTheme } from '@/src/features/Assets/components/Navbar/theme' +import { fontSizes, tokens } from '@/src/theme/tokens' import { createAnimations } from '@tamagui/animations-moti' -import { inputTheme } from '../components/SafeInput/theme' +import { inputTheme, inputWithLabelTheme } from '../components/SafeInput/theme' +import { safeTabTheme } from '@/src/components/SafeTab/theme' +import { SafeListItemTheme } from '@/src/components/SafeListItem/theme' +import { alertTheme } from '@/src/components/Alert/theme' const DmSansFont = createDmSansFont({ face: { @@ -11,29 +16,45 @@ const DmSansFont = createDmSansFont({ 600: { normal: 'DMSans-SemiBold', italic: 'DMSans-SemiBoldItalic' }, 700: { normal: 'DMSans-Bold', italic: 'DMSans-BoldItalic' }, }, + size: fontSizes, }) export const config = createTamagui({ fonts: { body: DmSansFont, heading: DmSansFont, + button: DmSansFont, }, themes: { light: { - background: tokens.color.backgroundMainLight, + background: tokens.color.backgroundDefaultLight, + backgroundSecondary: tokens.color.backgroundSecondaryLight, backgroundPaper: tokens.color.backgroundPaperLight, backgroundHover: tokens.color.backgroundLightLight, backgroundPress: tokens.color.primaryLightLight, backgroundFocus: tokens.color.backgroundMainLight, backgroundStrong: tokens.color.primaryDarkLight, + backgroundDisabled: tokens.color.backgroundDisabledLight, + backgroundSuccess: tokens.color.successBackgroundLight, + backgroundWarning: tokens.color.warningBackgroundLight, + backgroundError: tokens.color.errorBackgroundLight, backgroundTransparent: 'transparent', backgroundSkeleton: tokens.color.backgroundSkeletonLight, color: tokens.color.textPrimaryLight, primary: tokens.color.primaryMainLight, colorHover: tokens.color.textSecondaryLight, colorSecondary: tokens.color.textSecondaryLight, + colorLight: tokens.color.primaryLightLight, + colorContrast: tokens.color.textContrastLight, + colorOutline: tokens.color.textSecondaryLight, + borderMain: tokens.color.borderMainLight, borderLight: tokens.color.borderLightLight, error: tokens.color.errorMainLight, + success: tokens.color.successMainLight, + warning: tokens.color.warningMainLight, errorDark: tokens.color.errorDarkDark, + errorLight: tokens.color.errorLightLight, + errorBackground: tokens.color.errorBackgroundLight, + contrast: tokens.color.textContrastLight, }, light_label: { color: tokens.color.textSecondaryLight, @@ -41,44 +62,14 @@ export const config = createTamagui({ dark_label: { color: tokens.color.textSecondaryDark, }, - light_info: { - background: tokens.color.infoBackgroundLight, - color: tokens.color.infoMainLight, - }, - dark_info: { - background: tokens.color.infoBackgroundDark, - color: tokens.color.infoMainDark, - }, ...badgeTheme, + ...alertTheme, ...inputTheme, - light_success: { - background: tokens.color.successBackgroundLight, - color: tokens.color.successMainLight, - badgeBackground: tokens.color.successDarkLight, - badgeTextColor: tokens.color.backgroundMainDark, - }, - dark_success: { - background: tokens.color.successBackgroundDark, - color: tokens.color.successMainDark, - badgeBackground: tokens.color.successDarkDark, - }, + ...NetworkBadgeTheme, + ...navbarTheme, + ...safeTabTheme, + ...inputWithLabelTheme, dark_success_light: {}, - light_warning: { - background: tokens.color.warning1MainLight, - color: tokens.color.warning1ContrastTextLight, - }, - dark_warning: { - background: tokens.color.warning1MainDark, - color: tokens.color.warning1ContrastTextDark, - }, - light_error: { - background: tokens.color.error1MainLight, - color: tokens.color.error1ContrastTextLight, - }, - dark_error: { - background: tokens.color.error1MainDark, - color: tokens.color.error1ContrastTextDark, - }, light_logo: { background: tokens.color.logoBackgroundLight, }, @@ -86,33 +77,53 @@ export const config = createTamagui({ background: tokens.color.logoBackgroundDark, }, light_container: { - background: tokens.color.backgroundPaperLight, + background: tokens.color.backgroundDefaultLight, + }, + dark_container: { + background: tokens.color.backgroundPaperDark, }, - light_safe_list: { + light_settings: { background: tokens.color.backgroundDefaultLight, }, - dark_safe_list: { - background: tokens.color.backgroundDefaultDark, + dark_settings: { + background: tokens.color.backgroundPaperDark, }, + ...SafeListItemTheme, dark: { background: tokens.color.backgroundDefaultDark, + backgroundSecondary: tokens.color.backgroundSecondaryDark, backgroundPaper: tokens.color.backgroundPaperDark, backgroundHover: tokens.color.backgroundLightDark, backgroundPress: tokens.color.primaryLightDark, backgroundFocus: tokens.color.backgroundMainDark, backgroundStrong: tokens.color.primaryDarkDark, backgroundTransparent: 'transparent', - backgroundSkeleton: tokens.color.backgroundSkeletonLight, + backgroundDisabled: tokens.color.backgroundDisabledDark, + backgroundSkeleton: tokens.color.backgroundSkeletonDark, + backgroundSuccess: tokens.color.successBackgroundDark, + backgroundWarning: tokens.color.warningBackgroundDark, + backgroundError: tokens.color.errorBackgroundDark, color: tokens.color.textPrimaryDark, + colorLight: tokens.color.primaryLightDark, + colorOutline: tokens.color.primaryLightDark, primary: tokens.color.primaryMainDark, + borderMain: tokens.color.borderMainDark, borderLight: tokens.color.borderLightDark, colorHover: tokens.color.textSecondaryDark, - colorSecondary: tokens.color.textSecondaryDark, + colorSecondary: tokens.color.primaryLightDark, error: tokens.color.errorMainDark, errorDark: tokens.color.errorDarkDark, + errorLight: tokens.color.errorLightDark, + errorBackground: tokens.color.errorBackgroundDark, + success: tokens.color.successMainLight, + warning: tokens.color.warningMainDark, + contrast: tokens.color.textContrastDark, }, }, tokens, + settings: { + defaultFont: 'body', + }, animations: createAnimations({ fast: { type: 'spring', diff --git a/apps/mobile/src/theme/tokens.ts b/apps/mobile/src/theme/tokens.ts index 5486830953..d2de6f0a48 100644 --- a/apps/mobile/src/theme/tokens.ts +++ b/apps/mobile/src/theme/tokens.ts @@ -1,5 +1,5 @@ import { createTokens } from 'tamagui' -import { radius, zIndex } from '@tamagui/themes' +import { zIndex } from '@tamagui/themes' import { flattenPalette } from '@/src/theme/helpers/utils' import lightPalette from '@/src/theme/palettes/lightPalette' import darkPalette from '@/src/theme/palettes/darkPalette' @@ -7,6 +7,47 @@ const colors = { ...flattenPalette(lightPalette, { suffix: 'Light' }), ...flattenPalette(darkPalette, { suffix: 'Dark' }), } + +export const radius = { + 0: 0, + 1: 3, + 2: 5, + 3: 7, + 4: 9, + true: 9, + 5: 10, + 6: 16, + 7: 19, + 8: 22, + 9: 24, + 10: 34, + 11: 42, + 12: 50, +} + +export const fontSizes = { + 1: 11, + 2: 12, + 3: 13, + 4: 14, + true: 14, + $sm: 14, + $md: 14, + $xl: 14, + 5: 16, + 6: 18, + 7: 20, + 8: 23, + 9: 30, + 10: 44, + 11: 55, + 12: 62, + 13: 72, + 14: 92, + 15: 114, + 16: 134, +} + export const tokens = createTokens({ color: colors, space: { @@ -35,6 +76,9 @@ export const tokens = createTokens({ $8: 32, $9: 36, $10: 40, + $xl: 14, + $md: 14, + $sm: 14, }, zIndex, radius, diff --git a/apps/mobile/src/types/address.ts b/apps/mobile/src/types/address.ts index 2125eb77f6..0498bccb13 100644 --- a/apps/mobile/src/types/address.ts +++ b/apps/mobile/src/types/address.ts @@ -1,6 +1,10 @@ +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + export interface SafeInfo { address: Address chainId: string } +export type SignerInfo = AddressInfo + export type Address = `0x${string}` diff --git a/apps/mobile/src/types/iconTypes.ts b/apps/mobile/src/types/iconTypes.ts index 300a733c8f..7721cc2874 100644 --- a/apps/mobile/src/types/iconTypes.ts +++ b/apps/mobile/src/types/iconTypes.ts @@ -1,212 +1,240 @@ export type IconName = - | 'block' - | 'alert-triangle' + | 'add-owner' + | 'address-book' + | 'address-book-empty-list' | 'alert' - | 'info' - | 'question' - | 'points' - | 'code-blocks' - | 'hardware' - | 'keystone' - | 'ledger' - | 'seed' - | 'key' - | 'dapp-logo' - | 'double-arrow' - | 'arrow-sort' - | 'dropdown-arrow-small' - | 'options-vertical' - | 'options-horizontal' - | 'check-oulined' - | 'check' - | 'check-filled' - | 'arrow-down-1' + | 'alert-triangle' + | 'appearance' + | 'apps' | 'arrow-down' - | 'arrow-up' + | 'arrow-down-1' | 'arrow-left' | 'arrow-right' - | 'tag' + | 'arrow-sort' + | 'arrow-up' + | 'arrrow-right' + | 'batch' + | 'bell' + | 'block' + | 'blocks' + | 'blocks-1' + | 'bookmark' + | 'bookmark-filled' | 'camera' - | 'element-drag' - | 'transaction-partial-fill' - | 'rows-2' + | 'chat' + | 'check' + | 'check-filled' | 'check-notifications' - | 'qr-code-1' - | 'scan-1' - | 'shield-crossed' - | 'shield' + | 'check-oulined' + | 'chevron-down' + | 'chevron-left' + | 'chevron-right' + | 'chevron-up' | 'clock' - | 'update' - | 'repeat' + | 'close' + | 'close-filled' + | 'close-outlined' + | 'code-blocks' + | 'copy' + | 'dapp-logo' + | 'delete' + | 'desktop' + | 'document' + | 'double-arrow' | 'download' - | 'upload' - | 'qr-code' - | 'scan' + | 'dropdown-arrow-small' + | 'edit' + | 'edit-owner' + | 'element-drag' + | 'experimental' + | 'export' + | 'external-link' | 'eye-n' | 'eye-off' - | 'unlock' + | 'eye-on' + | 'face-id' + | 'file' + | 'filter' + | 'fingerprint' + | 'hardware' + | 'home' + | 'info' + | 'key' + | 'keystone' + | 'ledger' + | 'lightbulb' + | 'link' | 'lock' - | 'replace-owner' - | 'edit-owner' - | 'add-owner' - | 'send-to' + | 'magic' + | 'mobile' + | 'nft' + | 'options-horizontal' + | 'options-vertical' | 'owners' - | 'link' - | 'share' - | 'external-link' - | 'export' | 'paste' - | 'copy' - | 'sign' - | 'document' - | 'file' - | 'search' - | 'edit' - | 'delete' - | 'close-outlined' - | 'close-filled' - | 'close' - | 'plus-outlined' - | 'plus-filled' | 'plus' - | 'transaction-Batch' - | 'blocks-1' + | 'plus-filled' + | 'plus-outlined' + | 'points' + | 'qr-code' + | 'qr-code-1' + | 'question' + | 'repeat' + | 'replace-owner' + | 'rows' | 'rows-1' - | 'batch' - | 'filter' - | 'bookmark-filled' - | 'bookmark' - | 'transaction-recovery' + | 'rows-2' + | 'safe' + | 'scan' + | 'scan-1' + | 'search' + | 'seed' + | 'send-to' + | 'settings' + | 'share' + | 'shield' + | 'shield-crossed' + | 'sign' + | 'signature' + | 'star' + | 'tag' + | 'token' + | 'transaction-batch' | 'transaction-change-settings' | 'transaction-contract' + | 'transaction-earn' | 'transaction-execute' + | 'transaction-incoming' + | 'transaction-outgoing' + | 'transaction-partial-fill' + | 'transaction-recovery' | 'transaction-stake' | 'transaction-swap' - | 'transaction-outgoing' - | 'transaction-incoming' - | 'mobile' + | 'transactions' + | 'twitter-x' + | 'unlock' + | 'update' + | 'upload' | 'wallet' - | 'appearance' - | 'experimental' - | 'desktop' - | 'safe' - | 'bell' - | 'lightbulb' | 'what-is-new' - | 'blocks' - | 'rows' - | 'apps' - | 'address-book' - | 'chat' - | 'settings' - | 'transactions' - | 'nft' - | 'token' - | 'home' export const iconNames: IconName[] = [ - 'block', - 'alert-triangle', + 'add-owner', + 'address-book', + 'address-book-empty-list', 'alert', - 'info', - 'question', - 'points', - 'code-blocks', - 'hardware', - 'keystone', - 'ledger', - 'seed', - 'key', - 'dapp-logo', - 'double-arrow', - 'arrow-sort', - 'dropdown-arrow-small', - 'options-vertical', - 'options-horizontal', - 'check-oulined', - 'check', - 'check-filled', - 'arrow-down-1', + 'alert-triangle', + 'appearance', + 'apps', 'arrow-down', - 'arrow-up', + 'arrow-down-1', 'arrow-left', 'arrow-right', - 'tag', + 'arrow-sort', + 'arrow-up', + 'arrrow-right', + 'batch', + 'bell', + 'block', + 'blocks', + 'blocks-1', + 'bookmark', + 'bookmark-filled', 'camera', - 'element-drag', - 'transaction-partial-fill', - 'rows-2', + 'chat', + 'check', + 'check-filled', 'check-notifications', - 'qr-code-1', - 'scan-1', - 'shield-crossed', - 'shield', + 'check-oulined', + 'chevron-down', + 'chevron-left', + 'chevron-right', + 'chevron-up', 'clock', - 'update', - 'repeat', + 'close', + 'close-filled', + 'close-outlined', + 'code-blocks', + 'copy', + 'dapp-logo', + 'delete', + 'desktop', + 'document', + 'double-arrow', 'download', - 'upload', - 'qr-code', - 'scan', + 'dropdown-arrow-small', + 'edit', + 'edit-owner', + 'element-drag', + 'experimental', + 'export', + 'external-link', 'eye-n', 'eye-off', - 'unlock', + 'eye-on', + 'face-id', + 'file', + 'filter', + 'fingerprint', + 'hardware', + 'home', + 'info', + 'key', + 'keystone', + 'ledger', + 'lightbulb', + 'link', 'lock', - 'replace-owner', - 'edit-owner', - 'add-owner', - 'send-to', + 'magic', + 'mobile', + 'nft', + 'options-horizontal', + 'options-vertical', 'owners', - 'link', - 'share', - 'external-link', - 'export', 'paste', - 'copy', - 'sign', - 'document', - 'file', - 'search', - 'edit', - 'delete', - 'close-outlined', - 'close-filled', - 'close', - 'plus-outlined', - 'plus-filled', 'plus', - 'transaction-Batch', - 'blocks-1', + 'plus-filled', + 'plus-outlined', + 'points', + 'qr-code', + 'qr-code-1', + 'question', + 'repeat', + 'replace-owner', + 'rows', 'rows-1', - 'batch', - 'filter', - 'bookmark-filled', - 'bookmark', - 'transaction-recovery', + 'rows-2', + 'safe', + 'scan', + 'scan-1', + 'search', + 'seed', + 'send-to', + 'settings', + 'share', + 'shield', + 'shield-crossed', + 'sign', + 'signature', + 'star', + 'tag', + 'token', + 'transaction-batch', 'transaction-change-settings', 'transaction-contract', + 'transaction-earn', 'transaction-execute', + 'transaction-incoming', + 'transaction-outgoing', + 'transaction-partial-fill', + 'transaction-recovery', 'transaction-stake', 'transaction-swap', - 'transaction-outgoing', - 'transaction-incoming', - 'mobile', + 'transactions', + 'twitter-x', + 'unlock', + 'update', + 'upload', 'wallet', - 'appearance', - 'experimental', - 'desktop', - 'safe', - 'bell', - 'lightbulb', 'what-is-new', - 'blocks', - 'rows', - 'apps', - 'address-book', - 'chat', - 'settings', - 'transactions', - 'nft', - 'token', - 'home', ] diff --git a/apps/mobile/src/types/theme.ts b/apps/mobile/src/types/theme.ts new file mode 100644 index 0000000000..c111cdd4f9 --- /dev/null +++ b/apps/mobile/src/types/theme.ts @@ -0,0 +1 @@ +export type ThemePreference = 'light' | 'dark' | 'auto' diff --git a/apps/mobile/src/types/txType.ts b/apps/mobile/src/types/txType.ts new file mode 100644 index 0000000000..9ee923f225 --- /dev/null +++ b/apps/mobile/src/types/txType.ts @@ -0,0 +1,16 @@ +export enum ETxType { + ADD_SIGNER = 'ADD_SIGNER', + REMOVE_SIGNER = 'REMOVE_SIGNER', + STAKE_DEPOSIT = 'STAKE_DEPOSIT', + STAKE_WITHDRAW_REQUEST = 'STAKE_WITHDRAW_REQUEST', + STAKE_EXIT = 'STAKE_EXIT', + CONTRACT_INTERACTION = 'CONTRACT_INTERACTION', + SWAP_ORDER = 'SWAP_ORDER', + BRIDGE_ORDER = 'BRIDGE_ORDER', + LIFI_SWAP = 'LIFI_SWAP', + NFT_TRANSFER = 'NFT_TRANSFER', + TOKEN_TRANSFER = 'TOKEN_TRANSFER', + VAULT_DEPOSIT = 'VAULT_DEPOSIT', + VAULT_REDEEM = 'VAULT_REDEEM', + CANCEL_TX = 'CANCEL_TX', +} diff --git a/apps/mobile/src/utils/balance.test.ts b/apps/mobile/src/utils/balance.test.ts new file mode 100644 index 0000000000..6ad16aa3f3 --- /dev/null +++ b/apps/mobile/src/utils/balance.test.ts @@ -0,0 +1,20 @@ +import { shouldDisplayPreciseBalance } from './balance' + +describe('shouldDisplayPreciseBalance', () => { + it('returns true for balance amounts with less than 8 digits before the decimal point', () => { + expect(shouldDisplayPreciseBalance('210.2122')).toBe(true) + expect(shouldDisplayPreciseBalance('5.2213')).toBe(true) + expect(shouldDisplayPreciseBalance('1234567.89')).toBe(true) + }) + + it('returns false for balance amounts with 8 or more digits before the decimal point', () => { + expect(shouldDisplayPreciseBalance('83892893298.3838')).toBe(false) + expect(shouldDisplayPreciseBalance('12345678.1234')).toBe(false) + expect(shouldDisplayPreciseBalance('10000000.00')).toBe(false) + }) + + it('handles balance amounts without a decimal point', () => { + expect(shouldDisplayPreciseBalance('1234567')).toBe(true) + expect(shouldDisplayPreciseBalance('12345678')).toBe(false) + }) +}) diff --git a/apps/mobile/src/utils/balance.ts b/apps/mobile/src/utils/balance.ts new file mode 100644 index 0000000000..ca2a435c97 --- /dev/null +++ b/apps/mobile/src/utils/balance.ts @@ -0,0 +1,3 @@ +export const shouldDisplayPreciseBalance = (balanceAmount: string, integerPartLength = 8) => { + return balanceAmount.split('.')[0].length < integerPartLength +} diff --git a/apps/mobile/src/utils/chains.test.ts b/apps/mobile/src/utils/chains.test.ts new file mode 100644 index 0000000000..18158ea782 --- /dev/null +++ b/apps/mobile/src/utils/chains.test.ts @@ -0,0 +1,27 @@ +import { getAvailableChainsNames } from './chains' +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' + +describe('getAvailableChainsNames', () => { + it('returns an empty string when the chains array is empty', () => { + expect(getAvailableChainsNames([])).toBe('') + }) + + it('returns the single chain name when only one chain is provided', () => { + const chains = [{ chainName: 'Ethereum' }] as Chain[] + expect(getAvailableChainsNames(chains)).toBe('Ethereum') + }) + + it('returns a properly formatted string for two chains', () => { + const chains = [{ chainName: 'Ethereum' }, { chainName: 'Polygon' }] as Chain[] + expect(getAvailableChainsNames(chains)).toBe('Ethereum and Polygon') + }) + + it('returns a properly formatted string for multiple chains', () => { + const chains = [ + { chainName: 'Ethereum' }, + { chainName: 'Polygon' }, + { chainName: 'Binance Smart Chain' }, + ] as Chain[] + expect(getAvailableChainsNames(chains)).toBe('Ethereum, Polygon and Binance Smart Chain') + }) +}) diff --git a/apps/mobile/src/utils/chains.ts b/apps/mobile/src/utils/chains.ts new file mode 100644 index 0000000000..797e9b0792 --- /dev/null +++ b/apps/mobile/src/utils/chains.ts @@ -0,0 +1,20 @@ +import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' + +export const getAvailableChainsNames = (chains: Chain[]) => { + if (chains.length === 0) { + return '' + } + + if (chains.length === 1) { + return chains[0].chainName + } + + return ( + chains + .slice(0, -1) + .map((chain) => chain.chainName) + .join(', ') + + ' and ' + + chains[chains.length - 1].chainName + ) +} diff --git a/apps/mobile/src/utils/currency.test.ts b/apps/mobile/src/utils/currency.test.ts new file mode 100644 index 0000000000..4a6a0691d1 --- /dev/null +++ b/apps/mobile/src/utils/currency.test.ts @@ -0,0 +1,40 @@ +import { getCurrencyName, getCurrencySymbol } from './currency' + +describe('currency utils', () => { + describe('getCurrencyName', () => { + it('returns the correct name for USD', () => { + const name = getCurrencyName('USD', 'en') + // Accept either "US Dollar" or "USD" as fallback + expect(['US Dollar', 'USD']).toContain(name) + }) + + it('returns the correct name for EUR', () => { + const name = getCurrencyName('EUR', 'en') + expect(['Euro', 'EUR']).toContain(name) + }) + + it('falls back to code for unknown currency', () => { + const name = getCurrencyName('FOO', 'en') + expect(name).toBe('FOO') + }) + }) + + describe('getCurrencySymbol', () => { + it('returns the correct symbol for USD', () => { + const symbol = getCurrencySymbol('USD', 'en') + // Accept $ or USD as fallback + expect(['$', 'USD']).toContain(symbol) + }) + + it('returns the correct symbol for EUR', () => { + const symbol = getCurrencySymbol('EUR', 'en') + // Accept € or EUR as fallback + expect(['€', 'EUR']).toContain(symbol) + }) + + it('falls back to code for unknown currency', () => { + const symbol = getCurrencySymbol('FOO', 'en') + expect(symbol).toBe('FOO') + }) + }) +}) diff --git a/apps/mobile/src/utils/currency.ts b/apps/mobile/src/utils/currency.ts new file mode 100644 index 0000000000..8153c7e3fa --- /dev/null +++ b/apps/mobile/src/utils/currency.ts @@ -0,0 +1,38 @@ +const cryptoFallBackNames = { + BTC: 'Bitcoin', + ETH: 'Ethereum', +} + +export const getCurrencyName = (currency: string, locale = 'en') => { + try { + if (typeof Intl.DisplayNames === 'function') { + const displayNames = new Intl.DisplayNames([locale], { type: 'currency' }) + const name = displayNames.of(currency) + if (cryptoFallBackNames[name as keyof typeof cryptoFallBackNames]) { + return cryptoFallBackNames[name as keyof typeof cryptoFallBackNames] + } + return name || currency + } + } catch (_e) { + // Fallback to code if Intl.DisplayNames fails + } + return currency +} + +export const getCurrencySymbol = (currency: string, locale = 'en') => { + try { + const formatted = new Intl.NumberFormat(locale, { + style: 'currency', + currency, + currencyDisplay: 'symbol', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(1) + // Remove all digits, spaces, and punctuation, leaving the symbol + const symbol = formatted.replace(/[\d\s.,]/g, '') + return symbol || currency + } catch (_e) { + // Fallback to code if Intl.NumberFormat fails + } + return currency +} diff --git a/apps/mobile/src/utils/delegate.ts b/apps/mobile/src/utils/delegate.ts new file mode 100644 index 0000000000..ad99f69f2a --- /dev/null +++ b/apps/mobile/src/utils/delegate.ts @@ -0,0 +1,3 @@ +export const getDelegateKeyId = (ownerAddress: string, delegateAddress: string): string => { + return `delegate_${ownerAddress}_${delegateAddress}` +} diff --git a/apps/mobile/src/utils/errors/index.ts b/apps/mobile/src/utils/errors/index.ts new file mode 100644 index 0000000000..aada7e8908 --- /dev/null +++ b/apps/mobile/src/utils/errors/index.ts @@ -0,0 +1 @@ +export * from './standardErrors' diff --git a/apps/mobile/src/utils/errors/standardErrors.ts b/apps/mobile/src/utils/errors/standardErrors.ts new file mode 100644 index 0000000000..e9f054bf9e --- /dev/null +++ b/apps/mobile/src/utils/errors/standardErrors.ts @@ -0,0 +1,66 @@ +export enum ErrorType { + VALIDATION_ERROR = 'VALIDATION_ERROR', + NETWORK_ERROR = 'NETWORK_ERROR', + AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR', + STORAGE_ERROR = 'STORAGE_ERROR', + CLEANUP_ERROR = 'CLEANUP_ERROR', + USER_CANCELLED = 'USER_CANCELLED', + SYSTEM_ERROR = 'SYSTEM_ERROR', +} + +export interface StandardErrorResult<T = unknown> { + success: boolean + data?: T + error?: { + type: ErrorType + message: string + code?: string + details?: Record<string, unknown> + originalError?: unknown + } +} + +export function createStandardError( + type: ErrorType, + message: string, + originalError?: unknown, + details?: Record<string, unknown>, +): StandardErrorResult['error'] { + return { + type, + message, + code: type, + details, + originalError, + } +} + +export function standardizeError( + error: unknown, + fallbackType: ErrorType = ErrorType.SYSTEM_ERROR, + fallbackMessage = 'An unexpected error occurred', + details?: Record<string, unknown>, +): StandardErrorResult['error'] { + if (error instanceof Error) { + return createStandardError(fallbackType, error.message, error, details) + } + + const message = typeof error === 'string' ? error : fallbackMessage + return createStandardError(fallbackType, message, error, details) +} + +export function createSuccessResult<T>(data?: T): StandardErrorResult<T> { + return { success: true, data } +} + +export function createErrorResult<T>( + type: ErrorType, + message: string, + originalError?: unknown, + details?: Record<string, unknown>, +): StandardErrorResult<T> { + return { + success: false, + error: createStandardError(type, message, originalError, details), + } +} diff --git a/apps/mobile/src/utils/formatters.ts b/apps/mobile/src/utils/formatters.ts index 86db4980a9..7d0433b2e3 100644 --- a/apps/mobile/src/utils/formatters.ts +++ b/apps/mobile/src/utils/formatters.ts @@ -1,3 +1,15 @@ +import { SwapOrderTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { formatUnits } from 'ethers' + +type Quantity = { + amount: string | number | bigint + decimals: number +} + +function asDecimal(amount: number | bigint, decimals: number): number { + return Number(formatUnits(amount, decimals)) +} + export const ellipsis = (str: string, length: number): string => { return str.length > length ? `${str.slice(0, length)}...` : str } @@ -15,3 +27,25 @@ export const shortenAddress = (address: string, length = 4): string => { export const formatValue = (value: string, decimals: number): string => { return (parseInt(value) / 10 ** decimals).toString().substring(0, 8) } + +export const getLimitPrice = ( + order: Pick<SwapOrderTransactionInfo, 'sellAmount' | 'buyAmount' | 'buyToken' | 'sellToken'>, +): number => { + const { sellAmount, buyAmount, buyToken, sellToken } = order + + const ratio = calculateRatio( + { amount: sellAmount, decimals: sellToken.decimals }, + { amount: buyAmount, decimals: buyToken.decimals }, + ) + + return ratio +} + +const calculateRatio = (a: Quantity, b: Quantity) => { + if (BigInt(b.amount) === 0n) { + return 0 + } + return asDecimal(BigInt(a.amount), a.decimals) / asDecimal(BigInt(b.amount), b.decimals) +} + +export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1) diff --git a/apps/mobile/src/utils/gateway.test.ts b/apps/mobile/src/utils/gateway.test.ts deleted file mode 100644 index 80d2c7e3ab..0000000000 --- a/apps/mobile/src/utils/gateway.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { getExplorerLink, getHashedExplorerUrl, _replaceTemplate } from './gateway' - -describe('gateway', () => { - describe('replaceTemplate', () => { - it('should replace template syntax with data', () => { - const uri = 'Hello {{this}}' - const data = { this: 'world' } - - const result = _replaceTemplate(uri, data) - expect(result).toEqual('Hello world') - }) - it("shouldn't replace non-template text", () => { - const uri = 'Hello this' - const data = { this: 'world' } - - const result = _replaceTemplate(uri, data) - expect(result).toEqual('Hello this') - }) - }) - - describe('getHashedExplorerUrl', () => { - it('should return a url with a transaction hash', () => { - const txHash = '0x4d32cc132307cde65b44162156f961ed421a84f83bb8cf3730c91f53374cc5de' - - const result = getHashedExplorerUrl(txHash, { - address: 'https://etherscan.io/address/{{address}}', - txHash: 'https://etherscan.io/tx/{{txHash}}', - api: 'https://api.etherscan.io/api?module={{module}}&action={{action}}&address={{address}}&apiKey={{apiKey}}', - }) - - expect(result).toEqual( - 'https://etherscan.io/tx/0x4d32cc132307cde65b44162156f961ed421a84f83bb8cf3730c91f53374cc5de', - ) - }) - it('should return a url with an address', () => { - const address = '0xabcdbc2ecb47642ee8cf52fd7b88fa42fbb69f98' - - const result = getHashedExplorerUrl(address, { - address: 'https://etherscan.io/address/{{address}}', - txHash: 'https://etherscan.io/tx/{{txHash}}', - api: 'https://api.etherscan.io/api?module={{module}}&action={{action}}&address={{address}}&apiKey={{apiKey}}', - }) - - expect(result).toEqual('https://etherscan.io/address/0xabcdbc2ecb47642ee8cf52fd7b88fa42fbb69f98') - }) - }) - - describe('getExplorerLink', () => { - it('should return an object with a href and title', () => { - const address = '0xabcdbc2ecb47642ee8cf52fd7b88fa42fbb69f98' - - const { href, title } = getExplorerLink(address, { - address: 'https://etherscan.io/address/{{address}}', - txHash: 'https://etherscan.io/tx/{{txHash}}', - api: 'https://api.etherscan.io/api?module={{module}}&action={{action}}&address={{address}}&apiKey={{apiKey}}', - }) - - expect(href).toEqual('https://etherscan.io/address/0xabcdbc2ecb47642ee8cf52fd7b88fa42fbb69f98') - expect(title).toEqual('View on etherscan.io') - }) - }) -}) diff --git a/apps/mobile/src/utils/legacyData.test.ts b/apps/mobile/src/utils/legacyData.test.ts new file mode 100644 index 0000000000..6df7471237 --- /dev/null +++ b/apps/mobile/src/utils/legacyData.test.ts @@ -0,0 +1,113 @@ +import crypto from 'crypto' +jest.mock('react-native-quick-crypto', () => require('crypto')) +import { + decodeLegacyData, + SecuredDataFile, + SerializedDataFile, + LegacyDataPasswordError, + LegacyDataFormatError, + LegacyDataCorruptedError, +} from './legacyData' + +describe('decodeLegacyData', () => { + it('decodes encrypted file', () => { + const password = 'test-password' + const data: SerializedDataFile = { version: '1.0', data: { hello: 'world' } } + const salt = crypto.randomBytes(32) + const key = crypto.pbkdf2Sync(Buffer.from(password, 'utf8'), salt, 100000, 32, 'sha256') + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) + const encrypted = Buffer.concat([cipher.update(JSON.stringify(data), 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + const combined = Buffer.concat([iv, encrypted, tag]) + const file: SecuredDataFile = { + version: '1.0', + algo: 'aes-256-gcm', + salt: salt.toString('base64'), + rounds: 100000, + data: combined.toString('base64'), + } + + const decoded = decodeLegacyData(file, password) + expect(decoded).toEqual(data) + }) + + it('throws LegacyDataFormatError for unsupported version', () => { + const file = { + version: '2.0', + algo: 'aes-256-gcm', + salt: 'dGVzdC1zYWx0', + rounds: 100000, + data: 'dGVzdC1kYXRh', + } as unknown as SecuredDataFile + + expect(() => decodeLegacyData(file, 'password')).toThrow(LegacyDataFormatError) + }) + + it('throws LegacyDataFormatError for unsupported algorithm', () => { + const file = { + version: '1.0', + algo: 'aes-128-cbc', + salt: 'dGVzdC1zYWx0', + rounds: 100000, + data: 'dGVzdC1kYXRh', + } as unknown as SecuredDataFile + + expect(() => decodeLegacyData(file, 'password')).toThrow(LegacyDataFormatError) + }) + + it('throws LegacyDataPasswordError for wrong password', () => { + const password = 'correct-password' + const wrongPassword = 'wrong-password' + const data: SerializedDataFile = { version: '1.0', data: { hello: 'world' } } + const salt = crypto.randomBytes(32) + const key = crypto.pbkdf2Sync(Buffer.from(password, 'utf8'), salt, 100000, 32, 'sha256') + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) + const encrypted = Buffer.concat([cipher.update(JSON.stringify(data), 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + const combined = Buffer.concat([iv, encrypted, tag]) + const file: SecuredDataFile = { + version: '1.0', + algo: 'aes-256-gcm', + salt: salt.toString('base64'), + rounds: 100000, + data: combined.toString('base64'), + } + + expect(() => decodeLegacyData(file, wrongPassword)).toThrow(LegacyDataPasswordError) + }) + + it('throws LegacyDataCorruptedError for invalid JSON after successful decryption', () => { + const password = 'test-password' + const invalidJsonData = 'invalid json content' + const salt = crypto.randomBytes(32) + const key = crypto.pbkdf2Sync(Buffer.from(password, 'utf8'), salt, 100000, 32, 'sha256') + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) + const encrypted = Buffer.concat([cipher.update(invalidJsonData, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + const combined = Buffer.concat([iv, encrypted, tag]) + const file: SecuredDataFile = { + version: '1.0', + algo: 'aes-256-gcm', + salt: salt.toString('base64'), + rounds: 100000, + data: combined.toString('base64'), + } + + expect(() => decodeLegacyData(file, password)).toThrow(LegacyDataCorruptedError) + }) + + it('throws LegacyDataFormatError for invalid base64 data', () => { + const file: SecuredDataFile = { + version: '1.0', + algo: 'aes-256-gcm', + salt: 'dGVzdC1zYWx0', + rounds: 100000, + data: 'invalid base64!@#$%', + } + + expect(() => decodeLegacyData(file, 'password')).toThrow(LegacyDataFormatError) + }) +}) diff --git a/apps/mobile/src/utils/legacyData.ts b/apps/mobile/src/utils/legacyData.ts new file mode 100644 index 0000000000..e4237ec30c --- /dev/null +++ b/apps/mobile/src/utils/legacyData.ts @@ -0,0 +1,78 @@ +import crypto from 'react-native-quick-crypto' + +export type SecuredDataFile = { + version: '1.0' + algo: 'aes-256-gcm' + salt: string + rounds: number + data: string +} + +export type SerializedDataFile = { + version: '1.0' + data: unknown +} + +// Custom error classes for safe error categorization +export class LegacyDataPasswordError extends Error { + constructor() { + super('Invalid password for legacy data') + this.name = 'LegacyDataPasswordError' + } +} + +export class LegacyDataFormatError extends Error { + constructor() { + super('Invalid legacy data format') + this.name = 'LegacyDataFormatError' + } +} + +export class LegacyDataCorruptedError extends Error { + constructor() { + super('Legacy data appears to be corrupted') + this.name = 'LegacyDataCorruptedError' + } +} + +export function decodeLegacyData(file: SecuredDataFile, password: string): SerializedDataFile { + if (file.version !== '1.0' || file.algo !== 'aes-256-gcm') { + throw new LegacyDataFormatError() + } + + try { + const salt = Buffer.from(file.salt, 'base64') + const key = crypto.pbkdf2Sync(Buffer.from(password, 'utf8'), salt, file.rounds, 32, 'sha256') + const combined = Buffer.from(file.data, 'base64') + const iv = combined.subarray(0, 12) + const tag = combined.subarray(combined.length - 16) + const ciphertext = combined.subarray(12, combined.length - 16) + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(tag) + + let decrypted: Buffer + try { + decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + } catch (_cryptoError) { + // Authentication tag verification failure or decryption error = wrong password + throw new LegacyDataPasswordError() + } + + try { + return JSON.parse(decrypted.toString('utf8')) + } catch (_jsonError) { + // Successfully decrypted but invalid JSON = corrupted data + throw new LegacyDataCorruptedError() + } + } catch (error) { + // Re-throw our custom errors + if (error instanceof LegacyDataPasswordError || error instanceof LegacyDataCorruptedError) { + throw error + } + + // For any other errors (base64 decode, buffer operations, etc.) + // assume it's a format/corruption issue + throw new LegacyDataFormatError() + } +} diff --git a/apps/mobile/src/utils/notifications/__tests__/cleanup.test.ts b/apps/mobile/src/utils/notifications/__tests__/cleanup.test.ts new file mode 100644 index 0000000000..329f80620a --- /dev/null +++ b/apps/mobile/src/utils/notifications/__tests__/cleanup.test.ts @@ -0,0 +1,77 @@ +import { classifyNotificationError, createSubscriptionData } from '../cleanup' + +describe('notification cleanup utilities', () => { + describe('classifyNotificationError', () => { + it('should classify 404 errors as safe', () => { + const error = { status: 404, message: 'Not found' } + const result = classifyNotificationError(error) + + expect(result.type).toBe('safe') + expect(result.message).toBe('Subscription already removed') + }) + + it('should classify server errors as blocking', () => { + const error = { status: 500, message: 'Internal server error' } + const result = classifyNotificationError(error) + + expect(result.type).toBe('blocking') + expect(result.message).toContain('Server error (500)') + }) + + it('should classify rate limiting as blocking', () => { + const error = { status: 429, message: 'Too many requests' } + const result = classifyNotificationError(error) + + expect(result.type).toBe('blocking') + expect(result.message).toContain('Rate limited') + }) + + it('should classify network errors as blocking', () => { + const error = new Error('Network timeout') + const result = classifyNotificationError(error) + + expect(result.type).toBe('blocking') + expect(result.message).toBe('Network error: Cannot verify subscription removal') + }) + }) + + describe('createSubscriptionData', () => { + it('should create subscription data without delegate address', async () => { + const result = await createSubscriptionData('0xSafe1', ['1', '137'], 'device123') + + expect(result).toEqual([ + { + chainId: '1', + deviceUuid: 'device123', + safeAddress: '0xSafe1', + }, + { + chainId: '137', + deviceUuid: 'device123', + safeAddress: '0xSafe1', + }, + ]) + }) + + it('should create subscription data with delegate address', async () => { + const result = await createSubscriptionData('0xSafe1', ['1'], 'device123', '0xDelegate1') + + expect(result).toEqual([ + { + chainId: '1', + deviceUuid: 'device123', + safeAddress: '0xSafe1', + signerAddress: '0xDelegate1', + }, + ]) + }) + + it('should handle multiple chains with delegate', async () => { + const result = await createSubscriptionData('0xSafe1', ['1', '137', '10'], 'device123', '0xDelegate1') + + expect(result).toHaveLength(3) + expect(result.every((item) => item.signerAddress === '0xDelegate1')).toBe(true) + expect(result.map((item) => item.chainId)).toEqual(['1', '137', '10']) + }) + }) +}) diff --git a/apps/mobile/src/utils/notifications/accountType.test.ts b/apps/mobile/src/utils/notifications/accountType.test.ts new file mode 100644 index 0000000000..88acd129f9 --- /dev/null +++ b/apps/mobile/src/utils/notifications/accountType.test.ts @@ -0,0 +1,34 @@ +import { getAccountType } from './accountType' +import { NOTIFICATION_ACCOUNT_TYPE } from '@/src/store/constants' +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +const mockSafeInfo: SafeOverview = { + owners: [{ value: '0x123' }], + address: { value: '0x123' }, + chainId: '1', + threshold: 1, + fiatTotal: '0', + queued: 0, +} as SafeOverview + +const signers: Record<string, AddressInfo> = { + '0x123': { value: '0x123' }, +} + +describe('getAccountType', () => { + it('returns REGULAR when safeInfo is undefined', () => { + const result = getAccountType(undefined, signers) + expect(result).toEqual({ ownerFound: null, accountType: NOTIFICATION_ACCOUNT_TYPE.REGULAR }) + }) + + it('returns OWNER when owner matches a signer', () => { + const result = getAccountType(mockSafeInfo, signers) + expect(result).toEqual({ ownerFound: { value: '0x123' }, accountType: NOTIFICATION_ACCOUNT_TYPE.OWNER }) + }) + + it('returns REGULAR when no owners match signers', () => { + const result = getAccountType(mockSafeInfo, {}) + expect(result).toEqual({ ownerFound: null, accountType: NOTIFICATION_ACCOUNT_TYPE.REGULAR }) + }) +}) diff --git a/apps/mobile/src/utils/notifications/accountType.ts b/apps/mobile/src/utils/notifications/accountType.ts new file mode 100644 index 0000000000..60a3beb642 --- /dev/null +++ b/apps/mobile/src/utils/notifications/accountType.ts @@ -0,0 +1,16 @@ +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { NOTIFICATION_ACCOUNT_TYPE } from '@/src/store/constants' + +export const getAccountType = (safeInfo: SafeOverview | undefined, signers: Record<string, AddressInfo>) => { + if (!safeInfo) { + return { ownerFound: null, accountType: NOTIFICATION_ACCOUNT_TYPE.REGULAR } + } + + const ownerFound = safeInfo.owners.find((owner) => signers[owner.value]) ?? null + + return { + ownerFound, + accountType: ownerFound ? NOTIFICATION_ACCOUNT_TYPE.OWNER : NOTIFICATION_ACCOUNT_TYPE.REGULAR, + } +} diff --git a/apps/mobile/src/utils/notifications/cleanup.ts b/apps/mobile/src/utils/notifications/cleanup.ts new file mode 100644 index 0000000000..3ec91c0421 --- /dev/null +++ b/apps/mobile/src/utils/notifications/cleanup.ts @@ -0,0 +1,139 @@ +import { type Address } from '@/src/types/address' +import { type RootState } from '@/src/store' +import { selectAllDelegatesForSafeOwners } from '@/src/store/delegatesSlice' +import Logger from '@/src/utils/logger' + +export interface NotificationCleanupError { + type: 'safe' | 'blocking' + message: string + originalError: unknown +} + +export interface SafeNotificationInfo { + address: string + chainIds: string[] +} + +/** + * Classifies errors to determine if they should block private key deletion + */ +export const classifyNotificationError = (error: unknown): NotificationCleanupError => { + if (error && typeof error === 'object' && 'status' in error) { + const status = (error as { status: number }).status + + // Safe errors - don't block deletion + if (status === 404 || status === 410) { + return { + type: 'safe', + message: 'Subscription already removed', + originalError: error, + } + } + + // Blocking errors - prevent deletion + if (status >= 500 || status === 401 || status === 403) { + return { + type: 'blocking', + message: `Server error (${status}): Cannot verify subscription removal`, + originalError: error, + } + } + + // Rate limiting - blocking but with retry suggestion + if (status === 429) { + return { + type: 'blocking', + message: 'Rate limited: Too many requests. Please try again in a moment.', + originalError: error, + } + } + } + + // Network errors, timeouts, etc. - blocking by default + return { + type: 'blocking', + message: 'Network error: Cannot verify subscription removal', + originalError: error, + } +} + +export const getAffectedSafes = ( + ownerAddress: Address, + allSafes: RootState['safes'], + allChains: { chainId: string }[], + safeSubscriptions: RootState['safeSubscriptions'], +): SafeNotificationInfo[] => { + const affectedSafes: SafeNotificationInfo[] = [] + + Object.entries(allSafes).forEach(([safeAddress, chainDeployments]) => { + // Check if this owner is part of this safe + const isOwner = Object.values(chainDeployments).some((deployment) => + deployment.owners.some((owner) => owner.value === ownerAddress), + ) + + if (isOwner) { + // Get chains where this safe is subscribed to notifications + const subscribedChains = allChains + .map((chain) => chain.chainId) + .filter((chainId) => { + const subscriptionStatus = safeSubscriptions[safeAddress]?.[chainId] + return subscriptionStatus === true + }) + + if (subscribedChains.length > 0) { + affectedSafes.push({ + address: safeAddress, + chainIds: subscribedChains, + }) + } + } + }) + + return affectedSafes +} + +export const hasOtherDelegates = ( + safeAddress: Address, + excludeDelegateAddress: Address, + state: Pick<RootState, 'safes' | 'delegates'>, +): boolean => { + const allSafeDelegates = selectAllDelegatesForSafeOwners(state, safeAddress) + return allSafeDelegates.some((delegate) => delegate.delegateAddress !== excludeDelegateAddress) +} + +export const createSubscriptionData = async ( + safeAddress: string, + chainIds: string[], + deviceUuid: string, + delegateAddress?: string, +) => { + return chainIds.map((chainId) => ({ + chainId, + deviceUuid, + safeAddress, + ...(delegateAddress && { signerAddress: delegateAddress }), + })) +} + +/** + * Clears authentication cookies before making non-authenticated API calls. + * This is a workaround for the React Native bug where credentials: 'omit' is ignored + * on Android and cookies are always sent. + * + * We do inline import to avoid circular dependency. + * + * @see https://github.com/facebook/react-native/issues/12956 + */ +export const clearAuthBeforeUnauthenticatedCall = async (): Promise<void> => { + try { + const { cgwApi: authApi } = await import('@safe-global/store/gateway/AUTO_GENERATED/auth') + const { getStore } = await import('@/src/store/utils/singletonStore') + + await getStore().dispatch(authApi.endpoints.authLogoutV1.initiate()).unwrap() + + Logger.info('Cleared authentication cookies for unauthenticated API call') + } catch (error) { + // Logout failure shouldn't block the main operation + Logger.warn('Failed to clear authentication cookies', { error }) + } +} diff --git a/apps/mobile/src/utils/notifications/index.ts b/apps/mobile/src/utils/notifications/index.ts index 49bcd2f68b..161b7ff9a5 100644 --- a/apps/mobile/src/utils/notifications/index.ts +++ b/apps/mobile/src/utils/notifications/index.ts @@ -1,4 +1,6 @@ -import { AndroidChannel, AndroidImportance } from '@notifee/react-native' +import { AndroidChannel, AndroidImportance, AndroidVisibility } from '@notifee/react-native' +import { NotificationType } from '@safe-global/store/gateway/AUTO_GENERATED/notifications' +import { HDNodeWallet, Wallet } from 'ethers' export enum ChannelId { DEFAULT_NOTIFICATION_CHANNEL_ID = 'DEFAULT_NOTIFICATION_CHANNEL_ID', @@ -18,6 +20,7 @@ export const notificationChannels = [ lights: true, vibration: true, importance: AndroidImportance.HIGH, + visibility: AndroidVisibility.PUBLIC, title: 'Transaction', subtitle: 'Transaction Complete', } as SafeAndroidChannel, @@ -27,6 +30,7 @@ export const notificationChannels = [ lights: true, vibration: true, importance: AndroidImportance.HIGH, + visibility: AndroidVisibility.PUBLIC, title: 'Announcement', subtitle: 'Safe Announcement', } as SafeAndroidChannel, @@ -36,3 +40,22 @@ export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> { const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms)) return Promise.race([promise, timeout]) } + +export function getSigner(signerPK: string): Wallet | HDNodeWallet { + const signerAccount = new Wallet(signerPK) + + return signerAccount +} + +export const REGULAR_NOTIFICATIONS: NotificationType[] = [ + 'DELETED_MULTISIG_TRANSACTION', + 'INCOMING_ETHER', + 'INCOMING_TOKEN', + 'MODULE_TRANSACTION', + 'EXECUTED_MULTISIG_TRANSACTION', +] +export const OWNER_NOTIFICATIONS: NotificationType[] = [ + ...REGULAR_NOTIFICATIONS, + 'MESSAGE_CONFIRMATION_REQUEST', + 'CONFIRMATION_REQUEST', +] diff --git a/apps/mobile/src/utils/retry.ts b/apps/mobile/src/utils/retry.ts new file mode 100644 index 0000000000..0d224a0d93 --- /dev/null +++ b/apps/mobile/src/utils/retry.ts @@ -0,0 +1,86 @@ +import Logger from '@/src/utils/logger' + +export interface RetryOptions { + maxRetries?: number + baseDelay?: number + enableJitter?: boolean + retryAllErrors?: boolean +} + +/** + * Utility for retrying operations with configurable backoff strategies + */ +export const withRetry = async <T>(operation: () => Promise<T>, options: RetryOptions = {}): Promise<T> => { + const { maxRetries = 3, baseDelay = 1000, enableJitter = false, retryAllErrors = false } = options + + let lastError: Error | null = null + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await operation() + } catch (error) { + lastError = error as Error + + if (attempt === maxRetries) { + throw lastError + } + + // Determine if we should retry this error + const shouldRetry = retryAllErrors || isRetryableError(error) + + if (!shouldRetry) { + throw lastError + } + + // Calculate delay with appropriate strategy + const delay = calculateDelay(attempt, baseDelay, enableJitter, isRateLimitError(error)) + + Logger.warn(`Operation failed, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries + 1})`, { + error: lastError.message, + attempt: attempt + 1, + maxRetries: maxRetries + 1, + }) + + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + + throw lastError +} + +function isRateLimitError(error: unknown): boolean { + if (error && typeof error === 'object') { + if ('status' in error && (error as { status: number }).status === 429) { + return true + } + + if ('message' in error) { + const message = (error as { message: string }).message.toLowerCase() + return message.includes('429') || message.includes('rate limit') || message.includes('too many requests') + } + } + + return false +} + +function isRetryableError(error: unknown): boolean { + return isRateLimitError(error) +} + +function calculateDelay(attempt: number, baseDelay: number, enableJitter: boolean, isRateLimit: boolean): number { + if (isRateLimit) { + // Exponential backoff for rate limits + const exponentialDelay = baseDelay * Math.pow(2, attempt) + const jitter = enableJitter ? Math.random() * 1000 : 0 + return exponentialDelay + jitter + } else { + // Linear backoff for other errors + return baseDelay * (attempt + 1) + } +} + +export const withRateLimitRetry = <T>(operation: () => Promise<T>, maxRetries = 3) => + withRetry(operation, { maxRetries, enableJitter: true }) + +export const withGeneralRetry = <T>(operation: () => Promise<T>, maxRetries = 3) => + withRetry(operation, { maxRetries, retryAllErrors: true }) diff --git a/apps/mobile/src/utils/signer.test.ts b/apps/mobile/src/utils/signer.test.ts new file mode 100644 index 0000000000..df8b76d7cb --- /dev/null +++ b/apps/mobile/src/utils/signer.test.ts @@ -0,0 +1,40 @@ +import { getSafeSigners } from './signer' +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +describe('getSafeSigners', () => { + const mockSafeInfo: SafeOverview = { + owners: [{ value: '0x123' }, { value: '0x456' }, { value: '0x789' }], + address: { value: '0x123' }, + chainId: '1', + threshold: 2, + fiatTotal: '0', + queued: 0, + } as SafeOverview + + const mockSigners: Record<string, AddressInfo> = { + '0x123': { value: '0x123' }, + '0x789': { value: '0x789' }, + } + + it('should return only the owners that exist in signers', () => { + const result = getSafeSigners(mockSafeInfo, mockSigners) + expect(result).toEqual(['0x123', '0x789']) + }) + + it('should return empty array when no owners match signers', () => { + const emptySigners: Record<string, AddressInfo> = {} + const result = getSafeSigners(mockSafeInfo, emptySigners) + expect(result).toEqual([]) + }) + + it('should handle case when all owners are signers', () => { + const allSigners: Record<string, AddressInfo> = { + '0x123': { value: '0x123' }, + '0x456': { value: '0x456' }, + '0x789': { value: '0x789' }, + } + const result = getSafeSigners(mockSafeInfo, allSigners) + expect(result).toEqual(['0x123', '0x456', '0x789']) + }) +}) diff --git a/apps/mobile/src/utils/signer.ts b/apps/mobile/src/utils/signer.ts new file mode 100644 index 0000000000..a6ef9bc923 --- /dev/null +++ b/apps/mobile/src/utils/signer.ts @@ -0,0 +1,7 @@ +import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +export const getSafeSigners = (SafeInfo: SafeOverview, signers: Record<string, AddressInfo>) => { + const owners = SafeInfo.owners.map((owner) => owner.value) + return owners.filter((owner) => signers[owner]) +} diff --git a/apps/mobile/src/utils/transaction-guards.ts b/apps/mobile/src/utils/transaction-guards.ts index 4733b7faa2..cd9b0a11a2 100644 --- a/apps/mobile/src/utils/transaction-guards.ts +++ b/apps/mobile/src/utils/transaction-guards.ts @@ -3,11 +3,13 @@ import { type Cancellation, type MultiSend, ConflictType, + DetailedExecutionInfoType, TransactionInfoType, TransactionListItemType, TransactionTokenType, TransferDirection, } from '@safe-global/store/gateway/types' + import type { ModuleExecutionInfo, TransactionDetails, @@ -27,6 +29,15 @@ import type { Transaction, CreationTransactionInfo, CustomTransactionInfo, + MultisigExecutionDetails, + NativeStakingDepositTransactionInfo, + NativeStakingValidatorsExitTransactionInfo, + NativeStakingWithdrawTransactionInfo, + VaultDepositTransactionInfo, + VaultRedeemTransactionInfo, + DataDecoded, + BridgeAndSwapTransactionInfo, + SwapTransactionInfo, } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' import { HistoryTransactionItems, PendingTransactionItems } from '@safe-global/store/gateway/types' @@ -47,6 +58,12 @@ export const isTxQueued = (value: Transaction['txStatus']): boolean => { ) } +export const isMultisigDetailedExecutionInfo = ( + value?: TransactionDetails['detailedExecutionInfo'], +): value is MultisigExecutionDetails => { + return value?.type === DetailedExecutionInfoType.MULTISIG +} + export const getBulkGroupTxHash = (group: PendingTransactionItems[]) => { const hashList = group.map((item) => { if (isTransactionListItem(item)) { @@ -57,6 +74,7 @@ export const getBulkGroupTxHash = (group: PendingTransactionItems[]) => { return uniq(hashList).length === 1 ? hashList[0] : undefined } +export const isArrayParameter = (parameter: string): boolean => /(\[\d*?])+$/.test(parameter) export const getTxHash = (item: TransactionQueuedItem): string => item.transaction.txHash as unknown as string export const isTransferTxInfo = (value: Transaction['txInfo']): value is TransferTransactionInfo => { @@ -66,6 +84,15 @@ export const isTransferTxInfo = (value: Transaction['txInfo']): value is Transfe export const isSettingsChangeTxInfo = (value: Transaction['txInfo']): value is SettingsChangeTransaction => { return value.type === TransactionInfoType.SETTINGS_CHANGE } + +export const isAddSignerTxInfo = (value: Transaction['txInfo']): value is SettingsChangeTransaction => { + return isSettingsChangeTxInfo(value) && value.settingsInfo?.type === 'ADD_OWNER' +} + +export const isRemoveSignerTxInfo = (value: Transaction['txInfo']): value is SettingsChangeTransaction => { + return isSettingsChangeTxInfo(value) && value.settingsInfo?.type === 'REMOVE_OWNER' +} + /** * A fulfillment transaction for swap, limit or twap order is always a SwapOrder * It cannot be a TWAP order @@ -84,6 +111,10 @@ export const isCustomTxInfo = (value: Transaction['txInfo']): value is CustomTra return value.type === TransactionInfoType.CUSTOM } +export const isChangeThresholdTxInfo = (value: Transaction['txInfo']): value is SettingsChangeTransaction => { + return value.type === TransactionInfoType.SETTINGS_CHANGE && value.settingsInfo?.type === 'CHANGE_THRESHOLD' +} + export const isMultiSendTxInfo = (value: Transaction['txInfo']): value is MultiSend => { return ( value.type === TransactionInfoType.CUSTOM && @@ -92,6 +123,10 @@ export const isMultiSendTxInfo = (value: Transaction['txInfo']): value is MultiS ) } +export const isMultiSendData = (value: DataDecoded) => { + return value.method === 'multiSend' +} + export const isSwapOrderTxInfo = (value: TransactionInfo): value is SwapOrderTransactionInfo => { return value.type === TransactionInfoType.SWAP_ORDER } @@ -107,6 +142,22 @@ export const isCancellationTxInfo = (value: Transaction['txInfo']): value is Can return isCustomTxInfo(value) && value.isCancellation } +export const isStakingTxDepositInfo = (value: Transaction['txInfo']): value is NativeStakingDepositTransactionInfo => { + return value.type === TransactionInfoType.NATIVE_STAKING_DEPOSIT +} + +export const isStakingTxExitInfo = ( + value: Transaction['txInfo'], +): value is NativeStakingValidatorsExitTransactionInfo => { + return value.type === TransactionInfoType.NATIVE_STAKING_VALIDATORS_EXIT +} + +export const isStakingTxWithdrawInfo = ( + value: Transaction['txInfo'], +): value is NativeStakingWithdrawTransactionInfo => { + return value.type === TransactionInfoType.NATIVE_STAKING_WITHDRAW +} + export const isTransactionListItem = ( value: HistoryTransactionItems | PendingTransactionItems, ): value is TransactionQueuedItem => { @@ -154,3 +205,25 @@ export const isERC20Transfer = (value: TransferTransactionInfo['transferInfo']): export const isERC721Transfer = (value: TransferTransactionInfo['transferInfo']): value is Erc721Transfer => { return value.type === TransactionTokenType.ERC721 } + +export const isVaultDepositTxInfo = (value: TransactionDetails['txInfo']): value is VaultDepositTransactionInfo => { + return value.type === 'VaultDeposit' +} + +export const isVaultRedeemTxInfo = (value: TransactionDetails['txInfo']): value is VaultRedeemTransactionInfo => { + return value.type === 'VaultRedeem' +} + +export const isAnyEarnTxInfo = ( + value: TransactionDetails['txInfo'], +): value is VaultDepositTransactionInfo | VaultRedeemTransactionInfo => { + return isVaultDepositTxInfo(value) || isVaultRedeemTxInfo(value) +} + +export const isBridgeOrderTxInfo = (value: Transaction['txInfo']): value is BridgeAndSwapTransactionInfo => { + return value.type === 'SwapAndBridge' +} + +export const isLifiSwapTxInfo = (value: Transaction['txInfo']): value is SwapTransactionInfo => { + return value.type === 'Swap' +} diff --git a/apps/mobile/src/utils/transactions.test.tsx b/apps/mobile/src/utils/transactions.test.tsx new file mode 100644 index 0000000000..f1e13072bf --- /dev/null +++ b/apps/mobile/src/utils/transactions.test.tsx @@ -0,0 +1,47 @@ +import { getTransactionType } from './transactions' +import { ETxType } from '../types/txType' +import { NativeStakingDepositTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +describe('getTransactionType', () => { + it('should return STAKE_DEPOSIT for NativeStakingDeposit transactions', () => { + const stakingDepositTxInfo: NativeStakingDepositTransactionInfo = { + type: 'NativeStakingDeposit', + humanDescription: 'Deposit ETH for staking', + status: 'ACTIVE', + estimatedEntryTime: 86400000, + estimatedExitTime: 30 * 86400000, + estimatedWithdrawalTime: 32 * 86400000, + fee: 0.05, + monthlyNrr: 4.2, + annualNrr: 50.4, + value: '32000000000000000000', + numValidators: 1, + expectedAnnualReward: '1612800000000000000', + expectedMonthlyReward: '134400000000000000', + expectedFiatAnnualReward: 4838.4, + expectedFiatMonthlyReward: 403.2, + tokenInfo: { + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + decimals: 18, + logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png', + name: 'Ethereum', + symbol: 'ETH', + trusted: true, + }, + validators: ['0x123...abc'], + } + + const result = getTransactionType({ txInfo: stakingDepositTxInfo }) + expect(result).toBe(ETxType.STAKE_DEPOSIT) + }) + + it('should return null for unknown transaction types', () => { + const unknownTxInfo = { + type: 'UnknownType', + } + + // @ts-expect-error - Testing with invalid transaction type for completeness + const result = getTransactionType({ txInfo: unknownTxInfo }) + expect(result).toBe(null) + }) +}) diff --git a/apps/mobile/src/utils/transactions.tsx b/apps/mobile/src/utils/transactions.tsx index 87c4e487d2..0bae59a57e 100644 --- a/apps/mobile/src/utils/transactions.tsx +++ b/apps/mobile/src/utils/transactions.tsx @@ -1,5 +1,83 @@ import { GroupedTxs } from '@/src/features/TxHistory/utils' +import { + isTransferTxInfo, + isAddSignerTxInfo, + isMultiSendTxInfo, + isCustomTxInfo, + isERC721Transfer, + isRemoveSignerTxInfo, + isOrderTxInfo, + isVaultDepositTxInfo, + isVaultRedeemTxInfo, + isStakingTxDepositInfo, + isStakingTxExitInfo, + isStakingTxWithdrawInfo, + isCancellationTxInfo, + isBridgeOrderTxInfo, + isLifiSwapTxInfo, +} from '@/src/utils/transaction-guards' import { Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { ETxType } from '../types/txType' + +export const getTransactionType = ({ txInfo }: { txInfo: Transaction['txInfo'] }) => { + if (isTransferTxInfo(txInfo)) { + return ETxType.TOKEN_TRANSFER + } + + if (isTransferTxInfo(txInfo) && isERC721Transfer(txInfo)) { + return ETxType.NFT_TRANSFER + } + + if (isAddSignerTxInfo(txInfo)) { + return ETxType.ADD_SIGNER + } + + if (isRemoveSignerTxInfo(txInfo)) { + return ETxType.REMOVE_SIGNER + } + + if (isCancellationTxInfo(txInfo)) { + return ETxType.CANCEL_TX + } + + if (isMultiSendTxInfo(txInfo) || isCustomTxInfo(txInfo)) { + return ETxType.CONTRACT_INTERACTION + } + + if (isOrderTxInfo(txInfo)) { + return ETxType.SWAP_ORDER + } + + if (isBridgeOrderTxInfo(txInfo)) { + return ETxType.BRIDGE_ORDER + } + + if (isLifiSwapTxInfo(txInfo)) { + return ETxType.LIFI_SWAP + } + + if (isStakingTxDepositInfo(txInfo)) { + return ETxType.STAKE_DEPOSIT + } + + if (isStakingTxExitInfo(txInfo)) { + return ETxType.STAKE_WITHDRAW_REQUEST + } + + if (isStakingTxWithdrawInfo(txInfo)) { + return ETxType.STAKE_EXIT + } + + if (isVaultDepositTxInfo(txInfo)) { + return ETxType.VAULT_DEPOSIT + } + + if (isVaultRedeemTxInfo(txInfo)) { + return ETxType.VAULT_REDEEM + } + + return null +} export const groupBulkTxs = <T extends { type: string; transaction?: Transaction }>( list: GroupedTxs<T>, diff --git a/apps/mobile/src/utils/url.test.ts b/apps/mobile/src/utils/url.test.ts new file mode 100644 index 0000000000..50d33fd678 --- /dev/null +++ b/apps/mobile/src/utils/url.test.ts @@ -0,0 +1,64 @@ +import { isIpOrLocalhostUrl, isHttpsUrl } from './url' + +describe('url utilities', () => { + describe('isIpOrLocalhostUrl', () => { + it('should return true for localhost', () => { + expect(isIpOrLocalhostUrl('http://localhost:3000')).toBe(true) + expect(isIpOrLocalhostUrl('https://localhost')).toBe(true) + }) + + it('should return true for 127.0.0.1', () => { + expect(isIpOrLocalhostUrl('http://127.0.0.1:8080')).toBe(true) + expect(isIpOrLocalhostUrl('https://127.0.0.1')).toBe(true) + }) + + it('should return true for private IP ranges', () => { + expect(isIpOrLocalhostUrl('http://192.168.1.1')).toBe(true) + expect(isIpOrLocalhostUrl('http://10.0.0.1')).toBe(true) + expect(isIpOrLocalhostUrl('http://172.16.0.1')).toBe(true) + }) + + it('should return false for public domains', () => { + expect(isIpOrLocalhostUrl('https://safe.global')).toBe(false) + expect(isIpOrLocalhostUrl('https://app.safe.global')).toBe(false) + expect(isIpOrLocalhostUrl('https://google.com')).toBe(false) + }) + + it('should return false for invalid URLs', () => { + expect(isIpOrLocalhostUrl('not-a-url')).toBe(false) + expect(isIpOrLocalhostUrl('')).toBe(false) + }) + + it('should return false for public IP addresses', () => { + expect(isIpOrLocalhostUrl('http://8.8.8.8')).toBe(false) + expect(isIpOrLocalhostUrl('http://1.1.1.1')).toBe(false) + }) + }) + + describe('isHttpsUrl', () => { + it('should return true for HTTPS URLs', () => { + expect(isHttpsUrl('https://safe.global')).toBe(true) + expect(isHttpsUrl('https://app.safe.global')).toBe(true) + expect(isHttpsUrl('https://localhost:3000')).toBe(true) + expect(isHttpsUrl('https://192.168.1.1')).toBe(true) + }) + + it('should return false for HTTP URLs', () => { + expect(isHttpsUrl('http://safe.global')).toBe(false) + expect(isHttpsUrl('http://localhost:3000')).toBe(false) + expect(isHttpsUrl('http://192.168.1.1')).toBe(false) + }) + + it('should return false for other protocols', () => { + expect(isHttpsUrl('ftp://example.com')).toBe(false) + expect(isHttpsUrl('ws://example.com')).toBe(false) + expect(isHttpsUrl('file:///path/to/file')).toBe(false) + }) + + it('should return false for invalid URLs', () => { + expect(isHttpsUrl('not-a-url')).toBe(false) + expect(isHttpsUrl('')).toBe(false) + expect(isHttpsUrl('just-text')).toBe(false) + }) + }) +}) diff --git a/apps/mobile/src/utils/url.ts b/apps/mobile/src/utils/url.ts new file mode 100644 index 0000000000..80ceb07e68 --- /dev/null +++ b/apps/mobile/src/utils/url.ts @@ -0,0 +1,40 @@ +/** + * Determines if a URL string is for an IP address or localhost + * @param urlString - URL as string + * @returns true if the URL is for an IP address or localhost + */ +export function isIpOrLocalhostUrl(urlString: string): boolean { + try { + const urlObj = new URL(urlString) + const hostname = urlObj.hostname + + // Check if this is an IP address or localhost + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + /^192\.168\.\d+\.\d+$/.test(hostname) || + /^10\.\d+\.\d+\.\d+$/.test(hostname) || + /^172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+$/.test(hostname) + ) + } catch (error) { + // If we can't parse the URL, better to be safe and assume it's not an IP/localhost + console.error('Error parsing URL:', error) + return false + } +} + +/** + * Determines if a URL string uses the HTTPS protocol + * @param urlString - URL as string + * @returns true if the URL uses HTTPS protocol, false otherwise + */ +export function isHttpsUrl(urlString: string): boolean { + try { + const urlObj = new URL(urlString) + return urlObj.protocol === 'https:' + } catch (error) { + // If we can't parse the URL, assume it's not HTTPS for safety + console.error('Error parsing URL:', error) + return false + } +} diff --git a/apps/mobile/src/utils/uuid.test.ts b/apps/mobile/src/utils/uuid.test.ts new file mode 100644 index 0000000000..13400a4b04 --- /dev/null +++ b/apps/mobile/src/utils/uuid.test.ts @@ -0,0 +1,60 @@ +import { convertToUuid, isValidUuid } from './uuid' + +// Mock react-native-quick-crypto +jest.mock('react-native-quick-crypto', () => ({ + createHash: jest.fn().mockReturnValue({ + update: jest.fn().mockReturnThis(), + digest: jest.fn().mockReturnValue('abcdef1234567890abcdef1234567890'), + }), +})) + +describe('UUID Utilities', () => { + describe('convertToUuid', () => { + it('should return the same string if it already contains hyphens', () => { + const uuid = '123e4567-e89b-12d3-a456-426614174000' + expect(convertToUuid(uuid)).toBe(uuid) + }) + + it('should convert a device ID to a valid UUID v4 format', () => { + const deviceId = '1234567890abcdef' + const uuid = convertToUuid(deviceId) + + // Check the format using regex + expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + + // Verify parts of the UUID + const parts = uuid.split('-') + expect(parts.length).toBe(5) + expect(parts[0].length).toBe(8) + expect(parts[1].length).toBe(4) + expect(parts[2].length).toBe(4) + expect(parts[2][0]).toBe('4') // Version 4 + expect(['8', '9', 'a', 'b'].includes(parts[3][0].toLowerCase())).toBeTruthy() // Variant + expect(parts[3].length).toBe(4) + expect(parts[4].length).toBe(12) + }) + + it('should generate the same UUID for the same device ID', () => { + const deviceId = '1234567890abcdef' + const uuid1 = convertToUuid(deviceId) + const uuid2 = convertToUuid(deviceId) + + expect(uuid1).toBe(uuid2) + }) + }) + + describe('isValidUuid', () => { + it('should return true for valid UUIDs', () => { + expect(isValidUuid('123e4567-e89b-42d3-a456-426614174000')).toBe(true) + expect(isValidUuid('a8098c1a-f86e-4538-8B2F-ABB9770C8BDE')).toBe(true) // Case insensitive + }) + + it('should return false for invalid UUIDs', () => { + expect(isValidUuid('not-a-uuid')).toBe(false) + expect(isValidUuid('123e4567-e89b-12d3-a456-426614174000')).toBe(false) // Wrong version (not 4) + expect(isValidUuid('123e4567-e89b-42d3-e456-426614174000')).toBe(false) // Wrong variant (not 8-b) + expect(isValidUuid('123e4567-e89b42d3-a456-426614174000')).toBe(false) // Wrong format + expect(isValidUuid('')).toBe(false) + }) + }) +}) diff --git a/apps/mobile/src/utils/uuid.ts b/apps/mobile/src/utils/uuid.ts new file mode 100644 index 0000000000..cee3bcbb4b --- /dev/null +++ b/apps/mobile/src/utils/uuid.ts @@ -0,0 +1,44 @@ +import crypto from 'react-native-quick-crypto' + +/** + * Converts a device ID to a proper RFC4122-compliant UUID v4 + * @param deviceId The device ID to convert + * @returns A valid UUID v4 string in format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + */ +export const convertToUuid = (deviceId: string): string => { + // If already in UUID format, return as is + if (deviceId.includes('-')) { + return deviceId + } + + // Generate deterministic "random" bytes based on the device ID + const randomBytes = crypto.createHash('md5').update(deviceId).digest('hex') + + // Format as UUID v4 + return ( + randomBytes.substring(0, 8) + + '-' + + randomBytes.substring(8, 12) + + '-' + + // Set version bits (bits 12-15 of time_hi_and_version) to 0100 (version 4) + '4' + + randomBytes.substring(13, 16) + + '-' + + // Set variant bits (bits 6-7 of clock_seq_hi_and_reserved) to 10 + // (8, 9, a, or b) followed by the rest + ((parseInt(randomBytes.charAt(16), 16) & 0x3) | 0x8).toString(16) + + randomBytes.substring(17, 20) + + '-' + + randomBytes.substring(20, 32) + ) +} + +/** + * Validates if a string is a valid UUID + * @param uuid String to validate + * @returns True if the string is a valid UUID + */ +export const isValidUuid = (uuid: string): boolean => { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + return uuidRegex.test(uuid) +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index aac0107fa2..a2e2cde7d5 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -5,7 +5,9 @@ "paths": { "@/*": ["./*"], "@safe-global/store/*": ["../../packages/store/src/*"], - "@safe-global/test/*": ["../../config/test/*"] + "@safe-global/utils/*": ["../../packages/utils/src/*"], + "@safe-global/test/*": ["../../config/test/*"], + "@cowprotocol/app-data": ["../../node_modules/@cowprotocol/app-data/dist/index.d.ts"] }, "types": ["jest", "node"] }, diff --git a/apps/web/.env.example b/apps/web/.env.example index 69e9230b30..4a4f66bdce 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,46 +1,3 @@ -## Infura token -NEXT_PUBLIC_INFURA_TOKEN= -NEXT_PUBLIC_SAFE_APPS_INFURA_TOKEN= - -# WalletConnect -NEXT_PUBLIC_WC_PROJECT_ID= - -## CGW -NEXT_PUBLIC_GATEWAY_URL_PRODUCTION= -NEXT_PUBLIC_GATEWAY_URL_STAGING= - -# Blockaid -NEXT_PUBLIC_BLOCKAID_CLIENT_ID= - -# Transaction simulation -NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL= -NEXT_PUBLIC_TENDERLY_PROJECT_NAME= -NEXT_PUBLIC_TENDERLY_ORG_NAME= - -# Production build -NEXT_PUBLIC_IS_PRODUCTION= - -# Latest supported safe version, used for upgrade prompts -NEXT_PUBLIC_SAFE_VERSION= - -# Sentry -NEXT_PUBLIC_SENTRY_DSN= - -# Beamer -NEXT_PUBLIC_BEAMER_ID= - -# Safe Gelato relay service -NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_PRODUCTION= -NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_STAGING= - -# Firebase Cloud Messaging -NEXT_PUBLIC_FIREBASE_OPTIONS_PRODUCTION= -NEXT_PUBLIC_FIREBASE_VAPID_KEY_PRODUCTION= -NEXT_PUBLIC_FIREBASE_OPTIONS_STAGING= -NEXT_PUBLIC_FIREBASE_VAPID_KEY_STAGING= - -# Cypress wallet private keys -CYPRESS_WALLET_CREDENTIALS= - -# [optional] Beamer keys for e2e tests -BEAMER_DATA_E2E= +NEXT_PUBLIC_INFURA_TOKEN=1234567890 +NEXT_PUBLIC_WC_PROJECT_ID=0987654321 +NEXT_PUBLIC_IS_OFFICIAL_HOST=false \ No newline at end of file diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 93dcbb2dab..e546c7c7ef 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -1,6 +1,3 @@ -# Types -src/types/contracts - # Auto-generated service workers public/*.js public/*.map diff --git a/apps/web/.storybook/main.ts b/apps/web/.storybook/main.ts index 2c39553785..053c2f568d 100644 --- a/apps/web/.storybook/main.ts +++ b/apps/web/.storybook/main.ts @@ -1,4 +1,5 @@ import type { StorybookConfig } from '@storybook/nextjs' +import path from 'path' const config: StorybookConfig = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], @@ -11,10 +12,14 @@ const config: StorybookConfig = { '@storybook/addon-themes', '@storybook/addon-designs', ], - framework: { - name: '@storybook/nextjs', - options: {}, - }, + /** + * In our monorepo setup, if we just specify the name, + * we end up with the wrong path to webpack5 preset. We need to + * resolve the path: + * + * https://github.com/storybookjs/storybook/issues/21216#issuecomment-2187481646 + */ + framework: path.resolve(require.resolve('@storybook/nextjs/preset'), '..'), webpackFinal: async (config) => { config.module = config.module || {} config.module.rules = config.module.rules || [] diff --git a/apps/web/README.md b/apps/web/README.md index 851ee7f9a3..969b0a815a 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -43,33 +43,34 @@ Create a `.env` file with environment variables. You can use the `.env.example` Here's the list of all the environment variables: -| Env variable | Description | -| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `NEXT_PUBLIC_BRAND_NAME` | The name of the app, defaults to "Wallet fork" | -| `NEXT_PUBLIC_BRAND_LOGO` | The URL of the app logo displayed in the header | -| `NEXT_PUBLIC_INFURA_TOKEN` | [Infura](https://docs.infura.io/infura/networks/ethereum/how-to/secure-a-project/project-id) RPC API token | -| `NEXT_PUBLIC_SAFE_APPS_INFURA_TOKEN` | Infura token for Safe Apps, falls back to `NEXT_PUBLIC_INFURA_TOKEN` | -| `NEXT_PUBLIC_IS_PRODUCTION` | Set to `true` to build a minified production app | -| `NEXT_PUBLIC_GATEWAY_URL_PRODUCTION` | The base URL for the [Safe Client Gateway](https://github.com/safe-global/safe-client-gateway) | -| `NEXT_PUBLIC_GATEWAY_URL_STAGING` | The base CGW URL on staging | -| `NEXT_PUBLIC_SAFE_VERSION` | The latest version of the Safe contract, defaults to 1.4.1 | -| `NEXT_PUBLIC_WC_PROJECT_ID` | [WalletConnect v2](https://docs.walletconnect.com/2.0/cloud/relay) project ID | -| `NEXT_PUBLIC_TENDERLY_ORG_NAME` | [Tenderly](https://tenderly.co) org name for Transaction Simulation | -| `NEXT_PUBLIC_TENDERLY_PROJECT_NAME` | Tenderly project name | -| `NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL` | Tenderly simulation URL | -| `NEXT_PUBLIC_BEAMER_ID` | [Beamer](https://www.getbeamer.com) is a news feed for in-app announcements | -| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | [GTM](https://tagmanager.google.com) project id | -| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH` | Dev GTM key | -| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LATEST_AUTH` | Preview GTM key | -| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LIVE_AUTH` | Production GTM key | -| `NEXT_PUBLIC_SENTRY_DSN` | [Sentry](https://sentry.io) id for tracking runtime errors | -| `NEXT_PUBLIC_IS_OFFICIAL_HOST` | 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` | Redefine API base URL | -| `NEXT_PUBLIC_FIREBASE_OPTIONS_PRODUCTION` | Firebase Cloud Messaging (FCM) `initializeApp` options on production | -| `NEXT_PUBLIC_FIREBASE_VAPID_KEY_PRODUCTION` | FCM vapid key on production | -| `NEXT_PUBLIC_FIREBASE_OPTIONS_STAGING` | FCM `initializeApp` options on staging | -| `NEXT_PUBLIC_FIREBASE_VAPID_KEY_STAGING` | FCM vapid key on staging | -| `NEXT_PUBLIC_SPINDL_SDK_KEY` | [Spindl](http://spindl.xyz) SDK key | +| Env variable | Description | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_BRAND_NAME` | The name of the app, defaults to "Wallet fork" | +| `NEXT_PUBLIC_BRAND_LOGO` | The URL of the app logo displayed in the header | +| `NEXT_PUBLIC_INFURA_TOKEN` ❕ | [Infura](https://docs.infura.io/infura/networks/ethereum/how-to/secure-a-project/project-id) RPC API token. **Required for wallet connection and transacting!** | +| `NEXT_PUBLIC_SAFE_APPS_INFURA_TOKEN` | Infura token for Safe Apps, falls back to `NEXT_PUBLIC_INFURA_TOKEN` | +| `NEXT_PUBLIC_IS_PRODUCTION` | Set to `true` to build a minified production app | +| `NEXT_PUBLIC_DEFAULT_TESTNET_CHAIN_ID` | The default chain ID used when `NEXT_PUBLIC_IS_PRODUCTION` is set to `false`. Defaults to 11155111 (sepolia) | +| `NEXT_PUBLIC_DEFAULT_MAINNET_CHAIN_ID` | The default chain ID used when `NEXT_PUBLIC_IS_PRODUCTION` is set to `true`. Defaults to 1 (mainnet). Must be set to another value if mainnet isn't configured in the chain configs from CGW. | +| `NEXT_PUBLIC_GATEWAY_URL_PRODUCTION` | The base URL for the [Safe Client Gateway](https://github.com/safe-global/safe-client-gateway) | +| `NEXT_PUBLIC_GATEWAY_URL_STAGING` | The base CGW URL on staging | +| `NEXT_PUBLIC_SAFE_VERSION` | The latest version of the Safe contract, defaults to 1.4.1 | +| `NEXT_PUBLIC_WC_PROJECT_ID` | [WalletConnect v2](https://docs.walletconnect.com/2.0/cloud/relay) project ID | +| `NEXT_PUBLIC_TENDERLY_ORG_NAME` | [Tenderly](https://tenderly.co) org name for Transaction Simulation | +| `NEXT_PUBLIC_TENDERLY_PROJECT_NAME` | Tenderly project name | +| `NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL` | Tenderly simulation URL | +| `NEXT_PUBLIC_BEAMER_ID` | [Beamer](https://www.getbeamer.com) is a news feed for in-app announcements | +| `NEXT_PUBLIC_PROD_GA_TRACKING_ID` | Prod GA property id | +| `NEXT_PUBLIC_TEST_GA_TRACKING_ID` | Test GA property id | +| `NEXT_PUBLIC_SAFE_APPS_GA_TRACKING_ID` | Safe Apps GA property id | +| `NEXT_PUBLIC_SENTRY_DSN` | [Sentry](https://sentry.io) id for tracking runtime errors | +| `NEXT_PUBLIC_IS_OFFICIAL_HOST` | 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_BLOCKAID_CLIENT_ID` | Blockaid client id | +| `NEXT_PUBLIC_FIREBASE_OPTIONS_PRODUCTION` | Firebase Cloud Messaging (FCM) `initializeApp` options on production | +| `NEXT_PUBLIC_FIREBASE_VAPID_KEY_PRODUCTION` | FCM vapid key on production | +| `NEXT_PUBLIC_FIREBASE_OPTIONS_STAGING` | FCM `initializeApp` options on staging | +| `NEXT_PUBLIC_FIREBASE_VAPID_KEY_STAGING` | FCM vapid key on staging | +| `NEXT_PUBLIC_SPINDL_SDK_KEY` | [Spindl](http://spindl.xyz) SDK key | If you don't provide some of the variables, the corresponding features will be disabled in the UI. diff --git a/apps/web/cypress.config.js b/apps/web/cypress.config.js index 429e69f07e..5d71882ae7 100644 --- a/apps/web/cypress.config.js +++ b/apps/web/cypress.config.js @@ -1,12 +1,8 @@ import { defineConfig } from 'cypress' import 'dotenv/config' import * as fs from 'fs' -import path, { dirname } from 'path' -import { fileURLToPath } from 'url' -import matter from 'gray-matter' import { configureVisualRegression } from 'cypress-visual-regression' - -const __dirname = dirname(fileURLToPath(import.meta.url)) +import { version } from './src/markdown/terms/version.js' export default defineConfig({ projectId: 'exhdra', @@ -22,19 +18,8 @@ export default defineConfig({ e2e: { screenshotsFolder: './cypress/snapshots/actual', setupNodeEvents(on, config) { - // Read and parse the terms Markdown file - try { - const filePath = path.resolve(__dirname, './src/markdown/terms/terms.md') - - const content = fs.readFileSync(filePath, 'utf8') - const parsed = matter(content) - const frontmatter = parsed.data - - // Set Cookie term version on the cypress env - this way we can access it in the tests - config.env.CURRENT_COOKIE_TERMS_VERSION = frontmatter.version - } catch (error) { - console.error('Error reading or parsing terms.md file:', error) - } + // Set Cookie term version on the cypress env - this way we can access it in the tests + config.env.CURRENT_COOKIE_TERMS_VERSION = version configureVisualRegression(on), on('task', { diff --git a/apps/web/cypress/e2e/happypath/recovery_hp_1.cy.js b/apps/web/cypress/e2e/happypath/recovery_hp_1.cy.js index e542eead30..09698e6ecd 100644 --- a/apps/web/cypress/e2e/happypath/recovery_hp_1.cy.js +++ b/apps/web/cypress/e2e/happypath/recovery_hp_1.cy.js @@ -5,6 +5,7 @@ import * as recovery from '../pages/recovery.pages' import * as tx from '../pages/transactions.page' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' import * as wallet from '../../support/utils/wallet.js' +import * as createtx from '../pages/create_tx.pages.js' let recoverySafes = [] const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) @@ -32,6 +33,7 @@ describe('Recovery happy path tests 1', () => { recovery.enterRecovererAddress(constants.SEPOLIA_OWNER_2) recovery.agreeToTerms() recovery.clickOnNextBtn() + main.verifyElementsCount(createtx.noteTextField, 1) tx.executeFlow_1() recovery.verifyRecovererAdded([constants.SEPOLIA_OWNER_2_SHORT]) recovery.clearRecoverers() diff --git a/apps/web/cypress/e2e/happypath_2/add_owner.cy.js b/apps/web/cypress/e2e/happypath_2/add_owner.cy.js index 2cb31793f5..c791886fac 100644 --- a/apps/web/cypress/e2e/happypath_2/add_owner.cy.js +++ b/apps/web/cypress/e2e/happypath_2/add_owner.cy.js @@ -47,10 +47,13 @@ describe('Happy path Add Owners tests', () => { cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_24) wallet.connectSigner(signer2) owner.waitForConnectionStatus() - owner.openAddOwnerWindow() + owner.openManageSignersWindow() + owner.clickOnAddSignerBtn() owner.typeOwnerAddress(constants.SEPOLIA_OWNER_2) createTx.changeNonce(1) owner.clickOnNextBtn() + createTx.clickOnContinueSignTransactionBtn() + createTx.clickOnAcknowledgement() createTx.clickOnSignTransactionBtn() createTx.clickViewTransaction() @@ -62,6 +65,8 @@ describe('Happy path Add Owners tests', () => { function step2() { createTx.clickOnConfirmTransactionBtn() createTx.clickOnNoLaterOption() + createTx.clickOnContinueSignTransactionBtn() + createTx.clickOnAcknowledgement() createTx.clickOnSignTransactionBtn() navigation.clickOnWalletExpandMoreIcon() @@ -85,6 +90,8 @@ describe('Happy path Add Owners tests', () => { } else { createTx.clickOnConfirmTransactionBtn() createTx.clickOnNoLaterOption() + createTx.clickOnContinueSignTransactionBtn() + createTx.clickOnAcknowledgement() createTx.clickOnSignTransactionBtn() navigation.clickOnWalletExpandMoreIcon() diff --git a/apps/web/cypress/e2e/happypath_2/create_safe_cf.cy.js b/apps/web/cypress/e2e/happypath_2/create_safe_cf.cy.js index e6f091567a..6247ff7670 100644 --- a/apps/web/cypress/e2e/happypath_2/create_safe_cf.cy.js +++ b/apps/web/cypress/e2e/happypath_2/create_safe_cf.cy.js @@ -25,7 +25,7 @@ describe('CF Safe creation happy path tests', () => { createwallet.clickOnCreateNewSafeBtn() createwallet.clickOnNextBtn() createwallet.clickOnNextBtn() - createwallet.selectPayLaterOption() + createwallet.selectPayNowOption() createwallet.clickOnReviewStepNextBtn() cy.wait(1000) main.getAddedSafeAddressFromLocalStorage(constants.networkKeys.sepolia, 0).then((address) => { diff --git a/apps/web/cypress/e2e/happypath_2/mass_payouts.cy.js b/apps/web/cypress/e2e/happypath_2/mass_payouts.cy.js new file mode 100644 index 0000000000..8779f2dd5e --- /dev/null +++ b/apps/web/cypress/e2e/happypath_2/mass_payouts.cy.js @@ -0,0 +1,43 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as createtx from '../pages/create_tx.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import { getMockAddress } from '../../support/utils/ethers.js' + +let staticSafes = [] + +const sendValue2 = 0.0001 + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer2 = walletCredentials.OWNER_1_PRIVATE_KEY + +describe('Mass payouts happy path tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify that proposer can create a mass payout tx', () => { + const address1 = getMockAddress() + const address2 = getMockAddress() + + cy.visit(constants.transactionQueueUrl + staticSafes.SEP_STATIC_SAFE_42) + wallet.connectSigner(signer2) + cy.wait(5000) + createtx.deleteAllTx() + + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_42) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + createtx.clickOnAddRecipientBtn() + createtx.typeRecipientAddress_(0, address1) + createtx.typeRecipientAddress_(1, address2) + createtx.setSendValue_(0, sendValue2) + createtx.setSendValue_(1, sendValue2) + createtx.clickOnNextBtn() + createtx.clickOnContinueSignTransactionBtn() + createtx.clickOnProposeTransactionBtn() + createtx.clickViewTransaction() + main.verifyValuesExist(createtx.transactionItem, [createtx.tx_status.proposal]) + }) +}) diff --git a/apps/web/cypress/e2e/happypath_2/multichain_create_safe.cy.js b/apps/web/cypress/e2e/happypath_2/multichain_create_safe.cy.js index 3ed554398e..7a1fc6e296 100644 --- a/apps/web/cypress/e2e/happypath_2/multichain_create_safe.cy.js +++ b/apps/web/cypress/e2e/happypath_2/multichain_create_safe.cy.js @@ -40,7 +40,7 @@ describe('Happy path Multichain safe creation tests', { defaultCommandTimeout: 6 const safe = `sep:${main.getSafeAddressFromUrl(currentUrl)}` cy.visit(constants.homeUrl + safe) createwallet.clickOnActivateAccountBtn(0) - createwallet.selectRelayOption() + createwallet.selectPayNowOption() createwallet.clickOnFinalActivateAccountBtn() createwallet.clickOnLetsGoBtn() cy.visit(constants.setupUrl + safe) diff --git a/apps/web/cypress/e2e/happypath_2/nested_safes.cy.js b/apps/web/cypress/e2e/happypath_2/nested_safes.cy.js new file mode 100644 index 0000000000..d3cf0d6a54 --- /dev/null +++ b/apps/web/cypress/e2e/happypath_2/nested_safes.cy.js @@ -0,0 +1,53 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as sideBar from '../pages/sidebar.pages.js' +import * as nsafes from '../pages/nestedsafes.pages.js' +import * as txs from '../pages/transactions.page.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as createTx from '../pages/create_tx.pages.js' +import * as owner from '../pages/owners.pages' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +const nestedSafe1Short = '0x22e5...Cf9d' + +describe('Nested safes happy path tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify that batch tx appears in the Queue with create proxy action', () => { + const safe = 'Created safe' + + cy.visit(constants.transactionQueueUrl + staticSafes.SEP_STATIC_SAFE_39) + wallet.connectSigner(signer) + cy.wait(5000) + createTx.deleteAllTx() + + sideBar.clickOnOpenNestedSafeListBtn() + nsafes.clickOnAddNestedSafeBtn() + createTx.hasNonce() + createTx.changeNonce(3) + nsafes.nameInputHasPlaceholder() + nsafes.typeName(main.generateRandomString(51)) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.exceedChars) + nsafes.typeName(safe) + nsafes.clickOnAddNextBtn() + txs.selectExecuteLater() + createTx.clickOnContinueSignTransactionBtn() + createTx.clickOnAcknowledgement() + createTx.clickOnSignTransactionBtn() + createTx.clickViewTransaction() + main.verifyValuesExist(createTx.transactionItem, [ + createTx.tx_status.execute, + nsafes.nonfundAssetsActions[0], + nsafes.nonfundAssetsActions[1], + ]) + sideBar.clickOnOpenNestedSafeListBtn() + sideBar.checkSafesCountInPopverList(1) + sideBar.clickOnSafeInPopover(nestedSafe1Short) + }) +}) diff --git a/apps/web/cypress/e2e/happypath_2/proposers.cy.js b/apps/web/cypress/e2e/happypath_2/proposers.cy.js index 411548b5e0..5fb69c7250 100644 --- a/apps/web/cypress/e2e/happypath_2/proposers.cy.js +++ b/apps/web/cypress/e2e/happypath_2/proposers.cy.js @@ -3,6 +3,7 @@ import * as owner from '../pages/owners.pages.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' import * as wallet from '../../support/utils/wallet.js' import * as proposer from '../pages/proposers.pages.js' +import * as navigation from '../pages/navigation.page.js' let staticSafes = [] const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) @@ -15,13 +16,12 @@ const proposerName2 = 'Proposer 2' const proposerName = 'Proposer 1' const changedProposerName = 'Changed proposer name' -describe('Happy path Proposers tests', () => { +describe('Happy path Proposers tests', { defaultCommandTimeout: 30000 }, () => { before(async () => { staticSafes = await getSafes(CATEGORIES.static) }) - //TODO: Flaky due to UI retrieval issue - wip - it.skip('Verify that editing a proposer is only possible for the proposer created by the creator', () => { + it('Verify that editing a proposer is only possible for the proposer created by the creator', () => { cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_31) wallet.connectSigner(signer3) cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) @@ -30,11 +30,13 @@ describe('Happy path Proposers tests', () => { proposer.clickOnEditProposerBtn(proposerAddress2) proposer.enterProposerName(changedProposerName) proposer.clickOnSubmitProposerBtn() + cy.reload() proposer.checkProposerData([changedProposerName]) proposer.clickOnEditProposerBtn(proposerAddress2) proposer.enterProposerName(proposerName2) proposer.clickOnSubmitProposerBtn() + cy.reload() proposer.checkProposerData([proposerName2]) }) @@ -42,10 +44,13 @@ describe('Happy path Proposers tests', () => { cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_32) wallet.connectSigner(signer) cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + navigation.verifyTxBtnStatus(constants.enabledStates.enabled) proposer.deleteAllProposers() proposer.clickOnAddProposerBtn() proposer.enterProposerData(addedProposer, proposerName) proposer.clickOnSubmitProposerBtn() proposer.verifyProposerSuccessMsgDisplayed() + cy.reload() + proposer.checkProposerData([proposerName]) }) }) diff --git a/apps/web/cypress/e2e/happypath_2/swaps.cy.js b/apps/web/cypress/e2e/happypath_2/swaps.cy.js index a14575d7f8..b9961b1f51 100644 --- a/apps/web/cypress/e2e/happypath_2/swaps.cy.js +++ b/apps/web/cypress/e2e/happypath_2/swaps.cy.js @@ -1,26 +1,22 @@ import * as constants from '../../support/constants.js' import * as main from '../pages/main.page.js' import * as swaps from '../pages/swaps.pages.js' -import * as tx from '../pages/transactions.page.js' import * as create_tx from '../pages/create_tx.pages.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' -import * as owner from '../pages/owners.pages' import * as wallet from '../../support/utils/wallet.js' import * as swaps_data from '../../fixtures/swaps_data.json' import * as navigation from '../pages/navigation.page' +import { dataRow } from '../pages/tables.page' import { getEvents, events, checkDataLayerEvents } from '../../support/utils/gtag.js' const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) const signer = walletCredentials.OWNER_4_PRIVATE_KEY -const signer2 = walletCredentials.OWNER_3_WALLET_ADDRESS const signer3 = walletCredentials.OWNER_1_PRIVATE_KEY let staticSafes = [] let iframeSelector -const swapOrder = swaps_data.type.orderDetails - describe('Happy path Swaps tests', () => { before(async () => { staticSafes = await getSafes(CATEGORIES.static) @@ -35,7 +31,7 @@ describe('Happy path Swaps tests', () => { it( 'Verify an order can be created, signed by second signer and deleted. GA tx_confirm, tx_created', - { defaultCommandTimeout: 30000 }, + { defaultCommandTimeout: 60000 }, () => { const tx_created = [ { @@ -55,10 +51,13 @@ describe('Happy path Swaps tests', () => { ] // Clean txs in the queue cy.visit(constants.transactionQueueUrl + staticSafes.SEP_STATIC_SAFE_30) + navigation.verifyTxBtnStatus(constants.enabledStates.enabled) cy.wait(5000) create_tx.deleteAllTx() cy.visit(constants.swapUrl + staticSafes.SEP_STATIC_SAFE_30) + navigation.verifyTxBtnStatus(constants.enabledStates.enabled) + swaps.getMockQuoteResponse(swaps.quoteResponse.quote1) swaps.acceptLegalDisclaimer() cy.wait(4000) main.getIframeBody(iframeSelector).within(() => { @@ -72,23 +71,32 @@ describe('Happy path Swaps tests', () => { swaps.clickOnExceeFeeChkbox() swaps.clickOnSwapBtn() swaps.clickOnSwapBtn() + swaps.confirmPriceImpact() }) create_tx.changeNonce(0) + create_tx.clickOnContinueSignTransactionBtn() + create_tx.clickOnAcknowledgement() create_tx.clickOnSignTransactionBtn() create_tx.clickViewTransaction() + main.verifyValuesExist(dataRow, [create_tx.tx_status.execution_needed]) + cy.wait(1000) // Give it some time to logout properly on UI navigation.clickOnWalletExpandMoreIcon() navigation.clickOnDisconnectBtn() wallet.connectSigner(signer3) - + navigation.verifyTxBtnStatus(constants.enabledStates.enabled) cy.wait(5000) create_tx.verifyConfirmTransactionBtnIsVisible() create_tx.clickOnConfirmTransactionBtn() create_tx.clickOnNoLaterOption() + create_tx.clickOnContinueSignTransactionBtn() + create_tx.clickOnAcknowledgement() create_tx.clickOnSignTransactionBtn() navigation.clickOnWalletExpandMoreIcon() navigation.clickOnDisconnectBtn() wallet.connectSigner(signer) + navigation.verifyTxBtnStatus(constants.enabledStates.enabled) + main.verifyValuesExist(dataRow, [create_tx.tx_status.execution_needed]) create_tx.deleteTx() getEvents() diff --git a/apps/web/cypress/e2e/happypath_2/tx-builder.cy.js b/apps/web/cypress/e2e/happypath_2/tx-builder.cy.js new file mode 100644 index 0000000000..3494e2bb46 --- /dev/null +++ b/apps/web/cypress/e2e/happypath_2/tx-builder.cy.js @@ -0,0 +1,91 @@ +import 'cypress-file-upload' +import * as constants from '../../support/constants.js' +import * as safeapps from '../pages/safeapps.pages.js' +import * as createtx from '../pages/create_tx.pages.js' +import * as navigation from '../pages/navigation.page.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import { getEvents, events, checkDataLayerEvents } from '../../support/utils/gtag.js' +import * as wallet from '../../support/utils/wallet.js' + +let safeAppSafes = [] +let iframeSelector + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY +const signer2 = walletCredentials.OWNER_1_PRIVATE_KEY + +describe('Transaction Builder happy path tests', { defaultCommandTimeout: 20000 }, () => { + before(async () => { + safeAppSafes = await getSafes(CATEGORIES.safeapps) + }) + + it( + 'Verify a simple batch can be created, signed by second signer and deleted. GA tx_confirm, tx_created', + { defaultCommandTimeout: 50000 }, + () => { + const tx_created = [ + { + eventLabel: events.txCreatedTxBuilder.eventLabel, + eventCategory: events.txCreatedTxBuilder.category, + eventType: events.txCreatedTxBuilder.eventType, + event: events.txCreatedTxBuilder.event, + safeAddress: safeAppSafes.SEP_SAFEAPP_SAFE_1.slice(6), + }, + ] + const tx_confirmed = [ + { + eventLabel: events.txConfirmedTxBuilder.eventLabel, + eventCategory: events.txConfirmedTxBuilder.category, + eventType: events.txConfirmedTxBuilder.eventType, + safeAddress: safeAppSafes.SEP_SAFEAPP_SAFE_1.slice(6), + }, + ] + + const appUrl = constants.TX_Builder_url + iframeSelector = `iframe[id="iframe-${encodeURIComponent(appUrl)}"]` + const visitUrl = `/apps/open?safe=${safeAppSafes.SEP_SAFEAPP_SAFE_1}&appUrl=${encodeURIComponent(appUrl)}` + + cy.visit(constants.transactionQueueUrl + safeAppSafes.SEP_SAFEAPP_SAFE_1) + wallet.connectSigner(signer) + cy.wait(5000) + createtx.deleteAllTx() + cy.visit(visitUrl) + navigation.verifyTxBtnStatus(constants.enabledStates.enabled) + cy.enter(iframeSelector).then((getBody) => { + getBody().findByLabelText(safeapps.enterAddressStr).type(constants.SAFE_APP_ADDRESS) + getBody().find(safeapps.contractMethodIndex).parent().click() + getBody().findByRole('option', { name: safeapps.testAddressValue2 }).click() + getBody().findByLabelText(safeapps.newAddressValueStr).type(safeAppSafes.SEP_SAFEAPP_SAFE_2) + getBody().findByText(safeapps.addTransactionStr).click() + getBody().findAllByText(constants.SEPOLIA_CONTRACT_SHORT).should('have.length', 1) + getBody().findByText(safeapps.testAddressValueStr).should('exist') + getBody().findByText(safeapps.createBatchStr).click() + getBody().findByText(safeapps.sendBatchStr).click() + }) + + createtx.clickOnContinueSignTransactionBtn() + createtx.clickOnAcknowledgement() + createtx.clickOnSignTransactionBtn() + createtx.clickViewTransaction() + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + + wallet.connectSigner(signer2) + navigation.verifyTxBtnStatus(constants.enabledStates.enabled) + createtx.clickOnConfirmTransactionBtn() + createtx.clickOnNoLaterOption() + createtx.clickOnContinueSignTransactionBtn() + createtx.clickOnAcknowledgement() + createtx.clickOnSignTransactionBtn() + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + wallet.connectSigner(signer) + navigation.verifyTxBtnStatus(constants.enabledStates.enabled) + createtx.deleteTx() + createtx.verifyNumberOfTransactions(0) + getEvents() + checkDataLayerEvents(tx_created) + checkDataLayerEvents(tx_confirmed) + }, + ) +}) diff --git a/apps/web/cypress/e2e/pages/assets.pages.js b/apps/web/cypress/e2e/pages/assets.pages.js index a19f57b9ba..c495bf2d4a 100644 --- a/apps/web/cypress/e2e/pages/assets.pages.js +++ b/apps/web/cypress/e2e/pages/assets.pages.js @@ -2,6 +2,8 @@ import * as main from './main.page' import * as addressbook from '../pages/address_book.page' import * as createTx from '../pages/create_tx.pages' import { tableRow } from '../pages/address_book.page' +import { assetsSwapBtn } from '../pages/swaps.pages' +import { nftsRow } from '../pages/nfts.pages' let etherscanLinkSepolia = 'a[aria-label="View on sepolia.etherscan.io"]' export const balanceSingleRow = '[aria-labelledby="tableTitle"] > tbody tr' @@ -15,6 +17,7 @@ const paginationPageList = 'ul[role="listbox"]' const currencyDropDown = 'div[id="currency"]' export const tokenListTable = 'table[aria-labelledby="tableTitle"]' const tokenListDropdown = 'div[id="tokenlist-select"]' +export const tablePaginationContainer = '[data-testid="table-pagination"]' const hiddenTokenSaveBtn = 'span[data-track="assets: Save hide dialog"]' const hiddenTokenCancelBtn = 'span[data-track="assets: Cancel hide dialog"]' @@ -25,6 +28,7 @@ const currencyItem = '[data-testid="currency-item"]' const tokenAmountFld = '[data-testid="token-amount-field"]' const tokenBalance = '[data-testid="token-balance"]' const tokenItem = '[data-testid="token-item"]' +const sendBtn = '[data-testid="send-button"]' const hideTokenDefaultString = 'Hide tokens' const assetNameSortBtnStr = 'Asset' @@ -110,6 +114,33 @@ export const currentcyGnosisFormat = '< 0.00001 GNO' export const currencyOx = /^0x$/ export const currentcyOxFormat = '1.003 ZRX' +export function checkNftAddressFormat() { + cy.get(nftsRow).each(($el) => { + cy.wrap($el) + .invoke('text') + .should('match', /0x[a-fA-F0-9]{4}\.\.\.[a-fA-F0-9]{4}/) + }) +} + +export function checkNftCopyIconAndLink() { + cy.get(nftsRow).each(($el) => { + cy.wrap($el).within(() => { + cy.get(createTx.copyIcon, { timeout: 5000 }).should('exist') + }) + cy.wrap($el).within(() => { + cy.get(createTx.explorerBtn, { timeout: 5000 }).should('exist') + }) + }) +} + +export function showSendBtn() { + return cy.get(sendBtn).invoke('css', 'opacity', '1').should('have.css', 'opacity', '1') +} + +export function showSwapBtn() { + return cy.get(assetsSwapBtn).invoke('css', 'opacity', '1').should('have.css', 'opacity', '1') +} + export function enterAmount(amount) { cy.get(tokenAmountFld).find('input').clear().type(amount) } @@ -118,6 +149,15 @@ export function checkSelectedToken(token) { cy.get(tokenBalance).contains(token) } +function clickOnTokenSelector(index) { + cy.get(tokenBalance).eq(index).click() +} + +export function selectToken(index, token) { + clickOnTokenSelector(index) + cy.get(tokenItem).contains(token).click() +} + function clickOnCurrencySelector() { cy.get(currencySelector).click() } @@ -166,14 +206,6 @@ export function clickOnExecuteBtn(index) { }) } -export function showSendBtn(index) { - cy.get('button') - .contains(sendBtnStr) - .then((elements) => { - cy.wrap(elements[index]).invoke('css', 'opacity', 100).trigger('mouseover', { force: true }) - }) -} - export function VerifySendButtonIsDisabled() { cy.get('button').contains(sendBtnStr).should('be.disabled') } @@ -234,6 +266,14 @@ export function checkTokenCounter(value) { }) } +export function checkNFTCounter(value) { + cy.get(hiddenTokenIcon) + .parent() + .within(() => { + cy.get('p').should('include.text', value) + }) +} + export function checkHiddenTokenBtnCounter(value) { cy.get(hiddeTokensBtn).within(() => { cy.get('p').should('include.text', value) @@ -247,10 +287,10 @@ export function verifyEachRowHasCheckbox(state) { cy.get('tbody').within(() => { cy.get('tr').each(($row) => { if (state) { - cy.wrap($row).find('td').eq(3).find(hiddenTokenCheckbox).should('exist').should(state) + cy.wrap($row).find('td').eq(4).find(hiddenTokenCheckbox).should('exist').should(state) return } - cy.wrap($row).find('td').eq(3).find(hiddenTokenCheckbox).should('exist') + cy.wrap($row).find('td').eq(4).find(hiddenTokenCheckbox).should('exist') }) }) }) diff --git a/apps/web/cypress/e2e/pages/batches.pages.js b/apps/web/cypress/e2e/pages/batches.pages.js index c8fe065e4c..110abb8069 100644 --- a/apps/web/cypress/e2e/pages/batches.pages.js +++ b/apps/web/cypress/e2e/pages/batches.pages.js @@ -1,4 +1,5 @@ import * as constants from '../../support/constants' +import { clickOnContinueSignTransactionBtn, selectComboButtonOption } from './create_tx.pages' const tokenSelectorText = 'G(ö|oe)rli Ether' const noLaterString = 'No, later' @@ -21,22 +22,23 @@ export const addInitialTransactionStr = 'Add an initial transaction to the batch export const transactionAddedToBatchStr = 'Transaction is added to batch' export const addNewStransactionStr = 'Add new transaction' -const recipientInput = 'input[name="recipient"]' +const recipientInput = 'input[name^="recipients."][name$=".recipient"]' +const tokenBalance = '[data-testid="token-balance"]' const tokenAddressInput = 'input[name="tokenAddress"]' const listBox = 'ul[role="listbox"]' -const amountInput = '[name="amount"]' +const amountInput = 'input[name^="recipients."][name$=".amount"]' const nonceInput = 'input[name="nonce"]' const executeOptionsContainer = 'div[role="radiogroup"]' const expandedItem = 'div[class*="MuiCollapse-entered"]' const collapsedItem = 'div[class*="MuiCollapse-hidden"]' -export function addToBatch(EOA, currentNonce, amount, verify = false) { +export function addToBatch(EOA, currentNonce, amount) { fillTransactionData(EOA, amount) setNonceAndProceed(currentNonce) - // Execute the transaction if verification is required - if (verify) { - executeTransaction() - } + clickOnContinueSignTransactionBtn() + + selectComboButtonOption('addToBatch') + addToBatchButton() cy.contains(transactionAddedToBatchStr).click().should('not.be.visible') } @@ -44,7 +46,7 @@ export function addToBatch(EOA, currentNonce, amount, verify = false) { function fillTransactionData(EOA, amount) { cy.get(recipientInput).type(EOA, { delay: 1 }) // Click on the Token selector - cy.get(tokenAddressInput).prev().click() + cy.get(tokenBalance).click() cy.get(listBox).contains(constants.tokenNames.sepoliaEther).click() cy.get(amountInput).type(amount) cy.contains(nextBtn).click() @@ -68,6 +70,10 @@ function addToBatchButton() { cy.get('button').contains(addToBatchBtn).click() } +export function checkAddToBatchBtnDisabled() { + cy.get('button').contains(addToBatchBtn).should('be.disabled') +} + export function openBatchtransactionsModal() { cy.get(batchTxTopBar).should('be.visible').click() cy.contains(batchedTransactionsStr).should('be.visible') diff --git a/apps/web/cypress/e2e/pages/bridge.pages.js b/apps/web/cypress/e2e/pages/bridge.pages.js new file mode 100644 index 0000000000..67af952a7c --- /dev/null +++ b/apps/web/cypress/e2e/pages/bridge.pages.js @@ -0,0 +1,8 @@ +export const exchangeStr = 'Bridge' + +const bridgleLink = 'a[href*="/bridge"]' + +export function clickOnBridgeOption() { + cy.get(bridgleLink).should('be.visible').click() + cy.wait(1000) +} diff --git a/apps/web/cypress/e2e/pages/create_tx.pages.js b/apps/web/cypress/e2e/pages/create_tx.pages.js index 43d3358006..db5a4a02db 100644 --- a/apps/web/cypress/e2e/pages/create_tx.pages.js +++ b/apps/web/cypress/e2e/pages/create_tx.pages.js @@ -2,23 +2,31 @@ import * as constants from '../../support/constants' import * as main from '../pages/main.page' import * as wallet from '../pages/create_wallet.pages' import * as modal from '../pages/modals.page' +import { dataRow } from '../pages/tables.page' export const delegateCallWarning = '[data-testid="delegate-call-warning"]' export const policyChangeWarning = '[data-testid="threshold-warning"]' const newTransactionBtnStr = 'New transaction' -const recepientInput = 'input[name="recipient"]' -const tokenAddressInput = 'input[name="tokenAddress"]' -const amountInput = 'input[name="amount"]' +const recepientInput = 'input[name="recipients.0.recipient"]' +const recepientInput_ = (index) => `input[name="recipients.${index}.recipient"]` +const tokenAddressInput = 'input[name="recipients.0.tokenAddress"]' +const amountInput = 'input[name="recipients.0.amount"]' +const amountInput_ = (index) => `input[name="recipients.${index}.amount"]` const nonceInput = 'input[name="nonce"]' +const walletNonceInput = '[name="userNonce"]' const gasLimitInput = '[name="gasLimit"]' +const maxPriorityFee = '[name="maxPriorityFeePerGas"]' +const maxFee = '[name="maxFeePerGas"]' const rotateLeftIcon = '[data-testid="RotateLeftIcon"]' export const transactionItem = '[data-testid="transaction-item"]' export const connectedWalletExecMethod = '[data-testid="connected-wallet-execution-method"]' export const relayExecMethod = '[data-testid="relay-execution-method"]' +export const connectedWalletMethod = '[data-testid="connected-wallet-execution-method"]' export const payNowExecMethod = '[data-testid="pay-now-execution-method"]' -const addToBatchBtn = '[data-track="batching: Add to batch"]' +export const addToBatchBtn = '[data-track="batching: Add to batch"]' const accordionDetails = '[data-testid="accordion-details"]' -const copyIcon = '[data-testid="copy-btn-icon"]' +export const copyIcon = '[data-testid="copy-btn-icon"]' +export const explorerBtn = '[data-testid="explorer-btn"]' const transactionSideList = '[data-testid="transaction-actions-list"]' const confirmationVisibilityBtn = '[data-testid="confirmation-visibility-btn"]' const expandAllBtn = '[data-testid="expande-all-btn"]' @@ -45,21 +53,55 @@ const filterClearBtn = '[data-testid="clear-btn"]' export const addressItem = '[data-testid="address-item"]' const radioSelector = 'div[role="radiogroup"]' const rejectTxBtn = '[data-testid="reject-btn"]' +const rejectChoiceBtn = '[data-track="reject-tx: Reject onchain button"]' +const replaceChoiceBtn = '[data-track="reject-tx: Replace tx button"]' +export const deleteChoiceBtn = '[data-track="reject-tx: Delete offchain button"]' const deleteTxModalBtn = '[data-testid="delete-tx-btn"]' const toggleUntrustedBtn = '[data-testid="toggle-untrusted"]' const simulateTxBtn = '[data-testid="simulate-btn"]' const simulateSuccess = '[data-testid="simulation-success-msg"]' -const signBtn = '[data-testid="sign-btn"]' +const signBtn = '[data-testid="combo-submit-sign"]' +const continueSignBtn = '[data-testid="continue-sign-btn"]' export const altImgDai = 'img[alt="DAI"]' export const altImgCow = 'img[alt="COW"]' +export const altImgWeth = 'img[alt="WETH"]' +export const altImgUsdc = 'img[alt="USDC"]' +export const altImgUsdt = 'img[alt="USDT"]' export const altImgSwaps = 'svg[alt="Swap order"]' - +export const altImgLimitOrder = 'svg[alt="Limit order"]' +export const altImgTwapOrder = 'svg[alt="Twap Order"]' +export const txShareBlock = '[data-testid="share-block"]' +export const txShareBlockDetails = '[data-testid="share-block-details"]' +const copyLinkBtn = '[data-testid="copy-link-btn"]' +export const noteTextField = '[data-testid="tx-note-textfield"]' +const noteAlert = "[data-testid='tx-note-alert']" +const recoredTxNote = '[data-testid="tx-note"]' +const txNoteTooltip = '[data-testid="tx-note-tooltip"]' +const noteCreator = '[data-testid="note-creator"]' +const tableViewBtn = '[data-testid="table-view-btn"]' +const gridViewBtn = '[data-testid="grid-view-btn"]' +const txHexData = '[data-testid="tx-hex-data"]' +const txStack = '[data-testid="tx-stack"]' +const txOperation = '[data-testid="tx-operation"]' +const nonceFld = '[data-testid="nonce-fld"]' +const txHexDataRow = '[data-testid="tx-hexData"]' +const addrecipientBtn = '[data-testid="add-recipient-btn"]' +const removeRecipientBtn = '[data-testid="remove-recipient-btn"]' +const maxRecipientsReachedMsg = '[data-testid="max-recipients-reached"]' +const recipientsCount = '[data-testid="recipients-count"]' +const maxBtn = '[data-testid="max-btn"]' +const tokenAmountSection = '[data-testid="token-amount-section"]' +const insufficientBalanceError = '[data-testid="insufficient-balance-error"]' +const proposeTransactionBtn = '[data-testid="sign-btn"]' + +const insufficientFundsErrorStr = 'Insufficient funds' const viewTransactionBtn = 'View transaction' const transactionDetailsTitle = 'Transaction details' const QueueLabel = 'needs to be executed first' const TransactionSummary = 'Send ' const transactionsPerHrStr = 'free transactions left today' - +const txHashesStr = 'Transaction hashes' +const txAcknowledgementStr = 'I understand what' const maxAmountBtnStr = 'Max' const nextBtnStr = 'Next' const nativeTokenTransferStr = 'ETH' @@ -86,17 +128,180 @@ const enabledBulkExecuteBtnTooltip = 'All highlighted transactions will be inclu const bulkExecuteBtnStr = 'Bulk execute' const batchModalTitle = 'Batch' +const gasLimit21000 = 'Gas limit must be at least 21000' export const swapOrder = 'Swap order settlement' export const bulkTxs = 'Bulk transactions' - +export const txStr = 'Transactions' +export const txDetailsStr = 'Transaction details' +export const settingsStr = 'Settings' +export const assetsStr = 'Assets' +export const topAssetsStr = 'Top assets' +export const getStartedStr = 'Get started' +export const txNoteWarningMessage = 'Notes are publicly visible.Do not share any private or sensitive details' +export const recordedTxNote = 'Tx note one' + +const comboButton = '[data-testid="combo-submit-dropdown"]' +const comboButtonPopover = '[data-testid="combo-submit-popover"]' +export const comboButtonOptions = { + sign: 'Sign', + execute: 'Execute', + addToBatch: 'Add to batch', +} + +const advancedParametersValues = { + walletNonce: '5500', + maxPriorityFee: '0.1234', + maxFee: '0.5678', + gasLimit: '300001', +} +const advancedParametersInputNames = { + walletNonce: 'Wallet nonce', + maxPriorityFee: 'Max priority fee (Gwei)', + maxFee: 'Max fee (Gwei)', + gasLimit: 'Gas limit', +} + +// Transaction details on Tx creation +export const txAccordionDetails = '[data-testid="decoded-tx-details"]' + +//Arrays for the Transaction Details on Tx creation for different type of txs +export const MultisendData = ['Call', 'multiSend', 'on', 'Safe: MultiSendCallOnly 1.4.1'] + +export const tx_status = { + execution_needed: 'Execution needed', + execute: 'Execute', + proposal: 'Proposal', +} export const filterTypes = { incoming: 'Incoming', outgoing: 'Outgoing', module: 'Module-based', } -function clickOnRejectBtn() { - cy.get(rejectTxBtn).click() +export const txActions = { + setFallbackHandler: 'setFallbackHandler', +} + +export const advancedDetailsViewOptions = { + table: 'table', + grid: 'grid', +} + +export function checkHashesExist(count) { + cy.contains(txHashesStr) + .next() + .within(() => { + main.verifyElementsCount(txHexDataRow, count) + cy.get(txHexDataRow).each(($el) => { + cy.wrap($el) + .invoke('text') + .should('match', /0x[a-fA-F0-9]{64}/) + }) + }) +} +export function clickOnReplaceTxOption() { + cy.get(replaceChoiceBtn).find('button').click() +} + +export function verifyReplaceChoiceBtnVisible() { + cy.get(replaceChoiceBtn).find('button').should('be.visible') +} + +export function getRejectButton() { + return cy.get(rejectTxBtn) +} + +export function clickOnRejectBtn() { + getRejectButton().click() +} + +export function hoverOverRejectBtnBtn() { + getRejectButton().trigger('mouseover', { force: true }) +} + +export function verifyRejectBtnDisabled() { + getRejectButton().should('be.disabled') +} + +export function verifyTxRejectModalVisible() { + main.verifyMinimumElementsCount(wallet.choiceBtn, 2) +} + +export function clickOnRejectionChoiceBtn(choice) { + cy.get(wallet.choiceBtn).eq(choice).click() +} + +export function verifyTxNonceDisplayed(nonce) { + cy.get(nonceFld).should('include.text', nonce) +} + +export function checkNonceIsReadOnly() { + cy.get(nonceFld).then(($el) => { + expect($el[0].nodeName).to.equal('DIV') + }) +} + +export function verifyRejecChoiceBtnStatus(option) { + cy.get(rejectChoiceBtn).find('button').should(option) +} + +export function verifyDeleteChoiceBtnStatus(option) { + cy.get(deleteChoiceBtn).find('button').should(option) +} + +export function typeNoteText(text) { + cy.get(noteTextField).find('input').clear().type(text) +} + +export function checkMaxNoteLength() { + typeNoteText(main.generateRandomString(61)) + cy.get(noteTextField).should('exist').contains('60/60').should('be.visible') +} + +export function checkNoteWarningMsg() { + cy.get(noteAlert).invoke('text').should('include', txNoteWarningMessage) +} + +export function checkNoteRecordedNote(note) { + cy.get(recoredTxNote).should('be.visible').invoke('text').should('include', note) +} + +export function checkNoteCreator(creator) { + cy.get(txNoteTooltip).trigger('mouseover', { force: true }) + cy.get(noteCreator).should('be.visible').invoke('text').should('include', creator) +} + +export function checkNoteRecordedNoteReadOnly() { + cy.get(recoredTxNote).then(($p) => { + expect($p.prop('tagName')).to.equal('P') + }) +} + +export function clickOnCopyLinkBtn() { + cy.get(copyLinkBtn).click() +} + +export function verifyCopiedURL() { + cy.window().then((win) => { + cy.stub(win.navigator.clipboard, 'writeText').as('clipboardWrite') + }) + + cy.url().then((currentUrl) => { + clickOnCopyLinkBtn() + + cy.get('@clipboardWrite').should('have.been.calledWith', currentUrl) + }) +} + +export function expandTxShareBlock() { + cy.get(txShareBlock).click() + cy.get(txShareBlockDetails).should('be.visible') +} + +export function checkCopyBtnExistsInShareblock() { + cy.get(txShareBlock).within(() => { + cy.get(copyLinkBtn).should('exist') + }) } export function verifyBulkExecuteBtnIsEnabled(txs) { @@ -251,11 +456,12 @@ export function verifyNumberOfCopyIcons(number) { } export function verifyNumberOfExternalLinks(number) { - cy.get(copyIcon) - .parent() - .parent() - .next() - .children('a') + cy.get('main') + .find(explorerBtn) + //.parent() + // .parent() + // .next() + //.children('a') .then(($links) => { expect($links.length).to.be.at.least(number) for (let i = 0; i < number; i++) { @@ -308,14 +514,46 @@ export function clickOnExpandableAction(data) { export function clickOnAdvancedDetails() { cy.get(advancedDetails).click() + //({ force: true }) } export function expandAdvancedDetails(data) { clickOnAdvancedDetails() data.forEach((row) => { - cy.get(txRowTitle).contains(row).should('be.visible') + cy.get('div').contains(row).should('be.visible') }) } +//The whole block inside Transaction details accordion: data-root and advanced details together +export function verifytxAccordionDetails(data) { + main.checkTextsExistWithinElement(txAccordionDetails, data) +} +// Function to check elements inside Transaction details/DecodedDataRoot +export function checkDataDecodingRoot(data) { + main.checkTextsExistWithinElement(decodedDataTop, data) +} + +export function switchView(view) { + if (view === advancedDetailsViewOptions.table) { + cy.get(tableViewBtn).click() + cy.get(txHexData).should('be.visible') + } else { + cy.get(gridViewBtn).click() + cy.get(txOperation).should('be.visible') + } +} + +export function clickOnCopyDataBtn(expectedData) { + cy.window().then((win) => { + cy.stub(win.navigator.clipboard, 'writeText').as('clipboardWrite') + }) + + cy.get(txStack).find('button').click() + cy.get('@clipboardWrite').should('have.been.calledWith', expectedData) +} + +export function switchToGridView() { + cy.get(gridViewBtn).click() +} export function collapseAdvancedDetails() { clickOnAdvancedDetails() @@ -437,6 +675,11 @@ export function clickOnNewtransactionBtn() { export function typeRecipientAddress(address) { cy.get(recepientInput).clear().type(address).should('have.value', address) } + +export function typeRecipientAddress_(index, address) { + cy.get(recepientInput_(index)).clear().type(address).should('have.value', address) +} + export function verifyENSResolves(fullAddress) { let split = fullAddress.split(':') let noPrefixAddress = split[1] @@ -467,7 +710,7 @@ export function selectCurrentWallet() { } export function verifyRelayerAttemptsAvailable() { - cy.contains(transactionsPerHrStr).should('be.visible') + cy.contains(transactionsPerHrStr).should('exist') } export function clickOnTokenselectorAndSelectSepoliaEth() { @@ -499,6 +742,10 @@ export function setSendValue(value) { cy.get(amountInput).clear().type(value) } +export function setSendValue_(index, value) { + cy.get(amountInput_(index)).clear().type(value) +} + export function clickOnNextBtn() { cy.contains(nextBtnStr).click() } @@ -519,35 +766,64 @@ export function changeNonce(value) { cy.get(nonceInput).clear().type(value, { force: true }) } -export function verifyConfirmTransactionData() { - cy.contains(yesStr).should('exist').click() - cy.contains(estimatedFeeStr).should('exist') +export function hasNonce() { + cy.get(nonceInput).invoke('val').should('match', /^\d+$/) +} - // Asserting the sponsored info is present - cy.contains(executeStr).scrollIntoView().should('be.visible') +export function verifyNonceInputValue(value) { + cy.get(nonceInput).should('have.value', value) +} - cy.get('span').contains(estimatedFeeStr) +export function clickOnYesOption() { + cy.contains(yesStr).should('exist').click() } -export function openExecutionParamsModal() { +export function displayAdvancedDetails() { cy.contains(estimatedFeeStr).click() +} + +export function openExecutionParamsModal() { + displayAdvancedDetails() cy.contains(editBtnStr).click() } export function verifyAndSubmitExecutionParams() { cy.contains(executionParamsStr).parents('form').as('Paramsform') - const arrayNames = ['Wallet nonce', 'Max priority fee (Gwei)', 'Max fee (Gwei)', 'Gas limit'] + const arrayNames = [ + advancedParametersInputNames.walletNonce, + advancedParametersInputNames.maxPriorityFee, + advancedParametersInputNames.maxFee, + advancedParametersInputNames.gasLimit, + ] arrayNames.forEach((element) => { cy.get('@Paramsform').find('label').contains(`${element}`).next().find('input').should('not.be.disabled') }) cy.get('@Paramsform').find(gasLimitInput).clear().type('100').invoke('prop', 'value').should('equal', '100') - cy.contains('Gas limit must be at least 21000').should('be.visible') + cy.contains(gasLimit21000).should('be.visible') cy.get('@Paramsform').find(gasLimitInput).clear().type('300000').invoke('prop', 'value').should('equal', '300000') cy.get('@Paramsform').find(gasLimitInput).parent('div').find(rotateLeftIcon).click() cy.get('@Paramsform').submit() } +export function setAdvancedExecutionParams() { + cy.contains(executionParamsStr).parents('form').as('Paramsform') + cy.get('@Paramsform').find(gasLimitInput).clear().type(advancedParametersValues.gasLimit) + cy.get('@Paramsform').find(maxPriorityFee).clear().type(advancedParametersValues.maxPriorityFee) + cy.get('@Paramsform').find(maxFee).clear().type(advancedParametersValues.maxFee) + cy.get('@Paramsform').find(walletNonceInput).clear().type(advancedParametersValues.walletNonce) + cy.get('@Paramsform').submit() +} + +export function verifyEditedExcutionParams() { + cy.contains(advancedParametersInputNames.walletNonce).next().should('contain', advancedParametersValues.walletNonce) + cy.contains(advancedParametersInputNames.gasLimit).next().should('contain', advancedParametersValues.gasLimit) + cy.contains(advancedParametersInputNames.maxPriorityFee) + .next() + .should('contain', advancedParametersValues.maxPriorityFee) + cy.contains(advancedParametersInputNames.maxFee).next().should('contain', advancedParametersValues.maxFee) +} + export function clickOnNoLaterOption() { cy.contains(noLaterStr).click() } @@ -556,6 +832,18 @@ export function clickOnSignTransactionBtn() { cy.get(signBtn).click() } +export function clickOnProposeTransactionBtn() { + cy.get(proposeTransactionBtn).click() +} + +export function clickOnContinueSignTransactionBtn() { + cy.get(continueSignBtn).click() +} + +export function clickOnAcknowledgement() { + cy.contains(txAcknowledgementStr).click() +} + export function clickOnConfirmTransactionBtn() { cy.get('button').contains(confirmBtnStr).click() } @@ -666,3 +954,91 @@ export function clickOnSimulateTxBtn() { export function verifySuccessfulSimulation() { cy.get(simulateSuccess).should('exist') } + +export function insufficientBalanceErrorExists() { + cy.get(insufficientBalanceError).should('exist') +} + +export function recipientAddress(index, address) { + cy.contains(`Recipient ${index}`) + .parents(dataRow) + .within(() => { + cy.contains(address).should('exist') + }) +} + +export function insufficientFundsErrorExists(index) { + cy.get(tokenAmountSection) + .eq(index) + .within(() => { + cy.get('label').should('contain.text', insufficientFundsErrorStr) + }) +} + +export function checkTokenValue(index, value) { + cy.get(amountInput_(index)).should('have.value', value) +} +export function clickOnMaxBtn(index) { + cy.get(maxBtn).eq(index).click() +} + +export function verifyAddRecipientBtnIsVisible() { + cy.get(addrecipientBtn).should('be.visible') +} + +export function verifyAddRecipientBtnDoesNotExist() { + main.verifyElementsCount(addrecipientBtn, 0) +} + +export function clickOnAddRecipientBtn() { + cy.get(addrecipientBtn).click() +} + +export function clickOnRemoveRecipientBtn(index) { + cy.get(removeRecipientBtn).eq(index).click() +} + +export function checkNumberOfRecipients(count) { + cy.get(recipientsCount).should('have.text', `${count}`) +} + +export function checkMaxRecipientReached(attempt = 0) { + const maxAttempts = 4 + + cy.get('body').then(($body) => { + if ($body.find(maxRecipientsReachedMsg).length > 0) { + cy.get(maxRecipientsReachedMsg).should('exist') + cy.get(addrecipientBtn).should('be.disabled') + checkNumberOfRecipients('5/5') + return + } + + if (attempt >= maxAttempts) { + throw new Error('Max attempts reached but message did not appear') + } + + clickOnAddRecipientBtn() + checkNumberOfRecipients(`${attempt + 2}/5`) + checkMaxRecipientReached(attempt + 1) + }) +} + +export function selectComboButtonOption(option) { + cy.get(comboButton).click() + cy.get(comboButtonPopover).findByText(comboButtonOptions[option]).click() +} + +export function checkThatComboButtonOptionIsNotPresent(option) { + cy.get('body').then(($body) => { + if ($body.find(comboButton).length > 0) { + cy.get(comboButton).then(($dropdown) => { + if ($dropdown.is(':visible')) { + cy.get(comboButton).click() + cy.get(comboButtonPopover).should('be.visible') + cy.get(comboButtonPopover).should('not.contain.text', option) + cy.get('body').click(0, 0) + } + }) + } + }) +} diff --git a/apps/web/cypress/e2e/pages/create_wallet.pages.js b/apps/web/cypress/e2e/pages/create_wallet.pages.js index e7ae2fcfec..b6bbab4d80 100644 --- a/apps/web/cypress/e2e/pages/create_wallet.pages.js +++ b/apps/web/cypress/e2e/pages/create_wallet.pages.js @@ -1,5 +1,5 @@ import * as main from '../pages/main.page' -import { connectedWalletExecMethod, relayExecMethod } from '../pages/create_tx.pages' +import { connectedWalletExecMethod, relayExecMethod, connectedWalletMethod } from '../pages/create_tx.pages' import * as sidebar from '../pages/sidebar.pages' import * as constants from '../../support/constants' @@ -52,6 +52,7 @@ export const cfSafeCreationSuccessMsg = '[data-testid="account-success-message"] export const cfSafeActivationMsg = '[data-testid="safe-activation-message"]' export const cfSafeInfo = '[data-testid="safe-info"]' const connectWalletBtn = '[data-testid="connect-wallet-btn"]' +const networkSelectorItem = '[data-testid="network-selector-item"]' const sponsorStr = 'Your account is sponsored by Goerli' const safeCreationProcessing = 'Transaction is being executed' @@ -117,8 +118,8 @@ export function verifyCFSafeCreated() { main.verifyElementsIsVisible([sidebar.pendingActivationIcon, safeActivationSection]) } -export function selectPayLaterOption() { - cy.get(connectedWalletExecMethod).click() +export function selectPayNowOption() { + cy.get(connectedWalletMethod).click() } export function selectRelayOption() { @@ -210,7 +211,7 @@ export function clearWalletName() { } export function openNetworkSelector() { - cy.get(newtworkSelectorDiv).find(expandMoreIcon).parent().click() + cy.get(networkSelectorItem).should('be.visible').click({ force: true }) } export function selectNetwork(network) { cy.wait(1000) diff --git a/apps/web/cypress/e2e/pages/dashboard.pages.js b/apps/web/cypress/e2e/pages/dashboard.pages.js index ce1675e7c0..63ec9eb328 100644 --- a/apps/web/cypress/e2e/pages/dashboard.pages.js +++ b/apps/web/cypress/e2e/pages/dashboard.pages.js @@ -10,8 +10,8 @@ const overviewStr = 'Total asset value' const sendStr = 'Send' const receiveStr = 'Receive' const viewAllStr = 'View all' -const safeAppStr = 'Safe Apps' -const exploreSafeApps = 'Explore Safe Apps' +const safeAppStr = 'Featured Apps' +const oneInchSafeApp = '1inch Network' export const copiedAppUrl = 'share/safe-app?appUrl' const copyShareBtn = '[data-testid="copy-btn-icon"]' @@ -19,8 +19,8 @@ const exploreAppsBtn = '[data-testid="explore-apps-btn"]' const viewAllLink = '[data-testid="view-all-link"][href^="/transactions/queue"]' const noTxIcon = '[data-testid="no-tx-icon"]' const noTxText = '[data-testid="no-tx-text"]' -const pendingTxWidget = '[data-testid="pending-tx-widget"]' -const pendingTxItem = '[data-testid="tx-pending-item"]' +export const pendingTxWidget = '[data-testid="pending-tx-widget"]' +export const pendingTxItem = '[data-testid="tx-pending-item"]' const singleTxDetailsHeader = '[data-testid="tx-details"]' export function clickOnTxByIndex(index) { @@ -117,7 +117,7 @@ export function verifyOverviewWidgetData() { export function verifyTxQueueWidget() { // Alias for the Transaction queue section - cy.contains('h2', transactionQueueStr).parents('section').as('txQueueSection') + cy.contains('p', transactionQueueStr).parents('section').as('txQueueSection') cy.get('@txQueueSection').within(() => { // There should be queued transactions @@ -126,9 +126,11 @@ export function verifyTxQueueWidget() { // Queued txns cy.contains( `a[href^="/transactions/tx?id=multisig_0x"]`, - '14' + 'Send' + `-0.00002 ${constants.tokenAbbreviation.sep}` + '1 out of 1', + 'Send' + `-0.00002 ${constants.tokenAbbreviation.sep}`, ).should('exist') + cy.contains(`a[href^="/transactions/tx?id=multisig_0x"]`, '1 out of 1').should('exist') + cy.contains( `a[href="${constants.transactionQueueUrl}${encodeURIComponent(staticSafes.SEP_STATIC_SAFE_2)}"]`, viewAllStr, @@ -137,6 +139,6 @@ export function verifyTxQueueWidget() { } export function verifySafeAppsSection() { - cy.contains('h2', safeAppStr).parents('section').as('safeAppsSection') - cy.get('@safeAppsSection').contains(exploreSafeApps) + cy.contains('p', safeAppStr).parents('section').as('safeAppsSection') + cy.get('@safeAppsSection').contains(oneInchSafeApp) } diff --git a/apps/web/cypress/e2e/pages/header.page.js b/apps/web/cypress/e2e/pages/header.page.js new file mode 100644 index 0000000000..676bdc2500 --- /dev/null +++ b/apps/web/cypress/e2e/pages/header.page.js @@ -0,0 +1,5 @@ +export const notificationsBtn = '[data-testid="notifications-center"]' + +export function openNotificationCenter() { + cy.get(notificationsBtn).click() +} diff --git a/apps/web/cypress/e2e/pages/load_safe.pages.js b/apps/web/cypress/e2e/pages/load_safe.pages.js index 503c9608c1..d25d467237 100644 --- a/apps/web/cypress/e2e/pages/load_safe.pages.js +++ b/apps/web/cypress/e2e/pages/load_safe.pages.js @@ -10,7 +10,7 @@ const invalidAddressNameLengthErrorMsg = 'Maximum 50 symbols' const safeDataForm = '[data-testid=load-safe-form]' const removeOwnerBtn = '[data-testid="remove-owner-btn"]' -const addOwnerBtn = '[data-testid="add-owner-btn"]' +const addOwnerBtn = '[data-testid="add-new-signer"]' const ownerPolicyStepForm = '[data-testid="owner-policy-step-form"]' const addressItem = '[data-testid="address-item"]' const nameInput = 'input[name="name"]' @@ -29,6 +29,7 @@ const transactionStr = 'Transactions' const qrErrorMsg = 'The QR could not be read' const safeAddressError = 'Address given is not a valid Safe Account address' const ownerNameLabel = 'Signer name' +export const addSafeStr = 'Add existing Safe Account' const mandatoryNetworks = [constants.networks.sepolia, constants.networks.polygon, constants.networks.ethereum] @@ -192,7 +193,7 @@ export function verifyNameLengthErrorMessage() { } export function inputAddress(address) { - cy.get(addressInput).clear().type(address) + cy.get(addressInput).should('be.visible').clear().should('exist').type(address, { delay: 50 }) } export function verifyAddressInputValue(safeAddress) { diff --git a/apps/web/cypress/e2e/pages/main.page.js b/apps/web/cypress/e2e/pages/main.page.js index 5a770954af..3b9c332dd8 100644 --- a/apps/web/cypress/e2e/pages/main.page.js +++ b/apps/web/cypress/e2e/pages/main.page.js @@ -37,6 +37,21 @@ export const fetchSafeData = (safeAddress) => { expect(response.status).to.eq(200) }) } +export const getSafe = (safeAddress, chain) => { + return cy + .request({ + method: 'GET', + url: `${constants.stagingCGWUrlv1}${constants.stagingCGWChains}${chain}${constants.stagingCGWSafes}${safeAddress}`, + headers: { + accept: 'application/json', + }, + }) + .then((response) => { + expect(response.status).to.eq(200) + console.log('********* RESPONSE ' + JSON.stringify(response.body)) + return response.body + }) +} export const getSafeBalance = (safeAddress, chain) => { return cy @@ -216,6 +231,17 @@ export function closeOutreachPopup() { }) } +export function closeSecurityNotice() { + const value = 'I understand' + cy.wait(2000) + cy.get('body').then(($body) => { + if ($body.find('button:contains(' + value + ')').length > 0) { + cy.contains('button', value).click() + cy.wait(500) + } + }) +} + export function verifyOwnerConnected(prefix = 'sep:') { cy.get(connectedOwnerBlock).should('contain', prefix) } @@ -396,3 +422,7 @@ export function getSafeAddressFromUrl(url) { const match = url.match(addressPattern) return match ? match[0] : null } + +export function shortenAddress(address) { + return `${address.slice(0, 6)}...${address.slice(-4)}` +} diff --git a/apps/web/cypress/e2e/pages/messages.pages.js b/apps/web/cypress/e2e/pages/messages.pages.js index bc6052b32d..9990c0c069 100644 --- a/apps/web/cypress/e2e/pages/messages.pages.js +++ b/apps/web/cypress/e2e/pages/messages.pages.js @@ -1,6 +1,8 @@ import { messageItem } from './create_tx.pages' const onchainMsgInput = 'input[placeholder*="Message"]' +export const offchainMessage = 'Test message 2 off-chain' + export function enterOnchainMessage(msg) { cy.get(onchainMsgInput).type(msg) } diff --git a/apps/web/cypress/e2e/pages/navigation.page.js b/apps/web/cypress/e2e/pages/navigation.page.js index 18971fd50c..98d31f892e 100644 --- a/apps/web/cypress/e2e/pages/navigation.page.js +++ b/apps/web/cypress/e2e/pages/navigation.page.js @@ -29,7 +29,7 @@ export function clickOnNewTxBtnS() { } export function clickOnWalletExpandMoreIcon() { - cy.get(expandMoreIcon).eq(0).click() + cy.get(expandMoreIcon).eq(0).click({ force: true }) cy.get(sentinelStart).next().should('exist') } diff --git a/apps/web/cypress/e2e/pages/nestedsafes.pages.js b/apps/web/cypress/e2e/pages/nestedsafes.pages.js new file mode 100644 index 0000000000..ba3ac71434 --- /dev/null +++ b/apps/web/cypress/e2e/pages/nestedsafes.pages.js @@ -0,0 +1,109 @@ +import { setMaxAmount } from '../pages/create_tx.pages.js' +import { cardContent } from '../pages/modals.page.js' +import { addToBatchBtn } from '../pages/create_tx.pages.js' + +const addNestedSafeBtn = '[data-testid="add-nested-safe-button"]' +const nestedSafeNameInput = '[data-testid="nested-safe-name-input"]' +const nextBtn = '[data-testid="next-button"]' +const fundAssetBtn = '[data-testid="fund-asset-button"]' +const assetData = '[data-testid="asset-data"]' +const amountInput = '[data-testid="amount-input"]' +const assetsInput = (index) => `input[name="assets.${index}.amount"]` +const tokenSelector = '[data-testid="token-selector"]' +const tokenItem = '[data-testid="token-item"]' +const removeAssetIcon = '[data-testid="remove-asset-icon"]' +const advancedDetailsSummary = '[data-testid="decoded-tx-summary"]' + +export const fundAssetsActions = ['SafeProxyFactory 1.4.1: createProxyWithNonce', /2\s*Send.*0\.00002\s*ETH.*to/] +export const nonfundAssetsActions = ['createProxyWithNonce', 'SafeProxyFactory 1.4.1'] + +export function clickOnAdvancedDetails() { + cy.get(advancedDetailsSummary).click() +} + +export function checkAddTobatchBtnStatus(option) { + cy.get(addToBatchBtn) + .find('button') + .should(option === 'be.disabled' ? 'have.attr' : 'not.have.attr', 'disabled') +} + +export function actionsExist(actions) { + actions.forEach((action) => { + cy.get(cardContent).contains(action).should('exist') + }) +} + +export function getAssetCount() { + return cy.get(assetData).its('length') +} + +export function removeAsset(index) { + cy.get(removeAssetIcon).eq(index).click() +} + +export function selectToken(index, token) { + cy.get(tokenSelector).eq(index).click() + cy.get('li').contains(token).click() +} + +export function getTokenList(index) { + cy.get(tokenSelector).eq(index).click() + return cy + .get(tokenSelector) + .eq(index) + .find(tokenItem) + .find('p:first') + .then(($tokens) => { + return Cypress._.map($tokens, (token) => token.innerText.trim()) + }) +} + +export function setSendValue(index, value) { + cy.get(assetsInput(index)).clear().type(value) +} + +export function verifyMaxAmount(index, token, tokenAbbreviation) { + cy.get(assetData) + .eq(index) + .within(() => { + cy.get(assetsInput(index)) + .get('p') + .contains(token) + .next() + .then((element) => { + const maxBalance = parseFloat(element.text().replace(tokenAbbreviation, '').trim()) + cy.get(assetsInput(index)).should(($input) => { + const actualValue = parseFloat($input.val()) + expect(actualValue).to.be.closeTo(maxBalance, 0.1) + }) + console.log(maxBalance) + }) + }) +} + +export function setMaxAmountValue(index) { + cy.get(assetData) + .eq(index) + .within(() => { + setMaxAmount() + }) +} +export function clickOnFundAssetBtn() { + cy.get(fundAssetBtn).click() +} + +export function clickOnAddNextBtn() { + cy.get(nextBtn).click() +} + +export function clickOnAddNestedSafeBtn() { + cy.get(addNestedSafeBtn).click() +} + +export function typeName(name) { + cy.get(`${nestedSafeNameInput} input`).clear().type(name).should('have.value', name) +} + +export function nameInputHasPlaceholder() { + cy.get(`${nestedSafeNameInput} input`).should('have.attr', 'placeholder').and('not.be.empty') +} diff --git a/apps/web/cypress/e2e/pages/nfts.pages.js b/apps/web/cypress/e2e/pages/nfts.pages.js index 84b51313f4..4f5ca9ffa2 100644 --- a/apps/web/cypress/e2e/pages/nfts.pages.js +++ b/apps/web/cypress/e2e/pages/nfts.pages.js @@ -7,10 +7,11 @@ const nftModal = modal.modal const nftModalCloseBtn = main.modalDialogCloseBtn const recipientInput = 'input[name="recipient"]' -const nftsRow = '[data-testid^="nfts-table-row"]' +export const nftsRow = '[data-testid^="nfts-table-row"]' const inactiveNftIcon = '[data-testid="nft-icon-border"]' const activeNftIcon = '[data-testid="nft-icon-primary"]' const nftCheckBox = (index) => `[data-testid="nft-checkbox-${index}"] > input` +const selectAllNFTsCheckbox = 'span[title="Select all"] > input' const activeSendNFTBtn = '[data-testid="nft-send-btn-false"]' const disabledSendNFTBtn = '[data-testid="nft-send-btn-true"]' const modalTitle = '[data-testid="modal-title"]' @@ -20,7 +21,7 @@ const nftItemList = '[data-testid="nft-item-list"]' const nftItemNane = '[data-testid="nft-item-name"]' const txDetailsSummary = '[data-testid="decoded-tx-summary"]' const txAccordionDetails = '[data-testid="decoded-tx-details"]' -const accordionActionItem = '[data-testid="action-item"]' +export const accordionActionItem = '[data-testid="action-item"]' const noneNFTSelected = /0 NFT[s]? selected/ const sendNFTStr = 'Send NFTs' @@ -105,6 +106,14 @@ export function selectNFTs(numberOfNFTs) { cy.get(nftCheckBox(i)).click() cy.contains(`${i} NFT${i > 1 ? 's' : ''} selected`) } + checkSelectedNFTsNumberIs(numberOfNFTs) +} + +export function selectAllNFTs() { + cy.get(selectAllNFTsCheckbox).click() +} + +export function checkSelectedNFTsNumberIs(numberOfNFTs) { cy.contains('button', `Send ${numberOfNFTs} NFT${numberOfNFTs > 1 ? 's' : ''}`) } diff --git a/apps/web/cypress/e2e/pages/notifications.page.js b/apps/web/cypress/e2e/pages/notifications.page.js new file mode 100644 index 0000000000..5a6a769778 --- /dev/null +++ b/apps/web/cypress/e2e/pages/notifications.page.js @@ -0,0 +1,9 @@ +import * as main from './main.page.js' + +export const notificationsTitle = '[data-testid="notifications-title"]' +export const notificationsLogo = '[data-testid="notifications-icon"]' +export const pushNotificationsBtn = '[data-testid="notifications-button"]' + +export function checkCoreElementsVisible() { + main.verifyElementsIsVisible([notificationsLogo, notificationsTitle, pushNotificationsBtn]) +} diff --git a/apps/web/cypress/e2e/pages/owners.pages.js b/apps/web/cypress/e2e/pages/owners.pages.js index 1422cedef3..cebe1650f6 100644 --- a/apps/web/cypress/e2e/pages/owners.pages.js +++ b/apps/web/cypress/e2e/pages/owners.pages.js @@ -11,9 +11,12 @@ const changeThresholdBtn = 'span[data-track="settings: Change threshold"] > butt const tooltip = 'div[role="tooltip"]' const expandMoreIcon = 'svg[data-testid="ExpandMoreIcon"]' const sentinelStart = 'div[data-testid="sentinelStart"]' +const addNewSigner = '[data-testid="add-new-signer"]' const newOwnerName = 'input[name="newOwner.name"]' const newOwnerAddress = 'input[name="newOwner.address"]' const newOwnerNonceInput = 'input[name="nonce"]' +const signerNameField = '[data-testid="owner-name"]' +const signerAddressField = '[data-testid="address-item"]' const thresholdInput = 'input[name="threshold"]' const thresHoldDropDownIcon = 'svg[data-testid="ArrowDropDownIcon"]' const thresholdList = 'ul[role="listbox"]' @@ -23,20 +26,23 @@ const existingOwnerAddressInput = (index) => `input[name="owners.${index}.addres const existingOwnerNameInput = (index) => `input[name="owners.${index}.name"]` const singleOwnerNameInput = 'input[name="name"]' const finishTransactionBtn = '[data-testid="finish-transaction-btn"]' -const addOwnerBtn = '[data-testid="add-owner-btn"]' +const manageSignersBtn = '[data-testid="manage-signers-btn"]' +const submitNextBt = '[data-testid="submit-next"]' const addOwnerNextBtn = '[data-testid="add-owner-next-btn"]' const modalHeader = '[data-testid="modal-header"]' const addressToBeRemoved = '[aria-label="Copy to clipboard"] span' const thresholdNextBtn = '[data-testid="threshold-next-btn"]' +const signerList = '[data-testid="signer-list"]' const disconnectBtnStr = 'Disconnect' const notConnectedStatus = 'Connect' const e2eWalletStr = 'E2E Wallet' const max50charsLimitStr = 'Maximum 50 symbols' const executeBtnStr = 'Execute' +const continueBtnStr = 'Continue' const backbtnStr = 'Back' const removeOwnerStr = 'Remove signer' -const selectedOwnerStr = 'Selected signer' +const selectedOwnerStr = 'Signers' const addNewOwnerStr = 'Add new signer' const processedTransactionStr = 'Transaction was successful' const changeThresholdStr = 'Change threshold' @@ -45,6 +51,14 @@ export const safeAccountNonceStr = 'Safe Account nonce' export const nonOwnerErrorMsg = 'Your connected wallet is not a signer of this Safe Account' export const disconnectedUserErrorMsg = 'Please connect your wallet' +export function checkExistingSignerCount(count) { + cy.get(signerList).find(addressBook.tableRow).should('have.length', count) +} + +export function checkExistingSignerAddress(index, address) { + cy.get(signerList).find(addressBook.tableRow).eq(index).should('contain.text', address) +} + export function verifyOwnerTransactionComplted() { cy.get(processedTransactionStr).should('exist') cy.get(finishTransactionBtn).should('exist') @@ -113,7 +127,9 @@ export function hoverOverDeleteOwnerBtn(index) { } export function openRemoveOwnerWindow(btn) { - cy.get(removeOwnerBtn).eq(btn).click({ force: true }) + const minimumCount = btn === 0 ? 1 : btn + main.verifyMinimumElementsCount(removeOwnerBtn, minimumCount) + cy.get(removeOwnerBtn).eq(btn).should('be.enabled').click({ force: true }) cy.get('div').contains(removeOwnerStr).should('exist') } @@ -133,7 +149,9 @@ export function getAddressToBeRemoved() { } export function openReplaceOwnerWindow(index) { - cy.get(replaceOwnerBtn).eq(index).click({ force: true }) + const minimumCount = index === 0 ? 1 : index + main.verifyMinimumElementsCount(replaceOwnerBtn, minimumCount) + cy.get(replaceOwnerBtn).eq(index).should('be.enabled').click({ force: true }) cy.get(newOwnerName).should('be.visible') cy.get(newOwnerAddress).should('be.visible') } @@ -152,16 +170,16 @@ export function hoverOverReplaceOwnerBtn() { cy.get(replaceOwnerBtn).trigger('mouseover', { force: true }) } -export function verifyAddOwnerBtnIsEnabled() { - cy.get(addOwnerBtn).should('exist').and('not.be.disabled') +export function verifyManageSignersBtnIsEnabled() { + cy.get(manageSignersBtn).should('exist').and('not.be.disabled') } -export function verifyAddOwnerBtnIsDisabled() { - cy.get(addOwnerBtn).should('exist').and('be.disabled') +export function verifyManageSignersBtnIsDisabled() { + cy.get(manageSignersBtn).should('exist').and('be.disabled') } -export function hoverOverAddOwnerBtn() { - cy.get(addOwnerBtn).trigger('mouseover') +export function hoverOverManageSignersBtn() { + cy.get(manageSignersBtn).trigger('mouseover') } export function verifyTooltiptext(text) { @@ -169,7 +187,7 @@ export function verifyTooltiptext(text) { } export function clickOnWalletExpandMoreIcon() { - cy.get(expandMoreIcon).eq(0).click() + cy.get(expandMoreIcon).eq(0).click({ force: true }) cy.get(sentinelStart).next().should('be.visible') } @@ -186,15 +204,17 @@ export function waitForConnectionStatus() { cy.get(createWallet.accountInfoHeader).should('exist') } -export function clickOnAddSignerBtn() { - cy.get(addOwnerBtn).should('be.enabled').click() +export function clickOnManageSignersBtn() { + cy.get(manageSignersBtn).should('be.enabled').click() } -export function openAddOwnerWindow() { - clickOnAddSignerBtn() - cy.get(newOwnerName).should('be.visible') - cy.get(newOwnerAddress).should('be.visible') +export function openManageSignersWindow() { + clickOnManageSignersBtn() + cy.get(signerNameField).should('be.visible') + cy.get(signerAddressField).should('be.visible') +} +export function clickOnAddSignerBtn() { + cy.get(addNewSigner).should('be.enabled').click() } - export function verifyNonceInputValue(value) { cy.get(newOwnerNonceInput).should('not.be.disabled') main.verifyInputValue(newOwnerNonceInput, value) @@ -207,7 +227,7 @@ export function verifyErrorMsgInvalidAddress(errorMsg) { export function verifyValidWalletName(errorMsg) { cy.get('label').contains(errorMsg).should('not.exist') } - +//Type owner address on the manage signers form export function typeOwnerAddress(address) { cy.get(newOwnerAddress) .clear() @@ -218,12 +238,22 @@ export function typeOwnerAddress(address) { }) cy.wait(1000) } - +//Type the signer address into the 'Signer Address' field on the Manage Signers page, defined by the index (owners.index.address) +export function typeOwnerAddressManage(index, address) { + cy.get(existingOwnerAddressInput(index)).clear().type(address) +} +//Type the signer name for one field pages export function typeOwnerName(name) { cy.get(newOwnerName).clear().type(name) main.verifyInputValue(newOwnerName, name) } +//Type the signer name into the "Signer Name" field for manage signers +export function typeOwnerNameManage(index, name) { + cy.get(existingOwnerNameInput(index)).clear().type(name) + main.verifyInputValue(existingOwnerNameInput(index), name) +} + export function selectNewOwner(name) { cy.contains(name).click() } @@ -231,7 +261,11 @@ export function selectNewOwner(name) { export function verifyNewOwnerName(name) { cy.get(addressBook.addressBookRecipient).should('include.text', name) } - +//next button on Manage signers +export function clickOnNextBtnManage() { + cy.get(submitNextBt).should('be.enabled').click() +} +//Next button for usual tx flow export function clickOnNextBtn() { cy.get(addOwnerNextBtn).should('be.enabled').click() } @@ -242,7 +276,7 @@ export function clickOnBackBtn() { export function verifyConfirmTransactionWindowDisplayed() { cy.get('div').contains(constants.transactionStatus.confirm).should('exist') - cy.get('button').contains(executeBtnStr).should('exist') + cy.get('button').contains(continueBtnStr).should('exist') cy.get('button').contains(backbtnStr).should('exist') } diff --git a/apps/web/cypress/e2e/pages/proposers.pages.js b/apps/web/cypress/e2e/pages/proposers.pages.js index 11411278c6..7db70bec17 100644 --- a/apps/web/cypress/e2e/pages/proposers.pages.js +++ b/apps/web/cypress/e2e/pages/proposers.pages.js @@ -49,6 +49,7 @@ export function enterProposerData(address, name) { export function clickOnSubmitProposerBtn() { cy.get(submitProposerBtn).click() + verifyProposerSuccessMsgDisplayed() } export function checkCreatorAddress(data) { @@ -109,12 +110,14 @@ export function confirmProposerDeletion(index) { export function deleteAllProposers() { cy.get('body').then(($body) => { if ($body.find(deleteProposerBtn).length > 0) { - cy.get(deleteProposerBtn).then(($items) => { - for (let i = 0; i < $items.length; i++) { - cy.wrap($items[i]).click({ force: true }) - confirmProposerDeletion(0) - } - }) + cy.get(deleteProposerBtn) + .should('be.enabled') + .then(($items) => { + for (let i = 0; i < $items.length; i++) { + cy.wrap($items[i]).click({ force: true }) + confirmProposerDeletion(0) + } + }) } main.verifyElementsCount(deleteProposerBtn, 0) }) diff --git a/apps/web/cypress/e2e/pages/recovery.pages.js b/apps/web/cypress/e2e/pages/recovery.pages.js index 48887605aa..5cb4d5648c 100644 --- a/apps/web/cypress/e2e/pages/recovery.pages.js +++ b/apps/web/cypress/e2e/pages/recovery.pages.js @@ -27,7 +27,7 @@ const executeFormBtn = '[data-testid="execute-form-btn"]' const advancedBtn = '[data-testid="advanced-btn"]' const recoveryProposalModal = '[data-testid="recovery-proposal"]' const recoveryProposalHorizontal = '[data-testid="recovery-proposal-hr"]' -const recoveryModalTitle = 'How does recovery work?' +const recoveryModalTitle = 'How does recovery work' export const recoveryOptions = { customPeriod: 'Custom period', diff --git a/apps/web/cypress/e2e/pages/safeapps.pages.js b/apps/web/cypress/e2e/pages/safeapps.pages.js index f30fed55ed..a6944c3509 100644 --- a/apps/web/cypress/e2e/pages/safeapps.pages.js +++ b/apps/web/cypress/e2e/pages/safeapps.pages.js @@ -1,4 +1,5 @@ import * as constants from '../../support/constants' +import { accordionActionItem } from '../pages/nfts.pages' const searchAppInput = 'input[id="search-by-name"]' const appUrlInput = 'input[name="appUrl"]' @@ -11,6 +12,10 @@ const appModal = '[data-testid="app-info-modal"]' export const safeAppsList = '[data-testid="apps-list"]' const openSafeAppBtn = '[data-testid="open-safe-app-btn"]' const appMessageInput = 'input[placeholder="Message"]' +const txBuilderUntrustedFallbackAlert = '[data-testid="untrusted-fallback-handler-alert"]' +export const handlerInput = 'input[id="contract-field-handler"]' +const decodedTxSummary = '[data-testid="decoded-tx-summary"]' +export const cowFallBackHandlerTitle = 'div[title="CowSwapFallbackHandler"]' const addBtnStr = /add/i const noAppsStr = /no Safe Apps found/i @@ -57,6 +62,7 @@ export const contractMethodSelector = 'input[id="contract-method-selector"]' export const testAddressValue2 = 'testAddressValue' export const testBooleanValue = 'testBooleanValue' export const testFallback = 'fallback' +export const cowFallback = 'setFallbackHandler' export const customData = 'Custom hex data' export const testBooleanValue1 = '1 testBooleanValue' export const testBooleanValue2 = '2 testBooleanValue' @@ -65,8 +71,8 @@ export const transfer2AssetsStr = 'Transfer 2 assets' export const testTransfer1 = '1 transfer' export const testTransfer2 = '2 MetaMultiSigWallet: transfer' -export const nativeTransfer2 = '2 native transfer' -export const nativeTransfer1 = '1 native transfer' +export const nativeTransfer2 = /2 Send.*ETH to.*/ +export const nativeTransfer1 = /1 Send.*ETH to.*/ export const testNativeTransfer = 'native transfer' @@ -101,7 +107,13 @@ export const signOnchainMsgStr = 'Sign message (on-chain)' export const pinWalletConnectStr = /pin walletconnect/i export const transactionBuilderStr = 'Transaction Builder' export const cowswapStr = 'CowSwap' +export const basicTypesTestContractStr = 'BasicTypesTestContract' export const testAddressValueStr = 'testAddressValue' + +export function checkActions(count, action) { + cy.get(accordionActionItem).filter(`:contains("${action}")`).should('have.length', count) +} + export const logoWalletConnect = /logo.*walletconnect/i export const walletConnectHeadlinePreview = /walletconnect/i export const newAddressValueStr = 'newValue (address)' @@ -109,10 +121,7 @@ export const newAddressValueStr2 = 'newValue(address)' export const transactiobUilderHeadlinePreview = 'Transaction Builder' export const availableNetworksPreview = 'Available networks' export const connecttextPreview = 'Compose custom contract interactions and batch them into a single transaction' -const warningDefaultAppStr = 'The application you are trying to access is not in the default Safe Apps list' export const AddressEmptyCodeStr = 'AddressEmptyCode' -export const localStorageItem = - '{"https://safe-test-app.com":[{"feature":"camera","status":"granted"},{"feature":"microphone","status":"denied"}]}' export const gridItem = 'main .MuiPaper-root > .MuiGrid-item' export const linkNames = { wcLogo: /WalletConnect logo/i, @@ -141,6 +150,20 @@ export const permissionCheckboxNames = { fullscreen: 'Fullscreen', } +export const cowFallbackHandler = 'sep:0x2f55e8b20D0B9FEFA187AA7d00B6Cbe563605bF5' + +export function verifyUntrustedHandllerWarningVisible() { + cy.get(txBuilderUntrustedFallbackAlert).should('be.visible') +} + +export function verifyUntrustedHandllerWarningDoesNotExist() { + cy.get(txBuilderUntrustedFallbackAlert).should('not.exist') +} + +export function clickOnAdvancedDetails() { + cy.get(decodedTxSummary).click({ force: true }) +} + export function triggetOffChainTx() { cy.contains(dummyTxStr).click() } @@ -174,11 +197,6 @@ export function triggetOnChainTx() { cy.contains(signOnchainMsgStr).click() } -export function verifyWarningDefaultAppMsgIsDisplayed() { - cy.get('p').contains(warningDefaultAppStr).should('be.visible') - cy.wait(1000) -} - export function typeAppName(name) { cy.get(searchAppInput).clear().type(name) } @@ -290,7 +308,9 @@ export function clickOnContinueBtn() { export function checkLocalStorage() { clickOnContinueBtn().should(() => { - expect(window.localStorage.getItem(constants.BROWSER_PERMISSIONS_KEY)).to.eq(localStorageItem) + const storedItem = window.localStorage.getItem(constants.BROWSER_PERMISSIONS_KEY) + expect(storedItem).to.include('"feature":"camera","status":"granted"') + expect(storedItem).to.include('"feature":"microphone","status":"denied"') }) } diff --git a/apps/web/cypress/e2e/pages/sidebar.pages.js b/apps/web/cypress/e2e/pages/sidebar.pages.js index 57db9fc32e..136521f202 100644 --- a/apps/web/cypress/e2e/pages/sidebar.pages.js +++ b/apps/web/cypress/e2e/pages/sidebar.pages.js @@ -13,7 +13,7 @@ const safeIcon = '[data-testid="safe-icon"]' const sidebarContainer = '[data-testid="sidebar-container"]' const openSafesIcon = '[data-testid="open-safes-icon"]' const qrModalBtn = '[data-testid="qr-modal-btn"]' -const copyAddressBtn = '[data-testid="copy-address-btn"]' +export const copyAddressBtn = '[data-testid="copy-address-btn"]' const explorerBtn = '[data-testid="explorer-btn"]' export const sideBarListItem = '[data-testid="sidebar-list-item"]' const sideBarListItemWhatsNew = '[data-testid="list-item-whats-new"]' @@ -61,11 +61,65 @@ const emptyAccountList = '[data-testid="empty-account-list"]' const searchInput = '[id="search-by-name"]' const accountsList = '[data-testid="accounts-list"]' const sortbyBtn = '[data-testid="sortby-button"]' +export const currentSafeSection = '[data-testid="current-safe-section"]' +const readOnlyChip = '[data-testid="read-only-chip"]' +const addSafeBtn = '[data-testid="add-safe-button"]' +const indexStatusSection = '[data-testid="index-status"]' +const needHelpBtn = '[data-testid="list-item-need-help"]' +const openNestedSafeListBtn = '[data-track="nested-safes: Open nested Safe list"]' +const nestedSafeListPopover = '[data-testid="nested-safe-list"]' +const breadcrumpContainer = '[data-testid="safe-breadcrumb-container"]' +const parentSafeItem = 'div[aria-label="Parent Safe"]' +const nestedSafeItem = 'div[aria-label="Nested Safe"]' +const safeIconItem = '[data-testid="safe-icon"]' + +export function clickOnOpenNestedSafeListBtn() { + cy.get(openNestedSafeListBtn).click() +} + +export function checkSafesInPopverList(safes) { + main.verifyValuesExist(nestedSafeListPopover, safes) +} + +export function checkSafesCountInPopverList(number) { + main.verifyElementsCount(nestedSafeListPopover, number) +} + +export function clickOnSafeInPopover(safe) { + cy.get(nestedSafeListPopover).within(() => { + cy.contains(safe).click() + }) +} + +export function clickOnParentSafeInBreadcrumb() { + cy.wait(1000) // Needs time to render + cy.get(breadcrumpContainer).within(() => { + cy.get(parentSafeItem).within(() => { + cy.get('a').click() + }) + }) +} + +export function checkParentSafeInBreadcrumb(name, address) { + cy.get(breadcrumpContainer).within(() => { + cy.get(parentSafeItem).within(() => { + cy.get(`a[href*="${address}"]`).should('contain', name) + }) + }) +} + +export function checkNestedSafeInBreadcrumb(name) { + cy.get(breadcrumpContainer).within(() => { + cy.get(nestedSafeItem).within(() => { + cy.get('p').should('contain', name) + }) + }) +} export const importBtnStr = 'Import' export const exportBtnStr = 'Export' export const undeployedSafe = 'Undeployed Sepolia' -const notActivatedStr = 'Not activated' +export const notActivatedStr = 'Not activated' export const addingNetworkNotPossibleStr = 'Adding another network is not possible for this Safe.' export const createSafeMsg = (network) => `Successfully added your account on ${network}` const signersNotConsistentMsg = 'Signers are not consistent' @@ -106,22 +160,32 @@ const confirmTxStr = (number) => `${number} to confirm` const pedningTxStr = (n) => `${n} pending` export const confirmGenStr = 'to confirm' const searchResults = (number) => `Found ${number} result${number === 1 ? '' : 's'}` +const needHelpLink = 'https://help.safe.global' export const sortOptions = { lastVisited: '[data-testid="last-visited-option"]', name: '[data-testid="name-option"]', } + +export function whatsNewBtnIsVisible() { + cy.get(sideBarListItemWhatsNew).should('be.visible') +} + export function checkSearchResults(number) { cy.contains(searchResults(number)).should('exist') } +export function checkNeedHelpBtnLink() { + cy.get(needHelpBtn).should('have.attr', 'href', needHelpLink) +} + export const multichainSafes = { polygon: 'Multichain polygon', sepolia: 'Multichain Sepolia', } export function searchSafe(safe) { - cy.get(searchInput).clear().type(safe) + cy.get(searchInput).clear().type(safe, { force: true }) } export function openSortOptionsMenu() { @@ -157,6 +221,31 @@ export function verifyPinnedSafe(safe) { }) } +export function verifyCurrentSafe(safe) { + cy.get(currentSafeSection).within(() => { + cy.get(sideSafeListItem).contains(safe) + }) +} + +export function verifyCurrentSafeReadOnly(number) { + cy.get(currentSafeSection).within(() => { + cy.get(readOnlyChip).should('have.length', number) + }) +} + +export function verifyIndexStatusPresent() { + cy.get(indexStatusSection).should('have.attr', 'href', constants.indexStatusUrl) +} + +export function clickOnAddSafeBtn() { + cy.get(addSafeBtn).click() + cy.url().should('include', constants.loadNewSafeUrl) +} + +export function verifyCurrentSafeDoesNotExist() { + cy.get(currentSafeSection).should('not.exist') +} + export function getImportBtn() { return cy.get(importBtn).scrollIntoView().should('be.visible') } @@ -166,6 +255,18 @@ export function clickOnSidebarImportBtn() { file.verifyValidImportInputExists() } +export function clickOnCopyAddressBtn(expectedData) { + cy.window().then((win) => { + cy.stub(win.navigator.clipboard, 'writeText').as('clipboardWrite') + }) + cy.get(copyAddressBtn).click() + cy.get('@clipboardWrite', { timeout: 10000 }).should('have.been.called') + cy.get('@clipboardWrite').then((stub) => { + const actualCallArgs = stub.args[0][0] + expect(actualCallArgs).to.include(expectedData) + }) +} + export function showAllSafes() { cy.wait(500) cy.get('body').then(($body) => { @@ -200,11 +301,7 @@ export function verifySafeHeaderDetails(details) { } export function clickOnQRCodeBtn() { - cy.get(sidebarContainer) - .should('be.visible') - .within(() => { - cy.get(qrModalBtn).click() - }) + cy.get(qrModalBtn).should('be.visible').click() } export function verifyQRModalDisplayed() { @@ -266,6 +363,12 @@ export function verifyAccountListSafeCount(count) { }) } +export function verifyAccountListSafeData(data) { + cy.get(accountsList).within(() => { + main.verifyValuesExist(sideSafeListItem, [data]) + }) +} + export function clickOnOpenSidebarBtn() { cy.get(openSafesIcon).click() } @@ -324,6 +427,10 @@ export function verifyQueuedTx(safe) { return getSafeItemOptions(safe).find(queuedTxInfo).should('exist') } +export function verifySafeIconData(safe) { + return getSafeByName(safe).find(safeIconItem).should('be.visible') +} + export function clickOnSafeItemOptionsBtn(name) { getSafeItemOptions(name).find(safeItemOptionsBtn).click() } @@ -533,8 +640,12 @@ export function checkBalanceExists() { const element = cy.get(chainLogo).prev().contains(balance) } -export function checkAddChainDialogDisplayed() { +export function clickOnAddOptionsBtn() { cy.get(safeItemOptionsAddChainBtn).click() +} + +export function checkAddChainDialogDisplayed() { + clickOnAddOptionsBtn() cy.get(addChainDialog).should('be.visible') } @@ -599,7 +710,7 @@ function getNetworkElements() { } export function checkNetworkDisabled(networks) { - getNetworkElements().should('have.length', 20) + getNetworkElements().should('have.length.gte', 20) getNetworkElements().each(($el) => { const text = $el[0].innerText.trim() console.log(`Element text: ${text}`) diff --git a/apps/web/cypress/e2e/pages/spaces.page.js b/apps/web/cypress/e2e/pages/spaces.page.js new file mode 100644 index 0000000000..c89cba7bd9 --- /dev/null +++ b/apps/web/cypress/e2e/pages/spaces.page.js @@ -0,0 +1,216 @@ +import * as constants from '../../support/constants.js' +import * as main from './main.page.js' +import staticSafes from '../../fixtures/safes/static.json' +import { tableContainer } from './address_book.page.js' + +export const orgList = '[data-testid="org-list"]' +export const createSpaceBtn = '[data-testid="create-space-button"]' +export const createSpaceModalBtn = '[data-testid="create-space-modal-button"]' +export const orgSpaceInput = '[data-testid="space-name-input"]' +export const orgName = '[data-testid="org-name"]' +const spaceSelectorBtn = '[data-testid="space-selector-button"]' +const spaceSelectorMenu = '[data-testid="space-selector-menu"]' +const spaceEditInput = 'input[name="name"]' +const spaceSaveBtn = '[data-testid="space-save-button"]' +const updateSuccessMsg = 'Updated space name' +const spaceDeleteBtn = '[data-testid="space-delete-button"]' +const spaceConfirmDeleteBtn = '[data-testid="space-confirm-delete-button"]' +// const orgAddManuallyBtn = '[data-testid="add-manually-button"]' +const addSpaceAccountBtn = '[data-testid="add-space-account-button"]' +const addSpaceAccountManuallyBtn = '[data-testid="add-space-account-manually-button"]' +const addSpaceAccountManuallyModalBtn = '[data-testid="add-manually-button"]' + +const contectMenuRemoveBtn = '[data-testid="remove-button"]' +const spaceCard = '[data-testid="space-card"]' +const spaceVertMenuIcon = '[data-testid="MoreVertIcon"]' +const addAddressInput = '[data-testid="add-address-input"]' +const netwrokSelector = '[data-testid="network-selector"]' +const netwrokItem = '[data-testid="network-item"]' +const dashboardSafeList = '[data-testid="dashboard-safe-list"]' +const addAccountsBtn = '[data-testid="add-accounts-button"]' +const addMemberBtn = '[data-testid="add-member-button"]' +const addMemberModalBtn = '[data-testid="add-member-modal-button"]' +const memberAddressInput = '[data-testid="member-address-input"]' +const memberNameInput = '[data-testid="member-name-input"]' +const acceptInviteBtn = '[data-testid="accept-invite-button"]' +const inviteNameInput = '[data-testid="invite-name-input"]' +const confirmAcceptInviteBtn = '[data-testid="confirm-accept-invite-button"]' + +// export const createSpaceBtn = '[data-testid="create-space-button"]' + +export function confirmAcceptInvite() { + cy.get(confirmAcceptInviteBtn).click() +} + +export function typeInviteName(name) { + cy.get(inviteNameInput).find('input').clear().type(name) +} + +export function acceptInvite(name) { + cy.get(acceptInviteBtn).click() + typeInviteName(name) + confirmAcceptInvite() + cy.contains(name).should('be.visible') +} + +export function clickOnSignInBtn() { + cy.contains('Sign in with').click() +} + +export function clickOnCreateSpaceBtn() { + cy.get(createSpaceBtn).should('be.enabled').click() +} + +export function clickOnCreateSpaceModalBtn() { + cy.get(createSpaceModalBtn).should('be.enabled').click() +} + +export function typeSpaceName(name) { + cy.get(orgSpaceInput).find('input').clear().type(name) +} + +export function clickOnSpaceSelector() { + cy.get(spaceSelectorBtn).click() +} + +export function spaceExists(name) { + cy.get(spaceSelectorMenu).contains(name).should('be.visible') +} + +export function createSpace(name) { + clickOnCreateSpaceBtn() + typeSpaceName(name) + clickOnCreateSpaceModalBtn() +} + +export function getSpaceId() { + return cy.url().then((url) => { + const match = url.match(/spaceId=(\d+)/) + if (!match) { + throw new Error('spaceId not found in the URL') + } + return match[1] + }) +} + +export function goToSpaceSettings() { + getSpaceId().then((spaceId) => { + cy.visit(constants.spaceUrl + spaceId) + }) +} + +export function goToSpaceMembers() { + cy.wait(1000) + getSpaceId().then((spaceId) => { + cy.visit(constants.spaceMembersUrl + spaceId) + }) +} + +export function clickOnSaveSpaceNameBtn() { + cy.get(spaceSaveBtn).click() +} + +export function editSpace(newName) { + cy.get(spaceEditInput).clear().type(newName) + clickOnSaveSpaceNameBtn() + cy.contains(updateSuccessMsg).should('be.visible') +} + +export const deleteSpaceConfirmationMsg = (name) => `Deleted space ${name}` +const noSpacesStr = 'No spaces found' + +export function deleteAllSpaces() { + cy.wait(2000) + cy.get('body').then(($body) => { + if ($body.find(spaceCard).length > 0) { + cy.get(spaceCard).then(($items) => { + for (let i = $items.length - 1; i >= 0; i--) { + cy.wrap($items[i]).within(() => { + cy.get(spaceVertMenuIcon).click({ force: true }) + }) + cy.get(contectMenuRemoveBtn).click({ force: true }) + cy.get(spaceConfirmDeleteBtn).click() + deleteAllSpaces() + } + }) + } + }) +} + +export function deleteSpace(name) { + cy.get(spaceDeleteBtn).click({ force: true }) + cy.get(spaceConfirmDeleteBtn).click() + cy.contains(noSpacesStr).should('be.visible') +} + +export function clickOnAddAccountBtn() { + cy.get(addSpaceAccountBtn).should('be.enabled').click() +} + +export function clickOnAddAccountsBtn() { + cy.get(addAccountsBtn).should('be.enabled').click() +} + +export function clickOnAddAccountManuallyBtn() { + cy.get(addSpaceAccountManuallyBtn).should('be.enabled').click() +} + +export function clickOnAddAccountManuallyModalBtn() { + cy.get(addSpaceAccountManuallyModalBtn).should('be.visible').click() +} + +export function selectNetwork(network) { + cy.get(netwrokSelector).click() + cy.get(netwrokItem).contains(network).click() +} + +export function setAddress(address) { + cy.get(addAddressInput).find('input').clear().type(address) + cy.get(addAddressInput).find('input').should('have.value', address) +} + +export function accountIsOndashboard(address) { + const shortAddress = main.shortenAddress(address) + cy.get(dashboardSafeList).contains(shortAddress).should('be.visible') +} + +export function addAccountManually(address, network) { + const shortAddress = main.shortenAddress(address) + clickOnAddAccountBtn() + clickOnAddAccountManuallyModalBtn() + selectNetwork(network) + setAddress(address) + clickOnAddAccountManuallyBtn() + clickOnAddAccountsBtn() + accountIsOndashboard(address) +} + +export function clickOnAddMemberBtn() { + cy.get(addMemberBtn).should('be.enabled').click() +} + +export function clickOnAddMemberModalBtn() { + cy.get(addMemberModalBtn).should('be.enabled').click() +} + +export function typeMemberAddress(address) { + cy.get(memberAddressInput).find('input').clear().type(address) +} + +export function typeMemberName(name) { + cy.get(memberNameInput).find('input').clear().type(name) +} + +const pendingInvitationsList = '[data-testid="pending-invitations-list"]' + +export function memberIsInList(name) { + cy.contains(name).should('be.visible') +} + +export function addMember(name, address) { + clickOnAddMemberBtn() + typeMemberAddress(address) + typeMemberName(name) + clickOnAddMemberModalBtn() + memberIsInList(name) +} diff --git a/apps/web/cypress/e2e/pages/spending_limits.pages.js b/apps/web/cypress/e2e/pages/spending_limits.pages.js index a7b8442828..9572e774b8 100644 --- a/apps/web/cypress/e2e/pages/spending_limits.pages.js +++ b/apps/web/cypress/e2e/pages/spending_limits.pages.js @@ -94,7 +94,7 @@ export function verifyMandatoryTokensExist() { export function selectToken(token) { clickOnTokenDropdown() - cy.get(tokenItem).contains(token).click() + cy.get(tokenItem).contains(token).click({ force: true }) main.verifyValuesExist(tokenBalance, [token]) } @@ -122,12 +122,13 @@ export function clickOnMaxBtn() { } export function selectSpendingLimitOption() { - const input = () => { - return cy.get(spendingLimitTxOption).find('input') - } - cy.get(spendingLimitTxOption).click() - main.checkRadioButtonState(input, constants.checkboxStates.checked) + cy.get(spendingLimitTxOption).find('input').should('be.checked') +} + +export function selectStandardOption() { + cy.get(standardTx).click() + cy.get(standardTx).find('input').should('be.checked') } export function verifyTxOptionExist(options) { @@ -171,7 +172,7 @@ export function checkTimeDropdownOptions() { } export function verifyDefaultTimeIsSet() { - cy.get(timePeriodSection).find('div').contains(timePeriodOptions.oneTime).should('be.visible') + cy.get(timePeriodSection).scrollIntoView().find('div').contains(timePeriodOptions.oneTime).should('be.visible') } export function clickOnNewSpendingLimitBtn() { @@ -184,7 +185,7 @@ export function enterSpendingLimitAmount(amount) { } export function enterBeneficiaryAddress(address) { - getBeneficiaryInput().clear().type(address) + getBeneficiaryInput().clear({ force: true }).type(address, { force: true }) } export function checkBeneficiaryInputValue(value) { diff --git a/apps/web/cypress/e2e/pages/staking.page.js b/apps/web/cypress/e2e/pages/staking.page.js index f0daba1e04..d403451016 100644 --- a/apps/web/cypress/e2e/pages/staking.page.js +++ b/apps/web/cypress/e2e/pages/staking.page.js @@ -40,6 +40,10 @@ export function getActivationTimeRegex() { return new RegExp('^\\d+\\s+hour(s)?\\s+\\d+\\s+minute(s)?$') } +export function checkActivationTimeNonEmpty() { + return new RegExp('.+') +} + export function checkTxHeaderData(data) { main.verifyValuesExist(create_tx.transactionItem, data) } diff --git a/apps/web/cypress/e2e/pages/swaps.pages.js b/apps/web/cypress/e2e/pages/swaps.pages.js index 2a640147b9..7561a43d78 100644 --- a/apps/web/cypress/e2e/pages/swaps.pages.js +++ b/apps/web/cypress/e2e/pages/swaps.pages.js @@ -16,7 +16,7 @@ export const assetsSwapBtn = '[data-testid="swap-btn"]' export const dashboardSwapBtn = '[data-testid="overview-swap-btn"]' export const customRecipient = 'div[id="recipient"]' const recipientToggle = 'button[id="toggle-recipient-mode-button"]' -const twapsAddressToggle = '[class*="Toggle__Wrapper"]' +const twapsAddressToggle = 'button[class*="Toggle__Wrapper"]' const orderTypeMenuItem = 'div[class*="MenuItem"]' const explorerBtn = '[data-testid="explorer-btn"]' const limitPriceFld = '[data-testid="limit-price"]' @@ -25,7 +25,6 @@ const slippageFld = '[data-testid="slippage"]' const orderIDFld = '[data-testid="order-id"]' const widgetFeeFld = '[data-testid="widget-fee"]' const interactWithFld = '[data-testid="interact-wth"]' -const recipientAlert = '[data-testid="recipient-alert"]' const groupedItems = '[data-testid="grouped-items"]' const inputCurrencyPreview = '[id="input-currency-preview"]' const outputCurrencyPreview = '[id="output-currency-preview"]' @@ -35,12 +34,18 @@ const placeTwapOrderStrBtn = 'Place TWAP order' const placeLimitOrderStrBtn = 'Place limit order' export const unlockOrdersBtn = '[id="unlock-advanced-orders-btn"]' const limitOrderExpiryItem = (item) => `div[data-valuetext="${item}"]` +const tokenBlock = '[data-testid="block-label"]' +const confirmPriceImpactInput = '[id="confirm-modal-input"]' +const confirmPriceImpactBtn = '[id="confirm-modal-button"]' +const tokenBalance = 'span[class*="TokenBalance"]' +const tokenItem = 'div[class*="TokenDetails"]' const limitStrBtn = 'Limit' const swapStrBtn = 'Swap' const twapStrBtn = 'TWAP' const confirmSwapStr = 'Confirm Swap' const swapAnywayStrBtn = 'Swap anyway' +const acceptStrBtn = 'Accept' const maxStrBtn = 'Max' const numberOfPartsStr = /No\.? of parts/ const sellAmountStr = 'Sell amount' @@ -57,7 +62,15 @@ const priceProtectionStr = 'Price protection' const orderSplit = 'Order will be split in' const orderDetailsStr = 'Order details' const unlockTwapOrdersStrBtn = 'Unlock TWAP orders' +const settingsModalTitle = 'Advanced Order Settings' +const customRecipientStr = 'Custom Recipient' +const recipientWarningMsg = 'Order recipient address differs from order owner!' +const selectTokenStr = 'Select a token' +export const quoteResponse = { + quote1: 'swaps/quoteresponse1.json', + quote2: 'swaps/quoteresponse2.json', +} const getInsufficientBalanceStr = (token) => `Insufficient ${token} balance` const sellAmountIsSmallStr = 'Sell amount too small' @@ -128,6 +141,32 @@ export const swapTxs = { '&id=multisig_0x8f4A19C85b39032A37f7a6dCc65234f966F72551_0xd3d13db9fc438d0674819f81be62fcd9c74a8ed7c101a8249b8895e55ee80d76', safeAppSwapOrder: '&id=multisig_0x03042B890b99552b60A073F808100517fb148F60_0x5f08e05edb210a8990791e9df2f287a5311a8137815ec85856a2477a36552f1e', + wrapSwap: + '&id=multisig_0xF184a243925Bf7fb1D64487339FF4F177Fb75644_0x06d7e5920bb59a38cf46436b146c33e7307d690875f7d64bca32a0b0c3394deb', + swapQueue: + '&id=multisig_0xD8b85a669413b25a8BE7D7698f88b7bFA20889d2_0xc2a59a93e1cbaeab5fde7a5d4cc63938e1b1e4597c7e203146a6e6e07b43a92f', +} + +export const tokenBlockLabels = { + sell: 'Sell', + buy: 'Buy exactly', +} + +export function verifySwapBtnIsVisible() { + cy.get(assetsSwapBtn).should('be.visible') +} + +export function checkInputCurrencyPreviewValue(value) { + cy.get(inputCurrencyPreview).should('contain.text', value) +} + +export function checkOutputCurrencyPreviewValue(value) { + cy.get(outputCurrencyPreview).contains(value) +} +// +export function checkTokenBlockValue(index, value) { + // cy.get(tokenBlock).eq(index).contains(value) + cy.get(tokenBlock).eq(index).should('contain.text', value) } export function unlockTwapOrders(iframeSelector) { @@ -161,7 +200,7 @@ export function setExpiry(value) { } export function setLimitExpiry(value) { - cy.get('div').contains('Expiry').parent().find('button').click() + cy.get('div').contains('Order expires in').parent().find('button').click() cy.get(limitOrderExpiryItem(value)).dblclick() } @@ -209,8 +248,7 @@ export function clickOnExceeFeeChkbox() { } export function clickOnSwapBtn() { - cy.get('button').contains(swapBtnStr).as('swapBtn') - + cy.get('button').contains(swapBtnStr).should('be.enabled').as('swapBtn') cy.get('@swapBtn').should('exist').click({ force: true }) } @@ -228,11 +266,33 @@ export function clickOnReviewOrderBtn() { } cy.wrap($button).click() }) - cy.get(reviewTwapBtn).click() + cy.get(reviewTwapBtn).should('be.enabled').click() } export function placeTwapOrder() { - cy.contains(placeTwapOrderStrBtn).click() + cy.wait(3000) + cy.get('button') + .contains(acceptStrBtn) + .should(() => {}) + .then(($button) => { + if (!$button.length) { + return + } + cy.wrap($button).click() + }) + cy.get('button').contains(placeTwapOrderStrBtn).should('be.enabled').click() +} + +export function confirmPriceImpact() { + cy.wait(3000) + cy.get('span') + .contains('Swap anyway') + .should(() => {}) + .then(($checkbox) => { + if ($checkbox.length) { + cy.wrap($checkbox).click() + } + }) } export function placeLimitOrder() { @@ -313,7 +373,20 @@ export function selectOutputCurrency(option) { export function setInputValue(value) { cy.get(inputCurrencyInput).within(() => { - cy.get('input').clear().type(value) + cy.get('input') + .should('be.visible') + .should('not.be.disabled') + .clear() + .wait(3000) + .invoke('val', '') + .trigger('input') + .then(($input) => { + if ($input.val() !== '') { + cy.wrap($input).clear().invoke('val', '').trigger('input') + } + }) + .should('have.value', '') + .type(value, { force: true }) }) } @@ -323,12 +396,17 @@ export function setOutputValue(value) { }) } +export function outputInputIsNotEmpty() { + cy.get(outputCurrencyInput).find('input').invoke('val').should('not.be.empty') +} + export function enableCustomRecipient(option) { if (!option) cy.get(recipientToggle).click() } -export function enableTwapCustomRecipient() { - cy.get(twapsAddressToggle).click() +export function enableTwapCustomRecipient(option) { + main.verifyMinimumElementsCount(twapsAddressToggle, 1) + if (!option) cy.get(twapsAddressToggle).eq(0).click() } export function disableCustomRecipient(option) { @@ -369,7 +447,7 @@ export function getWidgetFee() { } export function getTokenValue() { - return new RegExp(`\\$\\d+\\.\\d{2}`, 'i') + return new RegExp(`\\$\\d+`, 'i') } export function checkTokenOrder(regexPattern, option) { @@ -396,9 +474,8 @@ export function verifyOrderIDUrl() { }) } -export function verifyOrderDetails(limitPrice, expiry, slippage, interactWith, oderID, widgetFee) { +export function verifyOrderDetails(limitPrice, slippage, interactWith, oderID, widgetFee) { cy.contains(limitPrice) - cy.contains(expiry) cy.contains(slippage) cy.contains(oderID) cy.contains(widgetFee) @@ -406,7 +483,7 @@ export function verifyOrderDetails(limitPrice, expiry, slippage, interactWith, o } export function verifyRecipientAlertIsDisplayed() { - main.verifyElementsIsVisible([recipientAlert]) + cy.contains(recipientWarningMsg) } export function closeIntroTwapModal() { @@ -424,17 +501,19 @@ export function closeIntroTwapModal() { } export function switchToTwap() { - cy.get('a').contains(swapStrBtn).click() + cy.get('button').contains(selectTokenStr).should('be.visible') + cy.get('div').contains(swapStrBtn).should('be.visible').click() cy.wait(1000) - cy.get('a').contains(twapStrBtn).click() + cy.get('div').contains(twapStrBtn).should('be.visible').click() cy.wait(1000) closeIntroTwapModal() } export function switchToLimit() { - cy.get('a').contains(swapStrBtn).click() + cy.get('button').contains(selectTokenStr).should('be.visible') + cy.get('div').contains(swapStrBtn).click() cy.wait(1000) - cy.get('a').contains(limitStrBtn).click() + cy.get('div').contains(limitStrBtn).click() cy.wait(1000) closeIntroTwapModal() } @@ -489,14 +568,14 @@ export function checkPercentageFilled(percentage, str) { export function clickOnTokenSelctor(direction) { let selector = inputCurrencyInput if (direction === 'output') selector = outputCurrencyInput - cy.get(selector).find('button').click() + cy.get(selector).find('button').eq(0).click() } export function checkTokenList(tokens) { cy.get(tokenList).within(() => { tokens.forEach(({ name, balance }) => { - cy.get('span').contains(name).should('exist') - cy.get('span').contains(balance).should('exist') + cy.get(tokenItem).contains(name).should('exist') + cy.get(tokenBalance).contains(balance).should('exist') }) }) } @@ -544,6 +623,7 @@ export function checkTwapSettlement(index, sentValue, receivedValue) { } export function getTwapInitialData() { + cy.wait(5000) let formData = {} return cy @@ -553,6 +633,9 @@ export function getTwapInitialData() { cy.get('input', { timeout: 10000 }) .should(($input) => { const value = parseFloat($input.val()) + if (isNaN(value)) { + throw new Error('Input token value is invalid') + } expect(value).to.be.greaterThan(0) }) .invoke('val') @@ -566,6 +649,9 @@ export function getTwapInitialData() { cy.get('input', { timeout: 10000 }) .should(($input) => { const value = parseFloat($input.val()) + if (isNaN(value)) { + throw new Error('Output token value is invalid') + } expect(value).to.be.greaterThan(0) }) .invoke('val') @@ -707,3 +793,17 @@ export function checkTwapValuesInReviewScreen(formData) { expect(text).to.include(formData.numberOfParts) }) } + +export function getMockQuoteResponse(response) { + cy.fixture(response).then((mockQuote) => { + const validTo = Math.floor(Date.now() / 1000) + 60 * 60 * 24 + const expiration = new Date(validTo * 1000).toISOString() + mockQuote.quote.validTo = validTo + mockQuote.expiration = expiration + + cy.intercept('POST', '**/quote', { + statusCode: 200, + body: mockQuote, + }).as('mockedQuote') + }) +} diff --git a/apps/web/cypress/e2e/pages/transactions.page.js b/apps/web/cypress/e2e/pages/transactions.page.js index c5c2a60b7e..f0ddee3604 100644 --- a/apps/web/cypress/e2e/pages/transactions.page.js +++ b/apps/web/cypress/e2e/pages/transactions.page.js @@ -6,10 +6,25 @@ const finishTransactionBtn = '[data-testid="finish-transaction-btn"]' const executeFormBtn = '[data-testid="execute-form-btn"]' const signBtn = '[data-testid="sign-btn"]' const txConfirmBtn = '[data-track="tx-list: Confirm transaction"] > button' +const untrustedFallbackHandlerWarning = '[data-testid="untrusted-fallback-handler-warning"]' const executeBtnStr = 'Execute' const txCompletedStr = 'Transaction was successful' export const relayRemainingAttemptsStr = 'free transactions left today' +const confirmTx = 'Confirm transaction' + +export const fallbackhandlerTx = { + illegalContract: + '&id=multisig_0xc36A530ccD728d36a654ccedEB7994473474C018_0xceccff6539d75da107014e1a4ae9ccb864a6a4bf10b4e0dd38431ac80148f2f5', +} + +export function verifyUntrustedHandllerWarningVisible() { + cy.get(untrustedFallbackHandlerWarning).should('be.visible') +} + +export function verifyUntrustedHandllerWarningDoesNotExist() { + cy.get(untrustedFallbackHandlerWarning).should('not.exist') +} export function verifyTxConfirmBtnDisabled() { cy.get(txConfirmBtn).should('be.disabled') @@ -39,6 +54,10 @@ export function clickOnExecuteBtn() { cy.get(executeFormBtn).click() } +export function verifyExecuteBtnIsVisible() { + cy.get(executeFormBtn).scrollIntoView().should('be.visible') +} + export function clickOnFinishBtn() { cy.get(finishTransactionBtn).click() } diff --git a/apps/web/cypress/e2e/pages/walletconnect.page.js b/apps/web/cypress/e2e/pages/walletconnect.page.js new file mode 100644 index 0000000000..32e633912b --- /dev/null +++ b/apps/web/cypress/e2e/pages/walletconnect.page.js @@ -0,0 +1,16 @@ +import * as main from './main.page.js' + +export const wcInput = '[data-testid="wc-input"]' +export const wcLogo = '[data-testid="wc-icon"]' +export const wcTitle = '[data-testid="wc-title"]' +const wcButton = 'span[data-track="walletconnect: WC popup"]' +const wcHintsBtn = '[data-track="walletconnect: WC hide hints"] > button' + +export const connectWCStr = 'Please open one of your Safe Accounts to connect to via WalletConnect' +export function checkBasicElementsVisible() { + main.verifyElementsIsVisible([wcLogo, wcTitle, wcHintsBtn]) +} + +export function clickOnWCBtn() { + cy.get(wcButton).click() +} diff --git a/apps/web/cypress/e2e/prodhealthcheck/add_owner.cy.js b/apps/web/cypress/e2e/prodhealthcheck/add_owner.cy.js index e7ea7cee73..2675e1ccdb 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/add_owner.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/add_owner.cy.js @@ -2,7 +2,7 @@ import * as constants from '../../support/constants' import * as owner from '../pages/owners.pages' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' import * as wallet from '../../support/utils/wallet.js' -import { acceptCookies2 } from '../pages/main.page.js' +import { acceptCookies2, closeSecurityNotice } from '../pages/main.page.js' let staticSafes = [] const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) @@ -16,15 +16,16 @@ describe('[PROD] Add Owners tests', () => { beforeEach(() => { cy.visit(constants.prodbaseUrl + constants.setupUrl + staticSafes.SEP_STATIC_SAFE_4) cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) + closeSecurityNotice() acceptCookies2() }) it('Verify add owner button is disabled for disconnected user', () => { - owner.verifyAddOwnerBtnIsDisabled() + owner.verifyManageSignersBtnIsDisabled() }) - it('Verify the Add New Owner Form can be opened', () => { + it('Verify the Manage Signers Form can be opened', () => { wallet.connectSigner(signer) - owner.openAddOwnerWindow() + owner.openManageSignersWindow() }) }) diff --git a/apps/web/cypress/e2e/prodhealthcheck/create_tx.cy.js b/apps/web/cypress/e2e/prodhealthcheck/create_tx.cy.js index ca3e57f2f7..ca23272e3e 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/create_tx.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/create_tx.cy.js @@ -31,13 +31,15 @@ describe('[PROD] Create transactions tests', () => { createtx.clickOnSendTokensBtn() }) - it('Verify submitting a tx and that clicking on notification shows the transaction in queue', () => { + // Unskip when sign method is released + it.skip('Verify submitting a tx and that clicking on notification shows the transaction in queue', () => { happyPathToStepTwo() createtx.verifySubmitBtnIsEnabled() - createtx.changeNonce(14) + createtx.changeNonce(3) cy.wait(1000) + createtx.clickOnContinueSignTransactionBtn() + createtx.clickOnAcknowledgement() createtx.clickOnSignTransactionBtn() - createtx.waitForProposeRequest() createtx.clickViewTransaction() createtx.verifySingleTxPage() createtx.verifyQueueLabel() diff --git a/apps/web/cypress/e2e/prodhealthcheck/load_safe.cy.js b/apps/web/cypress/e2e/prodhealthcheck/load_safe.cy.js index 53be43badb..2ba455c13c 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/load_safe.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/load_safe.cy.js @@ -3,7 +3,7 @@ import * as constants from '../../support/constants' import * as safe from '../pages/load_safe.pages' import * as createwallet from '../pages/create_wallet.pages' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' -import { acceptCookies2 } from '../pages/main.page.js' +import { acceptCookies2, closeSecurityNotice } from '../pages/main.page.js' let staticSafes = [] @@ -17,6 +17,8 @@ describe('[PROD] Load Safe tests', () => { beforeEach(() => { cy.visit(constants.prodbaseUrl + constants.loadNewSafeSepoliaUrl) + cy.contains(safe.addSafeStr, { timeout: 10000 }) + closeSecurityNotice() acceptCookies2() }) diff --git a/apps/web/cypress/e2e/prodhealthcheck/messages_onchain.cy.js b/apps/web/cypress/e2e/prodhealthcheck/messages_onchain.cy.js index f0daa16abe..ce0b18ae52 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/messages_onchain.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/messages_onchain.cy.js @@ -2,7 +2,7 @@ import * as constants from '../../support/constants.js' import * as createTx from '../pages/create_tx.pages.js' import * as msg_data from '../../fixtures/txmessages_data.json' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' -import { acceptCookies2 } from '../pages/main.page.js' +import { acceptCookies2, closeSecurityNotice } from '../pages/main.page.js' let staticSafes = [] @@ -15,6 +15,8 @@ describe('[PROD] Onchain Messages tests', () => { beforeEach(() => { cy.visit(constants.prodbaseUrl + constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_10) + cy.contains(createTx.txStr, { timeout: 10000 }) + closeSecurityNotice() acceptCookies2() }) diff --git a/apps/web/cypress/e2e/prodhealthcheck/multichain_network.cy.js b/apps/web/cypress/e2e/prodhealthcheck/multichain_network.cy.js index f2633d120f..54b87d6166 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/multichain_network.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/multichain_network.cy.js @@ -5,7 +5,8 @@ import * as ls from '../../support/localstorage_data.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' import * as wallet from '../../support/utils/wallet.js' import * as create_wallet from '../pages/create_wallet.pages.js' -import { acceptCookies2 } from '../pages/main.page.js' +import { acceptCookies2, closeSecurityNotice } from '../pages/main.page.js' +import * as createTx from '../pages/create_tx.pages.js' let staticSafes = [] @@ -17,11 +18,6 @@ describe('[PROD] Multichain add network tests', () => { staticSafes = await getSafes(CATEGORIES.static) }) - beforeEach(() => { - cy.visit(constants.prodbaseUrl + constants.setupUrl + staticSafes.SEP_STATIC_SAFE_4) - acceptCookies2() - }) - // TODO: Unskip after next release it.skip('Verify that zkSync network is not available as add network option for safes from other networks', () => { cy.visit(constants.prodbaseUrl + constants.setupUrl + staticSafes.SEP_STATIC_SAFE_4) @@ -34,17 +30,22 @@ describe('[PROD] Multichain add network tests', () => { }) // Limitation: zkSync network does not support private key. Test might be flaky. - it('Verify that it is not possible to add networks for the zkSync safes', () => { - wallet.connectSigner(signer) + // Unskip after networkSelectorItem is released + it.skip('Verify that it is not possible to add networks for the zkSync safes', () => { cy.visit(constants.prodbaseUrl + constants.setupUrl + staticSafes.ZKSYNC_STATIC_SAFE_29) + cy.contains(createTx.settingsStr, { timeout: 10000 }) + closeSecurityNotice() + wallet.connectSigner(signer) sideBar.openSidebar() create_wallet.openNetworkSelector() cy.contains(sideBar.addingNetworkNotPossibleStr) }) it('Verify that zkSync network is not available during multichain safe creation', () => { - wallet.connectSigner(signer) cy.visit(constants.prodbaseUrl + constants.welcomeUrl + '?chain=sep') + cy.contains(createTx.getStartedStr, { timeout: 10000 }) + closeSecurityNotice() + wallet.connectSigner(signer) create_wallet.clickOnContinueWithWalletBtn() create_wallet.clickOnCreateNewSafeBtn() create_wallet.selectMultiNetwork(1, constants.networks.polygon.toLowerCase()) @@ -52,8 +53,10 @@ describe('[PROD] Multichain add network tests', () => { }) it('Verify that zkSync network is available as part of single safe creation flow ', () => { - wallet.connectSigner(signer) cy.visit(constants.prodbaseUrl + constants.welcomeUrl + '?chain=sep') + cy.contains(createTx.getStartedStr, { timeout: 10000 }) + closeSecurityNotice() + wallet.connectSigner(signer) create_wallet.clickOnContinueWithWalletBtn() create_wallet.clickOnCreateNewSafeBtn() create_wallet.clearNetworkInput(1) @@ -61,15 +64,19 @@ describe('[PROD] Multichain add network tests', () => { cy.contains('li', constants.networks.zkSync).should('not.have.attr', 'aria-disabled', 'true') }) - it('Verify list of available networks for the safe deployed on one network with mastercopy 1.3.0', () => { + // Unskip after networkSelectorItem is released + it.skip('Verify list of available networks for the safe deployed on one network with mastercopy 1.3.0', () => { const safe = 'eth:0x55d93DF21332615D48EA0c0144c7b1D176F3e7cb' cy.visit(constants.prodbaseUrl + constants.setupUrl + safe) + cy.contains(createTx.settingsStr, { timeout: 10000 }) + closeSecurityNotice() create_wallet.openNetworkSelector() sideBar.clickOnShowAllNetworksStrBtn() sideBar.checkNetworkDisabled([constants.networks.zkSync, constants.networks.gnosisChiado]) }) - it('Verify list of available networks for the safe deployed on one network with mastercopy 1.4.1', () => { + // Unskip after networkSelectorItem is released + it.skip('Verify list of available networks for the safe deployed on one network with mastercopy 1.4.1', () => { cy.visit(constants.prodbaseUrl + constants.setupUrl + staticSafes.MATIC_STATIC_SAFE_28) create_wallet.openNetworkSelector() sideBar.clickOnShowAllNetworksStrBtn() diff --git a/apps/web/cypress/e2e/prodhealthcheck/nfts.cy.js b/apps/web/cypress/e2e/prodhealthcheck/nfts.cy.js index a3eae33f62..fada13ac24 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/nfts.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/nfts.cy.js @@ -78,8 +78,11 @@ describe('[PROD] NFTs tests', () => { nfts.selectNFTs(1) nfts.sendNFT() nfts.typeRecipientAddress(staticSafes.SEP_STATIC_SAFE_1) - createTx.changeNonce(2) + createTx.changeNonce(1) nfts.clikOnNextBtn() + createTx.clickOnNoLaterOption() + createTx.clickOnContinueSignTransactionBtn() + createTx.clickOnAcknowledgement() createTx.clickOnSignTransactionBtn() createTx.waitForProposeRequest() createTx.clickViewTransaction() diff --git a/apps/web/cypress/e2e/prodhealthcheck/recovery.cy.js b/apps/web/cypress/e2e/prodhealthcheck/recovery.cy.js index e201a66a3b..ee7c2dea34 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/recovery.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/recovery.cy.js @@ -2,6 +2,8 @@ import * as constants from '../../support/constants.js' import * as main from '../pages/main.page.js' import * as recovery from '../pages/recovery.pages.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import { closeSecurityNotice } from '../pages/main.page.js' +import * as createTx from '../pages/create_tx.pages.js' let recoverySafes, staticSafes = [] @@ -18,7 +20,7 @@ describe('[PROD] Production recovery health check tests', { defaultCommandTimeou }) }) - it('Verify that the Security section contains Account recovery block on supported netwroks', () => { + it.skip('Verify that the Security section contains Account recovery block on supported netwroks', () => { const safes = [ staticSafes.ETH_STATIC_SAFE_15, staticSafes.GNO_STATIC_SAFE_16, @@ -28,6 +30,8 @@ describe('[PROD] Production recovery health check tests', { defaultCommandTimeou safes.forEach((safe) => { cy.visit(constants.prodbaseUrl + constants.securityUrl + safe) + cy.contains(createTx.settingsStr, { timeout: 10000 }) + closeSecurityNotice() recovery.getSetupRecoveryBtn() }) }) @@ -43,6 +47,8 @@ describe('[PROD] Production recovery health check tests', { defaultCommandTimeou safes.forEach((safe) => { cy.visit(constants.prodbaseUrl + constants.securityUrl + safe) + cy.contains(createTx.settingsStr, { timeout: 10000 }) + closeSecurityNotice() main.verifyElementsCount(recovery.setupRecoveryBtn, 0) }) }) diff --git a/apps/web/cypress/e2e/prodhealthcheck/remove_owner.cy.js b/apps/web/cypress/e2e/prodhealthcheck/remove_owner.cy.js index 1506729935..025c9bee3a 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/remove_owner.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/remove_owner.cy.js @@ -33,6 +33,8 @@ describe('[PROD] Remove Owners tests', () => { owner.getAddressToBeRemoved() owner.verifyOwnerDeletionWindowDisplayed() createTx.changeNonce(10) + createTx.clickOnContinueSignTransactionBtn() + createTx.clickOnAcknowledgement() createTx.clickOnSignTransactionBtn() createTx.waitForProposeRequest() createTx.clickViewTransaction() diff --git a/apps/web/cypress/e2e/prodhealthcheck/sidebar.cy.js b/apps/web/cypress/e2e/prodhealthcheck/sidebar.cy.js index 6d481ae478..c170315b55 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/sidebar.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/sidebar.cy.js @@ -4,7 +4,8 @@ import * as sideBar from '../pages/sidebar.pages' import * as navigation from '../pages/navigation.page' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' import * as wallet from '../../support/utils/wallet.js' -import { acceptCookies2 } from '../pages/main.page.js' +import { acceptCookies2, closeSecurityNotice } from '../pages/main.page.js' +import * as createTx from '../pages/create_tx.pages.js' let staticSafes = [] const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) @@ -17,10 +18,13 @@ describe('[PROD] Sidebar tests', () => { beforeEach(() => { cy.visit(constants.prodbaseUrl + constants.homeUrl + staticSafes.SEP_STATIC_SAFE_9) + cy.contains(createTx.topAssetsStr, { timeout: 10000 }) + closeSecurityNotice() acceptCookies2() }) it('Verify current safe details', () => { + wallet.connectSigner(signer) sideBar.verifySafeHeaderDetails(sideBar.testSafeHeaderDetails) }) @@ -36,6 +40,6 @@ describe('[PROD] Sidebar tests', () => { }) it('Verify New Transaction button disabled for non-owners', () => { - main.verifyElementsCount(navigation.newTxBtn, 0) + sideBar.verifyNewTxBtnStatus(constants.enabledStates.disabled) }) }) diff --git a/apps/web/cypress/e2e/prodhealthcheck/sidebar_3.cy.js b/apps/web/cypress/e2e/prodhealthcheck/sidebar_3.cy.js index f5f1d6e67e..46321c7363 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/sidebar_3.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/sidebar_3.cy.js @@ -4,7 +4,8 @@ import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' import * as wallet from '../../support/utils/wallet.js' import * as navigation from '../pages/navigation.page.js' import * as owner from '../pages/owners.pages.js' -import { acceptCookies2 } from '../pages/main.page.js' +import { acceptCookies2, closeSecurityNotice } from '../pages/main.page.js' +import * as createTx from '../pages/create_tx.pages.js' let staticSafes = [] const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) @@ -18,6 +19,8 @@ describe('[PROD] Sidebar tests 3', () => { it('Verify the "Accounts" counter at the top is counting all safes the user owns', () => { cy.visit(constants.prodbaseUrl + constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.contains(createTx.assetsStr, { timeout: 10000 }) + closeSecurityNotice() cy.intercept('GET', constants.safeListEndpoint, { 11155111: [sideBar.sideBarSafes.safe1, sideBar.sideBarSafes.safe2], }) @@ -28,19 +31,15 @@ describe('[PROD] Sidebar tests 3', () => { }) it('Verify pending signature is displayed in sidebar for unsigned tx', () => { - cy.visit(constants.prodbaseUrl + constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_7) + cy.visit(constants.prodbaseUrl + constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_44) + cy.contains(createTx.assetsStr, { timeout: 10000 }) + closeSecurityNotice() wallet.connectSigner(signer) acceptCookies2() - cy.intercept('GET', constants.safeListEndpoint, { - 11155111: [sideBar.sideBarSafesPendingActions.safe1], - }) sideBar.openSidebar() sideBar.verifyTxToConfirmDoesNotExist() owner.clickOnWalletExpandMoreIcon() navigation.clickOnDisconnectBtn() - cy.intercept('GET', constants.safeListEndpoint, { - 11155111: [sideBar.sideBarSafesPendingActions.safe1], - }) wallet.connectSigner(signer2) sideBar.verifyAddedSafesExist([sideBar.sideBarSafesPendingActions.safe1short]) sideBar.checkTxToConfirm(1) @@ -48,6 +47,8 @@ describe('[PROD] Sidebar tests 3', () => { it('Verify balance exists in a tx in sidebar', () => { cy.visit(constants.prodbaseUrl + constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_7) + cy.contains(createTx.assetsStr, { timeout: 10000 }) + closeSecurityNotice() wallet.connectSigner(signer) acceptCookies2() owner.clickOnWalletExpandMoreIcon() diff --git a/apps/web/cypress/e2e/prodhealthcheck/spending_limits.cy.js b/apps/web/cypress/e2e/prodhealthcheck/spending_limits.cy.js index 127838bf53..6e135a9e81 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/spending_limits.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/spending_limits.cy.js @@ -5,7 +5,8 @@ import * as tx from '../pages/create_tx.pages' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' import * as wallet from '../../support/utils/wallet.js' import { getMockAddress } from '../../support/utils/ethers.js' -import { acceptCookies2 } from '../pages/main.page.js' +import { acceptCookies2, closeSecurityNotice } from '../pages/main.page.js' +import * as createTx from '../pages/create_tx.pages.js' let staticSafes = [] const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) @@ -19,6 +20,8 @@ describe('[PROD] Spending limits tests', () => { beforeEach(() => { cy.visit(constants.prodbaseUrl + constants.setupUrl + staticSafes.SEP_STATIC_SAFE_8) + cy.contains(createTx.settingsStr, { timeout: 10000 }) + closeSecurityNotice() cy.get(spendinglimit.spendingLimitsSection).should('be.visible') acceptCookies2() }) diff --git a/apps/web/cypress/e2e/prodhealthcheck/swaps_history_2.cy.js b/apps/web/cypress/e2e/prodhealthcheck/swaps_history_2.cy.js index 5c28a1cd57..3fc73352e9 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/swaps_history_2.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/swaps_history_2.cy.js @@ -5,7 +5,8 @@ import * as swaps_data from '../../fixtures/swaps_data.json' import * as data from '../../fixtures/txhistory_data_data.json' import * as swaps from '../pages/swaps.pages.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' -import { acceptCookies2 } from '../pages/main.page.js' +import { acceptCookies2, closeSecurityNotice } from '../pages/main.page.js' +import * as createTx from '../pages/create_tx.pages.js' let staticSafes = [] @@ -21,6 +22,8 @@ describe('[PROD] Swaps history tests 2', () => { cy.visit( constants.prodbaseUrl + constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_1 + swaps.swapTxs.buy2actions, ) + cy.contains(createTx.txDetailsStr, { timeout: 10000 }) + closeSecurityNotice() acceptCookies2() const eq = swaps.createRegex(swapsHistory.oneGNOFull, 'COW') const atMost = swaps.createRegex(swapsHistory.forAtMostCow, 'COW') @@ -49,6 +52,8 @@ describe('[PROD] Swaps history tests 2', () => { ) const dai = swaps.createRegex(swapsHistory.forAtLeastFullDai, 'DAI') const eq = swaps.createRegex(swapsHistory.DAIeqCOW, 'COW') + cy.contains(createTx.txDetailsStr, { timeout: 10000 }) + closeSecurityNotice() acceptCookies2() main.verifyValuesExist(create_tx.transactionItem, [swapsHistory.title]) create_tx.verifySummaryByName(swapsHistory.title, null, [typeGeneral.statusOk]) diff --git a/apps/web/cypress/e2e/prodhealthcheck/swaps_tokens.cy.js b/apps/web/cypress/e2e/prodhealthcheck/swaps_tokens.cy.js index a66d0c5342..f0c42b1ed0 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/swaps_tokens.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/swaps_tokens.cy.js @@ -4,7 +4,8 @@ import * as swaps from '../pages/swaps.pages.js' import * as assets from '../pages/assets.pages.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' import * as wallet from '../../support/utils/wallet.js' -import { acceptCookies2 } from '../pages/main.page.js' +import { acceptCookies2, closeSecurityNotice } from '../pages/main.page.js' +import * as createTx from '../pages/create_tx.pages.js' let staticSafes = [] const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) @@ -19,6 +20,8 @@ describe('[PROD] Swaps token tests', () => { beforeEach(() => { cy.visit(constants.prodbaseUrl + constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_1) + cy.contains(createTx.assetsStr, { timeout: 10000 }) + closeSecurityNotice() acceptCookies2() }) diff --git a/apps/web/cypress/e2e/prodhealthcheck/tokens.cy.js b/apps/web/cypress/e2e/prodhealthcheck/tokens.cy.js index 6441d45430..2c91e42e41 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/tokens.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/tokens.cy.js @@ -1,7 +1,8 @@ import * as constants from '../../support/constants' import * as assets from '../pages/assets.pages' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' -import { acceptCookies2 } from '../pages/main.page.js' +import { acceptCookies2, closeSecurityNotice } from '../pages/main.page.js' +import * as createTx from '../pages/create_tx.pages.js' const TOKEN_AMOUNT_COLUMN = 1 const FIAT_AMOUNT_COLUMN = 2 @@ -9,13 +10,15 @@ const FIAT_AMOUNT_COLUMN = 2 let staticSafes = [] describe('[PROD] Prod tokens tests', () => { - const fiatRegex = assets.fiatRegex + const value = '--' before(async () => { staticSafes = await getSafes(CATEGORIES.static) }) beforeEach(() => { cy.visit(constants.prodbaseUrl + constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_2) + cy.contains(createTx.assetsStr, { timeout: 10000 }) + closeSecurityNotice() acceptCookies2() }) @@ -27,7 +30,7 @@ describe('[PROD] Prod tokens tests', () => { assets.currencyDaiFormat_2, TOKEN_AMOUNT_COLUMN, FIAT_AMOUNT_COLUMN, - fiatRegex, + value, ) assets.verifyBalance(assets.currencyAave, TOKEN_AMOUNT_COLUMN, assets.currencyAaveAlttext) @@ -36,7 +39,7 @@ describe('[PROD] Prod tokens tests', () => { assets.currentcyAaveFormat, TOKEN_AMOUNT_COLUMN, FIAT_AMOUNT_COLUMN, - fiatRegex, + value, ) assets.verifyBalance(assets.currencyLink, TOKEN_AMOUNT_COLUMN, assets.currencyLinkAlttext) @@ -45,7 +48,7 @@ describe('[PROD] Prod tokens tests', () => { assets.currentcyLinkFormat, TOKEN_AMOUNT_COLUMN, FIAT_AMOUNT_COLUMN, - fiatRegex, + value, ) assets.verifyBalance(assets.currencyTestTokenA, TOKEN_AMOUNT_COLUMN, assets.currencyTestTokenAAlttext) @@ -54,7 +57,7 @@ describe('[PROD] Prod tokens tests', () => { assets.currentcyTestTokenAFormat, TOKEN_AMOUNT_COLUMN, FIAT_AMOUNT_COLUMN, - fiatRegex, + value, ) assets.verifyBalance(assets.currencyTestTokenB, TOKEN_AMOUNT_COLUMN, assets.currencyTestTokenBAlttext) @@ -63,7 +66,7 @@ describe('[PROD] Prod tokens tests', () => { assets.currentcyTestTokenBFormat, TOKEN_AMOUNT_COLUMN, FIAT_AMOUNT_COLUMN, - fiatRegex, + value, ) assets.verifyBalance(assets.currencyUSDC, TOKEN_AMOUNT_COLUMN, assets.currencyTestUSDCAlttext) @@ -72,7 +75,7 @@ describe('[PROD] Prod tokens tests', () => { assets.currentcyTestUSDCFormat, TOKEN_AMOUNT_COLUMN, FIAT_AMOUNT_COLUMN, - fiatRegex, + value, ) }) diff --git a/apps/web/cypress/e2e/prodhealthcheck/tx_history.cy.js b/apps/web/cypress/e2e/prodhealthcheck/tx_history.cy.js index baf145c06c..daabd9400c 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/tx_history.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/tx_history.cy.js @@ -3,7 +3,7 @@ import * as main from '../pages/main.page' import * as createTx from '../pages/create_tx.pages' import * as data from '../../fixtures/txhistory_data_data.json' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' -import { acceptCookies2 } from '../pages/main.page.js' +import { acceptCookies2, closeSecurityNotice } from '../pages/main.page.js' let staticSafes = [] @@ -34,6 +34,8 @@ describe('[PROD] Tx history tests 1', () => { cy.visit(constants.prodbaseUrl + constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_7) cy.wait('@allTransactions') + cy.contains(createTx.txStr, { timeout: 10000 }) + closeSecurityNotice() acceptCookies2() }) @@ -86,12 +88,7 @@ describe('[PROD] Tx history tests 1', () => { it('Verify exapanded details for initial spending limits setup', () => { createTx.clickOnTransactionItemByName(typeSpendingLimits.title, typeSpendingLimits.summaryTxInfo) createTx.verifyExpandedDetails( - [ - typeSpendingLimits.contractTitle, - typeSpendingLimits.call_multiSend, - typeSpendingLimits.transactionHash, - typeSpendingLimits.safeTxHash, - ], + [typeSpendingLimits.contractTitle, typeSpendingLimits.call_multiSend, typeSpendingLimits.transactionHash], createTx.delegateCallWarning, ) }) @@ -113,7 +110,6 @@ describe('[PROD] Tx history tests 1', () => { typeDeleteAllowance.beneficiary, typeDeleteAllowance.beneficiaryAddress, typeDeleteAllowance.transactionHash, - typeDeleteAllowance.safeTxHash, typeDeleteAllowance.token, typeDeleteAllowance.tokenName, ]) @@ -121,7 +117,11 @@ describe('[PROD] Tx history tests 1', () => { it('Verify advanced details displayed in exapanded details for allowance deletion', () => { createTx.clickOnTransactionItemByName(typeDeleteAllowance.title, typeDeleteAllowance.summaryTxInfo) - createTx.expandAdvancedDetails([typeDeleteAllowance.baseGas]) + createTx.expandAdvancedDetails([ + typeDeleteAllowance.baseGas, + typeDeleteAllowance.operation, + typeDeleteAllowance.zero_call, + ]) createTx.collapseAdvancedDetails([typeDeleteAllowance.baseGas]) }) }) diff --git a/apps/web/cypress/e2e/prodhealthcheck/tx_history_2.cy.js b/apps/web/cypress/e2e/prodhealthcheck/tx_history_2.cy.js index a7ad1394aa..8c5748f18f 100644 --- a/apps/web/cypress/e2e/prodhealthcheck/tx_history_2.cy.js +++ b/apps/web/cypress/e2e/prodhealthcheck/tx_history_2.cy.js @@ -2,7 +2,7 @@ import * as constants from '../../support/constants' import * as createTx from '../pages/create_tx.pages' import * as data from '../../fixtures/txhistory_data_data.json' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' -import { acceptCookies2 } from '../pages/main.page.js' +import { acceptCookies2, closeSecurityNotice } from '../pages/main.page.js' let staticSafes = [] @@ -17,7 +17,7 @@ const typeSideActions = data.type.sideActions const typeGeneral = data.type.general const typeUntrustedToken = data.type.untrustedReceivedToken -describe('[PROD] Tx history tests 2', () => { +describe('[PROD] Tx history tests 2', { defaultCommandTimeout: 30000 }, () => { before(async () => { staticSafes = await getSafes(CATEGORIES.static) }) @@ -35,6 +35,9 @@ describe('[PROD] Tx history tests 2', () => { ).as('allTransactions') cy.visit(constants.prodbaseUrl + constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_7) + cy.wait('@allTransactions', { timeout: 30000 }) + cy.contains(createTx.txStr) + closeSecurityNotice() acceptCookies2() }) @@ -45,11 +48,7 @@ describe('[PROD] Tx history tests 2', () => { // On-chain rejection it('Verify exapanded details for on-chain rejection', () => { createTx.clickOnTransactionItemByName(typeOnchainRejection.title) - createTx.verifyExpandedDetails([ - typeOnchainRejection.description, - typeOnchainRejection.transactionHash, - typeOnchainRejection.safeTxHash, - ]) + createTx.verifyExpandedDetails([typeOnchainRejection.description, typeOnchainRejection.transactionHash2]) createTx.verifyActionListExists([ typeSideActions.rejectionCreated, typeSideActions.confirmations, @@ -60,10 +59,7 @@ describe('[PROD] Tx history tests 2', () => { // Batch transaction it('Verify exapanded details for batch', () => { createTx.clickOnTransactionItemByName(typeBatch.title, typeBatch.summaryTxInfo) - createTx.verifyExpandedDetails( - [typeBatch.contractTitle, typeBatch.transactionHash, typeBatch.safeTxHash], - createTx.delegateCallWarning, - ) + createTx.verifyExpandedDetails([typeBatch.contractTitle, typeBatch.transactionHash], createTx.delegateCallWarning) createTx.verifyActions([typeBatch.nativeTransfer.title]) }) @@ -87,7 +83,6 @@ describe('[PROD] Tx history tests 2', () => { typeChangeOwner.oldOwner.ownerAddress, typeChangeOwner.transactionHash, - typeChangeOwner.safeTxHash, ]) }) @@ -109,11 +104,7 @@ describe('[PROD] Tx history tests 2', () => { it('Verify exapanded details for changing threshold', () => { createTx.clickOnTransactionItemByName(typeChangeThreshold.title) createTx.verifyExpandedDetails( - [ - typeChangeThreshold.requiredConfirmationsTitle, - typeChangeThreshold.transactionHash, - typeChangeThreshold.safeTxHash, - ], + [typeChangeThreshold.requiredConfirmationsTitle, typeChangeThreshold.transactionHash], createTx.policyChangeWarning, ) createTx.checkRequiredThreshold(2) diff --git a/apps/web/cypress/e2e/regression/add_owner.cy.js b/apps/web/cypress/e2e/regression/add_owner.cy.js index 2c5813c683..2ef361d3d8 100644 --- a/apps/web/cypress/e2e/regression/add_owner.cy.js +++ b/apps/web/cypress/e2e/regression/add_owner.cy.js @@ -22,19 +22,20 @@ describe('Add Owners tests', () => { // Added to prod it('Verify add owner button is disabled for disconnected user', () => { - owner.verifyAddOwnerBtnIsDisabled() + owner.verifyManageSignersBtnIsDisabled() }) // Added to prod it('Verify the Add New Owner Form can be opened', () => { wallet.connectSigner(signer) - owner.openAddOwnerWindow() + owner.openManageSignersWindow() }) it('Verify error message displayed if character limit is exceeded in Name input', () => { wallet.connectSigner(signer) - owner.openAddOwnerWindow() - owner.typeOwnerName(main.generateRandomString(51)) + owner.openManageSignersWindow() + owner.clickOnAddSignerBtn() + owner.typeOwnerNameManage(1, main.generateRandomString(51)) owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.exceedChars) }) @@ -47,35 +48,39 @@ describe('Add Owners tests', () => { addressBook.verifyNewEntryAdded(constants.addresBookContacts.user1.name, constants.addresBookContacts.user1.address) cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_4) wallet.connectSigner(signer) - owner.openAddOwnerWindow() - owner.typeOwnerAddress(constants.addresBookContacts.user1.address) - owner.verifyNewOwnerName(constants.addresBookContacts.user1.name) + owner.openManageSignersWindow() + owner.clickOnAddSignerBtn() + owner.typeOwnerAddressManage(1, constants.addresBookContacts.user1.address) + owner.verifyNewOwnerName(1, constants.addresBookContacts.user1.name) }) - + //The case should be updated with review "Add owner" field on next page and other options it('Verify that Name field not mandatory', () => { wallet.connectSigner(signer) - owner.openAddOwnerWindow() - owner.typeOwnerAddress(getMockAddress()) - owner.clickOnNextBtn() + owner.openManageSignersWindow() + owner.clickOnAddSignerBtn() + owner.typeOwnerAddressManage(1, getMockAddress()) + owner.clickOnNextBtnManage() owner.verifyConfirmTransactionWindowDisplayed() }) it('Verify default threshold value. Verify correct threshold calculation', () => { wallet.connectSigner(signer) - owner.openAddOwnerWindow() - owner.typeOwnerAddress(constants.DEFAULT_OWNER_ADDRESS) + owner.openManageSignersWindow() + owner.clickOnAddSignerBtn() + owner.typeOwnerAddressManage(1, constants.DEFAULT_OWNER_ADDRESS) owner.verifyThreshold(1, 2) }) - + //TBD the case should be updated with additional steps to verify a new owner address and name it('Verify valid Address validation', () => { wallet.connectSigner(signer) - owner.openAddOwnerWindow() - owner.typeOwnerAddress(constants.SEPOLIA_OWNER_2) - owner.clickOnNextBtn() + owner.openManageSignersWindow() + owner.clickOnAddSignerBtn() + owner.typeOwnerAddressManage(1, constants.SEPOLIA_OWNER_2) + owner.clickOnNextBtnManage() owner.verifyConfirmTransactionWindowDisplayed() owner.clickOnBackBtn() - owner.typeOwnerAddress(staticSafes.SEP_STATIC_SAFE_3) - owner.clickOnNextBtn() + owner.typeOwnerAddressManage(1, staticSafes.SEP_STATIC_SAFE_3) + owner.clickOnNextBtnManage() owner.verifyConfirmTransactionWindowDisplayed() }) }) diff --git a/apps/web/cypress/e2e/regression/assets.cy.js b/apps/web/cypress/e2e/regression/assets.cy.js index 418fac951b..0cb4ca8814 100644 --- a/apps/web/cypress/e2e/regression/assets.cy.js +++ b/apps/web/cypress/e2e/regression/assets.cy.js @@ -30,7 +30,7 @@ describe('Assets tests', () => { assets.clickOnSendBtn(0) }) - it('[SMOKE] Verify that Token list dropdown down options show/hide spam tokens', () => { + it('Verify that Token list dropdown shows options "Default tokens" and "All tokens"', () => { let spamTokens = [ assets.currencyAave, assets.currencyTestTokenA, @@ -40,7 +40,10 @@ describe('Assets tests', () => { assets.currencyDaiCap, ] + assets.selectTokenList(assets.tokenListOptions.default) + main.verifyValuesExist(assets.tokenListTable, [constants.tokenNames.sepoliaEther]) main.verifyValuesDoNotExist(assets.tokenListTable, spamTokens) + assets.selectTokenList(assets.tokenListOptions.allTokens) spamTokens.push(constants.tokenNames.sepoliaEther) main.verifyValuesExist(assets.tokenListTable, spamTokens) diff --git a/apps/web/cypress/e2e/regression/assets_2.cy.js b/apps/web/cypress/e2e/regression/assets_2.cy.js new file mode 100644 index 0000000000..d8f8df01b4 --- /dev/null +++ b/apps/web/cypress/e2e/regression/assets_2.cy.js @@ -0,0 +1,83 @@ +import * as constants from '../../support/constants.js' +import * as assets from '../pages/assets.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as main from '../pages/main.page.js' +import * as navigation from '../pages/navigation.page' +import * as nfts from '../pages/nfts.pages.js' +import { clickOnAssetSwapBtn } from '../pages/swaps.pages.js' + +let staticSafes = [] + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_1_PRIVATE_KEY +const signer2 = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Assets 2 tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify no pagination shows at the bottom if there are less than 25 rows', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_2) + let spamTokens = [ + assets.currencyAave, + assets.currencyTestTokenA, + assets.currencyTestTokenB, + assets.currencyUSDC, + assets.currencyLink, + assets.currencyDaiCap, + ] + + assets.selectTokenList(assets.tokenListOptions.allTokens) + main.verifyValuesExist(assets.tokenListTable, spamTokens) + main.verifyElementsCount(assets.tablePaginationContainer, 0) + }) + + it('Verify Proposers have the Send and Swap buttons enabled', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_31) + wallet.connectSigner(signer) + assets.selectTokenList(assets.tokenListOptions.default) + main.verifyValuesExist(assets.tokenListTable, [constants.tokenNames.sepoliaEther]) + assets.showSendBtn().should('be.enabled') + assets.showSwapBtn().should('be.enabled') + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify that Send and Swap buttons are enabled for spending limit users', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_8) + wallet.connectSigner(signer2) + assets.selectTokenList(assets.tokenListOptions.default) + main.verifyValuesExist(assets.tokenListTable, [constants.tokenNames.sepoliaEther]) + assets.showSendBtn().should('be.enabled') + assets.showSwapBtn().should('be.enabled') + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify the counter at the top is updated for every selected token', () => { + cy.visit(constants.balanceNftsUrl + staticSafes.SEP_STATIC_SAFE_2) + nfts.waitForNftItems(5) + nfts.selectNFTs(1) + }) + + it('Verify the "select all" checkbox does checks all the nfts', () => { + cy.visit(constants.balanceNftsUrl + staticSafes.SEP_STATIC_SAFE_2) + nfts.waitForNftItems(5) + nfts.selectAllNFTs() + nfts.checkSelectedNFTsNumberIs(10) + }) + + it('Verify every NFT has its shorten address', () => { + cy.visit(constants.balanceNftsUrl + staticSafes.SEP_STATIC_SAFE_2) + nfts.waitForNftItems(5) + assets.checkNftAddressFormat() + }) + + it('Verify every NFT has the copy-to-clipboard and blockexplorer button', () => { + cy.visit(constants.balanceNftsUrl + staticSafes.SEP_STATIC_SAFE_2) + nfts.waitForNftItems(5) + assets.checkNftCopyIconAndLink() + }) +}) diff --git a/apps/web/cypress/e2e/regression/bulk_execution.cy.js b/apps/web/cypress/e2e/regression/bulk_execution.cy.js index 12aa9b6cf5..848c435cf2 100644 --- a/apps/web/cypress/e2e/regression/bulk_execution.cy.js +++ b/apps/web/cypress/e2e/regression/bulk_execution.cy.js @@ -36,7 +36,7 @@ describe('Bulk execution', () => { 'Verify that "Confirm bulk execution" screen contains only available for execution txs in the actions list', { defaultCommandTimeout: 30000 }, () => { - const actions = ['1transfer', '2removeOwner'] + const actions = ['1Send', '2removeOwner'] cy.visit(constants.transactionQueueUrl + fundsSafes.SEP_FUNDS_SAFE_14) wallet.connectSigner(signer) diff --git a/apps/web/cypress/e2e/regression/create_safe_simple.cy.js b/apps/web/cypress/e2e/regression/create_safe_simple.cy.js index 3f896e9188..60385b4af2 100644 --- a/apps/web/cypress/e2e/regression/create_safe_simple.cy.js +++ b/apps/web/cypress/e2e/regression/create_safe_simple.cy.js @@ -134,8 +134,7 @@ describe('Safe creation tests', () => { }) }) - // Unskip when the bug is fixed - it.skip('Verify Next button is disabled until switching to network is done', () => { + it('Verify Next button is disabled until switching to network is done', () => { createwallet.clickOnContinueWithWalletBtn() createwallet.clickOnCreateNewSafeBtn() createwallet.verifyNextBtnIsEnabled() diff --git a/apps/web/cypress/e2e/regression/create_tx.cy.js b/apps/web/cypress/e2e/regression/create_tx.cy.js index e5afce8cb7..947322f7b5 100644 --- a/apps/web/cypress/e2e/regression/create_tx.cy.js +++ b/apps/web/cypress/e2e/regression/create_tx.cy.js @@ -34,6 +34,8 @@ describe('Create transactions tests', () => { createtx.verifySubmitBtnIsEnabled() createtx.changeNonce(14) cy.wait(1000) + createtx.clickOnContinueSignTransactionBtn() + createtx.selectComboButtonOption('sign') createtx.clickOnSignTransactionBtn() createtx.clickViewTransaction() createtx.verifySingleTxPage() @@ -48,6 +50,8 @@ describe('Create transactions tests', () => { createtx.clickOnNewtransactionBtn() createtx.clickOnSendTokensBtn() happyPathToStepTwo() + createtx.clickOnContinueSignTransactionBtn() + createtx.selectComboButtonOption('execute') cy.contains(tx.relayRemainingAttemptsStr).should('exist') }) }) diff --git a/apps/web/cypress/e2e/regression/create_tx_2.cy.js b/apps/web/cypress/e2e/regression/create_tx_2.cy.js index 2753dcdcf4..80d951217a 100644 --- a/apps/web/cypress/e2e/regression/create_tx_2.cy.js +++ b/apps/web/cypress/e2e/regression/create_tx_2.cy.js @@ -29,23 +29,35 @@ describe('Create transactions tests 2', () => { createtx.clickOnSendTokensBtn() }) + it('Verify advance parameters are saved after editing', () => { + happyPathToStepTwo() + createtx.changeNonce('5') + createtx.clickOnContinueSignTransactionBtn() + createtx.selectComboButtonOption('execute') + createtx.selectCurrentWallet() + createtx.openExecutionParamsModal() + createtx.setAdvncedExecutionParams() + createtx.displayAdvncedDetails() + createtx.verifyEditedExutionParams() + }) + it('Verify advance parameters gas limit input', () => { happyPathToStepTwo() - createtx.changeNonce('1') + createtx.changeNonce('5') + createtx.clickOnContinueSignTransactionBtn() + createtx.selectComboButtonOption('execute') createtx.selectCurrentWallet() createtx.openExecutionParamsModal() createtx.verifyAndSubmitExecutionParams() }) - it('Verify a transaction shows relayer and addToBatch button', () => { + it('Verify a transaction shows relayer attempts', () => { happyPathToStepTwo() createtx.verifySubmitBtnIsEnabled() createtx.verifyNativeTokenTransfer() - createtx.changeNonce('1') - createtx.verifyConfirmTransactionData() + createtx.changeNonce('5') + createtx.clickOnContinueSignTransactionBtn() + createtx.selectComboButtonOption('execute') createtx.verifyRelayerAttemptsAvailable() - createtx.selectCurrentWallet() - createtx.clickOnNoLaterOption() - createtx.verifyAddToBatchBtnIsEnabled() }) }) diff --git a/apps/web/cypress/e2e/regression/dashboard.cy.js b/apps/web/cypress/e2e/regression/dashboard.cy.js index 2307e446dc..faf0787986 100644 --- a/apps/web/cypress/e2e/regression/dashboard.cy.js +++ b/apps/web/cypress/e2e/regression/dashboard.cy.js @@ -2,11 +2,15 @@ import * as constants from '../../support/constants' import * as dashboard from '../pages/dashboard.pages' import * as safeapps from '../pages/safeapps.pages' import * as createTx from '../pages/create_tx.pages' +import * as main from '../pages/main.page.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' let staticSafes = [] -const txData = ['14', 'Send', '-0.00002 ETH', '1 out of 1'] +const txData = ['Send', '-0.00002 ETH', '1 out of 1'] +const txaddOwner = ['addOwnerWithThreshold', '1 out of 2'] +const txMultiSendCall3 = ['Batch', '3 actions', '1 out of 2'] +const txMultiSendCall2 = ['Batch', '2 actions', '1 out of 2'] describe('Dashboard tests', { defaultCommandTimeout: 20000 }, () => { before(async () => { @@ -17,16 +21,6 @@ describe('Dashboard tests', { defaultCommandTimeout: 20000 }, () => { cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_2) }) - it('Verify that pinned in dashboard, an app keeps its status on apps page', () => { - dashboard.pinAppByIndex(0).then((pinnedApp) => { - cy.visit(constants.appsUrlGeneral + staticSafes.SEP_STATIC_SAFE_2) - safeapps.verifyPinnedApp(pinnedApp) - cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_2) - dashboard.clickOnPinBtnByName(pinnedApp) - dashboard.verifyPinnedAppsCount(0) - }) - }) - it('Verify clicking on View All button directs to list of all queued txs', () => { dashboard.clickOnViewAllBtn() createTx.verifyNumberOfTransactions(2) @@ -36,4 +30,23 @@ describe('Dashboard tests', { defaultCommandTimeout: 20000 }, () => { dashboard.clickOnTxByIndex(0) dashboard.verifySingleTxItem(txData) }) + + it('Verify there is empty tx string and image when there are no tx queued', () => { + cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_13) + dashboard.verifyEmptyTxSection() + }) + + it('[SMOKE] Verify that the last created tx in conflicting tx is showed in the widget', () => { + cy.get(dashboard.pendingTxWidget, { timeout: 30000 }).should('be.visible') + main.verifyElementsCount(dashboard.pendingTxItem, 1) + dashboard.verifyDataInPendingTx(txData) + }) + + it('[SMOKE] Verify that tx are displayed correctly in Pending tx section', () => { + cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_12) + cy.wait(1000) + dashboard.verifyTxItemInPendingTx(txMultiSendCall3) + dashboard.verifyTxItemInPendingTx(txaddOwner) + dashboard.verifyTxItemInPendingTx(txMultiSendCall2) + }) }) diff --git a/apps/web/cypress/e2e/regression/limit_order.cy.js b/apps/web/cypress/e2e/regression/limit_order.cy.js index faed8493ef..b7893b5ef8 100644 --- a/apps/web/cypress/e2e/regression/limit_order.cy.js +++ b/apps/web/cypress/e2e/regression/limit_order.cy.js @@ -32,6 +32,7 @@ describe('Limit order tests', { defaultCommandTimeout: 30000 }, () => { iframeSelector = `iframe[src*="${constants.swapWidget}"]` swaps.acceptLegalDisclaimer() main.getIframeBody(iframeSelector).within(() => { + cy.wait(20000) // Need more time to load UI swaps.switchToLimit() swaps.selectInputCurrency(swaps.swapTokens.cow) swaps.setInputValue(500) diff --git a/apps/web/cypress/e2e/regression/limit_order_history.cy.js b/apps/web/cypress/e2e/regression/limit_order_history.cy.js index 5871fbf874..fbcbfd7f9f 100644 --- a/apps/web/cypress/e2e/regression/limit_order_history.cy.js +++ b/apps/web/cypress/e2e/regression/limit_order_history.cy.js @@ -3,18 +3,13 @@ import * as main from '../pages/main.page.js' import * as swaps from '../pages/swaps.pages.js' import * as create_tx from '../pages/create_tx.pages.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' -import * as wallet from '../../support/utils/wallet.js' import * as swaps_data from '../../fixtures/swaps_data.json' - -const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) -const signer = walletCredentials.OWNER_4_PRIVATE_KEY +import * as data from '../../fixtures/txhistory_data_data.json' let staticSafes = [] -let iframeSelector - const swapsHistory = swaps_data.type.history -const swapOrder = swaps_data.type.orderDetails +const typeGeneral = data.type.general describe('Limit order history tests', { defaultCommandTimeout: 30000 }, () => { before(async () => { @@ -40,4 +35,20 @@ describe('Limit order history tests', { defaultCommandTimeout: 30000 }, () => { create_tx.clickOnAdvancedDetails() create_tx.verifyAdvancedDetails([swapsHistory.gGpV2, swapsHistory.actionPreSignatureG]) }) + + it( + 'Verify that limit order tx created via CowSwap safe app has decoding in the history', + { defaultCommandTimeout: 30000 }, + () => { + cy.visit(constants.transactionUrl + swaps.limitOrderSafe + swaps.swapTxs.sellLimitOrderFilled) + const usdc = swaps.createRegex(swapsHistory.forAtLeastFullUSDT, 'USDT') + const eq = swaps.createRegex(swapsHistory.USDTeqUSDC, 'USDC') + + create_tx.verifySummaryByName(swapsHistory.limitorder_title, null, [typeGeneral.statusOk]) + main.verifyElementsExist([create_tx.altImgUsdc, create_tx.altImgUsdt], create_tx.altImgLimitOrder) + create_tx.verifyExpandedDetails([swapsHistory.sellOrder, swapsHistory.sell, usdc, eq, swapsHistory.filled]) + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([swapsHistory.gGpV2, swapsHistory.actionPreSignatureG]) + }, + ) }) diff --git a/apps/web/cypress/e2e/regression/limit_order_queue.cy.js b/apps/web/cypress/e2e/regression/limit_order_queue.cy.js new file mode 100644 index 0000000000..7cf8375fde --- /dev/null +++ b/apps/web/cypress/e2e/regression/limit_order_queue.cy.js @@ -0,0 +1,38 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as swaps from '../pages/swaps.pages.js' +import * as create_tx from '../pages/create_tx.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as swaps_data from '../../fixtures/swaps_data.json' + +let staticSafes = [] + +const swapsHistory = swaps_data.type.history + +describe('Limit order queue tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it( + 'Verify that limit order tx created via CowSwap safe app has decoding in the history', + { defaultCommandTimeout: 30000 }, + () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.sellQLimitOrder) + const dai = swaps.createRegex(swapsHistory.forAtLeastFullDai, 'DAI') + const eq = swaps.createRegex(swapsHistory.DAIeqCOW, 'COW') + + create_tx.verifySummaryByName(swapsHistory.limitorder_title) + main.verifyElementsExist([create_tx.altImgCow, create_tx.altImgDai], create_tx.altImgLimitOrder) + create_tx.verifyExpandedDetails([ + swapsHistory.sellOrder, + swapsHistory.sell, + dai, + eq, + swapsHistory.executionNeeded, + ]) + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([swapsHistory.gGpV2, swapsHistory.actionPreSignatureG]) + }, + ) +}) diff --git a/apps/web/cypress/e2e/regression/load_safe_3.cy.js b/apps/web/cypress/e2e/regression/load_safe_3.cy.js index baae295b26..d00bed828a 100644 --- a/apps/web/cypress/e2e/regression/load_safe_3.cy.js +++ b/apps/web/cypress/e2e/regression/load_safe_3.cy.js @@ -57,4 +57,9 @@ describe('Load Safe tests - 3', () => { safe.clickNetworkSelector(constants.networks.polygon) safe.selectSepolia() }) + + it('Verify there are mandatory networks in dropdown: Eth, Polygon, Sepolia', () => { + safe.clickNetworkSelector(constants.networks.sepolia) + safe.verifyMandatoryNetworksExist() + }) }) diff --git a/apps/web/cypress/e2e/regression/mass_payouts.cy.js b/apps/web/cypress/e2e/regression/mass_payouts.cy.js new file mode 100644 index 0000000000..3717771133 --- /dev/null +++ b/apps/web/cypress/e2e/regression/mass_payouts.cy.js @@ -0,0 +1,103 @@ +import * as constants from '../../support/constants.js' +import * as createtx from '../pages/create_tx.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as spendinglimit from '../pages/spending_limits.pages' +import * as navigation from '../pages/navigation.page' +import { getMockAddress } from '../../support/utils/ethers.js' +import { selectToken, selectTokenList, tokenListOptions } from '../pages/assets.pages.js' + +let staticSafes = [] + +const sendValue = 0.00998 +const sendValue2 = 0.0001 + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Mass payouts tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify that the "Add recipient" button is displayed for the targeted safes on New Tx form', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_6) + wallet.connectSigner(signer) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + createtx.verifyAddRecipientBtnIsVisible() + }) + + it('Verify that users can add up to 5 recipients', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_6) + wallet.connectSigner(signer) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + createtx.checkMaxRecipientReached() + }) + + it('Verify that "Remove recipient" deletes the recipient field', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_6) + wallet.connectSigner(signer) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + createtx.clickOnAddRecipientBtn() + createtx.checkNumberOfRecipients('2/5') + createtx.clickOnRemoveRecipientBtn(0) + createtx.checkNumberOfRecipients('1/5') + }) + + it('Verify that "Remove recipient" deletes the recipient field', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_8) + wallet.connectSigner(signer) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + spendinglimit.selectSpendingLimitOption() + createtx.verifyAddRecipientBtnDoesNotExist() + }) + + it('Verify the "Max" button sets the full amount', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_6) + wallet.connectSigner(signer) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + createtx.clickOnAddRecipientBtn() + createtx.checkNumberOfRecipients('2/5') + createtx.clickOnMaxBtn(1) + createtx.checkTokenValue(1, sendValue) + }) + + it('Verify "insufficient amount" error for the same token during send to a few recipients', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_6) + wallet.connectSigner(signer) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + createtx.clickOnAddRecipientBtn() + createtx.clickOnMaxBtn(0) + createtx.clickOnMaxBtn(1) + createtx.checkTokenValue(1, sendValue) + createtx.insufficientFundsErrorExists(0) + createtx.insufficientFundsErrorExists(1) + createtx.insufficientBalanceErrorExists() + }) + + it('Verify recipients are displayed in review tx screen', () => { + const address1 = getMockAddress() + const address2 = getMockAddress() + + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_6) + wallet.connectSigner(signer) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + createtx.clickOnAddRecipientBtn() + createtx.typeRecipientAddress_(0, address1) + createtx.typeRecipientAddress_(1, address2) + createtx.setSendValue_(0, sendValue2) + createtx.setSendValue_(1, sendValue2) + createtx.clickOnNextBtn() + createtx.recipientAddress(1, address1) + createtx.recipientAddress(2, address2) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) +}) diff --git a/apps/web/cypress/e2e/regression/messages_offchain.cy.js b/apps/web/cypress/e2e/regression/messages_offchain.cy.js index 5b98aa1bfb..d25b5adbbd 100644 --- a/apps/web/cypress/e2e/regression/messages_offchain.cy.js +++ b/apps/web/cypress/e2e/regression/messages_offchain.cy.js @@ -2,11 +2,16 @@ import * as constants from '../../support/constants.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' import * as modal from '../pages/modals.page' import * as messages from '../pages/messages.pages.js' +import * as createTx from '../pages/create_tx.pages.js' import * as msg_confirmation_modal from '../pages/modals/message_confirmation.pages.js' +import * as msg_data from '../../fixtures/txmessages_data.json' +import * as main from '../pages/main.page.js' import * as wallet from '../../support/utils/wallet.js' let staticSafes = [] -const offchainMessage = 'Test message 2 off-chain' + +const typeMessagesGeneral = msg_data.type.general +const typeMessagesOffchain = msg_data.type.offChain const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) const signer2 = walletCredentials.OWNER_1_PRIVATE_KEY @@ -16,19 +21,71 @@ describe('Offchain Messages tests', () => { staticSafes = await getSafes(CATEGORIES.static) }) - beforeEach(() => { - cy.visit(constants.transactionsMessagesUrl + staticSafes.SEP_STATIC_SAFE_10) - }) - it('Verify confirmation window is displayed for unsigned message', () => { cy.visit(constants.transactionsMessagesUrl + staticSafes.SEP_STATIC_SAFE_26) wallet.connectSigner(signer2) messages.clickOnMessageSignBtn(0) msg_confirmation_modal.verifyConfirmationWindowTitle(modal.modalTitiles.confirmMsg) - msg_confirmation_modal.verifyMessagePresent(offchainMessage) + msg_confirmation_modal.verifyMessagePresent(messages.offchainMessage) msg_confirmation_modal.clickOnMessageDetails() msg_confirmation_modal.verifyOffchainMessageHash(0) msg_confirmation_modal.verifyOffchainMessageHash(1) msg_confirmation_modal.checkMessageInfobox() }) + + it('Verify summary for off-chain unsigned messages', () => { + cy.visit(constants.transactionsMessagesUrl + staticSafes.SEP_STATIC_SAFE_10) + createTx.verifySummaryByIndex(0, [ + typeMessagesGeneral.sign, + typeMessagesGeneral.oneOftwo, + typeMessagesOffchain.testMessage1, + ]) + createTx.verifySummaryByIndex(2, [ + typeMessagesGeneral.sign, + typeMessagesGeneral.oneOftwo, + typeMessagesOffchain.testMessage2, + ]) + }) + + it('Verify summary for off-chain signed messages', () => { + cy.visit(constants.transactionsMessagesUrl + staticSafes.SEP_STATIC_SAFE_10) + createTx.verifySummaryByIndex(1, [ + typeMessagesGeneral.sign, + typeMessagesGeneral.oneOftwo, + typeMessagesOffchain.name, + ]) + createTx.verifySummaryByIndex(3, [ + typeMessagesGeneral.sign, + typeMessagesGeneral.oneOftwo, + typeMessagesOffchain.testMessage3, + ]) + }) + + it('Verify exapanded details for EIP 191 off-chain message', () => { + cy.visit(constants.transactionsMessagesUrl + staticSafes.SEP_STATIC_SAFE_10) + createTx.clickOnTransactionItemByIndex(2) + cy.contains(typeMessagesOffchain.message2).should('be.visible') + }) + + it('Verify exapanded details for EIP 712 off-chain message', () => { + cy.visit(constants.transactionsMessagesUrl + staticSafes.SEP_STATIC_SAFE_10) + const jsonString = createTx.messageNestedStr + const values = [ + typeMessagesOffchain.name, + typeMessagesOffchain.testStringNested, + typeMessagesOffchain.EIP712Domain, + typeMessagesOffchain.message3, + ] + + createTx.clickOnTransactionItemByIndex(1) + cy.get(createTx.txRowTitle) + .next() + .then(($section) => { + expect($section.text()).to.include(jsonString) + const count = $section.text().split(jsonString).length - 1 + expect(count).to.eq(3) + }) + + main.verifyTextVisibility(values) + }) }) diff --git a/apps/web/cypress/e2e/regression/messages_popup.cy.js b/apps/web/cypress/e2e/regression/messages_popup.cy.js index cfa1aa1ab5..4aebaa4101 100644 --- a/apps/web/cypress/e2e/regression/messages_popup.cy.js +++ b/apps/web/cypress/e2e/regression/messages_popup.cy.js @@ -19,7 +19,7 @@ describe('Messages popup window tests', () => { beforeEach(() => { cy.visit(constants.appsCustomUrl + staticSafes.SEP_STATIC_SAFE_10) - iframeSelector = `iframe[id="iframe-${constants.safeTestAppurl}"]` + iframeSelector = `iframe[id="iframe-${encodeURIComponent(constants.safeTestAppurl)}"]` }) it('Verify off-chain message popup window can be triggered', () => { @@ -55,7 +55,8 @@ describe('Messages popup window tests', () => { apps.clickOnApp(safeApp) apps.clickOnOpenSafeAppBtn() main.getIframeBody(iframeSelector).within(() => { - messages.enterOnchainMessage(onchainMessage) + apps.enterMessage(onchainMessage) + //messages.enterOnchainMessage(onchainMessage) apps.triggetOnChainTx() }) msg_confirmation_modal.verifyConfirmationWindowTitle(modal.modalTitiles.confirmMsg) diff --git a/apps/web/cypress/e2e/regression/multichain_create_safe.cy.js b/apps/web/cypress/e2e/regression/multichain_create_safe.cy.js index cf74dc4595..87432dc611 100644 --- a/apps/web/cypress/e2e/regression/multichain_create_safe.cy.js +++ b/apps/web/cypress/e2e/regression/multichain_create_safe.cy.js @@ -23,7 +23,7 @@ describe('Multichain safe creation tests', () => { createwallet.selectMultiNetwork(1, constants.networks.polygon.toLowerCase()) createwallet.clickOnNextBtn() createwallet.clickOnNextBtn() - main.verifyElementsCount(createwallet.payNowExecMethod, 0) + main.verifyElementsCount(createtx.payNowExecMethod, 0) }) it('Verify that Pay now is available for single safe creation', () => { diff --git a/apps/web/cypress/e2e/regression/multichain_networkswitch.cy.js b/apps/web/cypress/e2e/regression/multichain_networkswitch.cy.js index b06c135d0a..d387b8f8e6 100644 --- a/apps/web/cypress/e2e/regression/multichain_networkswitch.cy.js +++ b/apps/web/cypress/e2e/regression/multichain_networkswitch.cy.js @@ -14,7 +14,7 @@ const signer = walletCredentials.OWNER_4_PRIVATE_KEY // DO NOT use OWNER_2_PRIVATE_KEY for safe creation. Used for CF safes. const signer2 = walletCredentials.OWNER_2_PRIVATE_KEY -describe('Multichain header network switch tests', { defaultCommandTimeout: 60000 }, () => { +describe('Multichain header network switch tests', { defaultCommandTimeout: 30000 }, () => { before(async () => { staticSafes = await getSafes(CATEGORIES.static) }) @@ -26,7 +26,7 @@ describe('Multichain header network switch tests', { defaultCommandTimeout: 6000 main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.multichain) }) - it('Verify the list of networks where the safe is already deployed with the same address', () => { + it('Verify the list of networks where the safe is already deployed with the same address when all networks added', () => { let safe = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'sep') cy.visit(constants.BALANCE_URL + safe) wallet.connectSigner(signer) @@ -36,8 +36,6 @@ describe('Multichain header network switch tests', { defaultCommandTimeout: 6000 sideBar.checkUndeployedSafeExists(0) navigation.clickOnModalCloseBtn(0) create_wallet.openNetworkSelector() - sideBar.clickOnShowAllNetworksBtn() - sideBar.checkNetworkPresence([constants.networks.gnosis], sideBar.addNetworkOption) sideBar.checkNetworkPresence( [constants.networks.ethereum, constants.networks.polygon, constants.networks.sepolia], sideBar.addedNetworkOption, @@ -49,7 +47,7 @@ describe('Multichain header network switch tests', { defaultCommandTimeout: 6000 cy.visit(constants.BALANCE_URL + safe) create_wallet.openNetworkSelector() sideBar.clickOnShowAllNetworksBtn() - sideBar.checkNetworkPresence([constants.networks.gnosis], sideBar.addNetworkOption).click() + sideBar.checkNetworkPresence([constants.networks.ethereum], sideBar.addNetworkOption).click() sideBar.checkNetworkIsNotEditable() }) @@ -58,8 +56,8 @@ describe('Multichain header network switch tests', { defaultCommandTimeout: 6000 cy.visit(constants.BALANCE_URL + safe) create_wallet.openNetworkSelector() sideBar.clickOnShowAllNetworksBtn() - sideBar.checkNetworkPresence([constants.networks.gnosis, constants.networks.ethereum], sideBar.addNetworkOption) - main.verifyElementsCount(sideBar.addNetworkOption, 4) + sideBar.checkNetworkPresence([constants.networks.ethereum], sideBar.addNetworkOption) + main.verifyMinimumElementsCount(sideBar.addNetworkOption, 1) }) it('Verify that test networks and main networks are splitted', () => { @@ -71,7 +69,7 @@ describe('Multichain header network switch tests', { defaultCommandTimeout: 6000 it('Verify Add network tooltip on hover for available networks in "Show all networks"', () => { create_wallet.openNetworkSelector() sideBar.clickOnShowAllNetworksBtn() - sideBar.checkNetworkPresence([constants.networks.gnosis], sideBar.addNetworkOption).trigger('mouseover') + sideBar.checkNetworkPresence([constants.networks.ethereum], sideBar.addNetworkOption).trigger('mouseover') main.verifyElementsExist([sideBar.addNetworkTooltip]) }) diff --git a/apps/web/cypress/e2e/regression/multichain_setup.cy.js b/apps/web/cypress/e2e/regression/multichain_setup.cy.js index 4178f18cbc..9d761ea9e1 100644 --- a/apps/web/cypress/e2e/regression/multichain_setup.cy.js +++ b/apps/web/cypress/e2e/regression/multichain_setup.cy.js @@ -39,7 +39,7 @@ describe('Multichain setup tests', { defaultCommandTimeout: 60000 }, () => { main.verifyElementsCount(navigation.newTxBtn, 0) main.verifyElementsCount(create_wallet.activateAccountBtn, 2) cy.visit(constants.setupUrl + safe) - owner.verifyAddOwnerBtnIsDisabled() + owner.verifyManageSignersBtnIsDisabled() sideBar.verifyNavItemDisabled(sideBar.sideBarListItems[4]) sideBar.verifyNavItemDisabled(sideBar.sideBarListItems[6]) }) @@ -54,17 +54,10 @@ describe('Multichain setup tests', { defaultCommandTimeout: 60000 }, () => { it('Verify warning on add owner for one safe in the group', () => { cy.visit(constants.setupUrl + staticSafes.MATIC_STATIC_SAFE_28) - owner.openAddOwnerWindow() - owner.typeOwnerAddress(constants.SEPOLIA_OWNER_2) - owner.clickOnNextBtn() - sideBar.checkInconsistentSignersMsgDisplayedConfirmTxView(constants.networks.polygon) - }) - - it('Verify warning on add owner for one safe in the group', () => { - cy.visit(constants.setupUrl + staticSafes.MATIC_STATIC_SAFE_28) - owner.openAddOwnerWindow() - owner.typeOwnerAddress(constants.SEPOLIA_OWNER_2) - owner.clickOnNextBtn() + owner.openManageSignersWindow() + owner.clickOnAddSignerBtn() + owner.typeOwnerAddressManage(1, constants.SEPOLIA_OWNER_2) + owner.clickOnNextBtnManage() sideBar.checkInconsistentSignersMsgDisplayedConfirmTxView(constants.networks.polygon) }) diff --git a/apps/web/cypress/e2e/regression/nested_safes.cy.js b/apps/web/cypress/e2e/regression/nested_safes.cy.js new file mode 100644 index 0000000000..aef12af1e4 --- /dev/null +++ b/apps/web/cypress/e2e/regression/nested_safes.cy.js @@ -0,0 +1,63 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as sideBar from '../pages/sidebar.pages.js' +import * as nsafes from '../pages/nestedsafes.pages.js' +import * as ls from '../../support/localstorage_data.js' +import * as txs from '../pages/transactions.page.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as createTx from '../pages/create_tx.pages.js' +import { checkExistingSignerCount, checkExistingSignerAddress } from '../pages/owners.pages.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +const mainSafe = 'Main nested safe' +const nestedSafe1 = 'Nested safe1' +const nestedSafe2 = 'Nested safe2' +const nestedSafe1Short = '0x22e5...Cf9d' +const nestedSafe2Short = '0xE557...2208' + +describe('Nested safes basic flow tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify nested safes preserve correct structure', () => { + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.nestedsafes) + + cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_39) + // Check nested safe 1 + sideBar.clickOnOpenNestedSafeListBtn() + sideBar.checkSafesInPopverList([nestedSafe1Short]) + sideBar.clickOnSafeInPopover(nestedSafe1Short) + cy.url().should('include', staticSafes.SEP_STATIC_SAFE_40.substring(4)) + sideBar.checkParentSafeInBreadcrumb(mainSafe, staticSafes.SEP_STATIC_SAFE_39.substring(4)) + sideBar.checkNestedSafeInBreadcrumb(nestedSafe1) + + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_40) + checkExistingSignerCount(1) + checkExistingSignerAddress(0, staticSafes.SEP_STATIC_SAFE_39.substring(4)) + + // Check nested safe 2 + sideBar.clickOnOpenNestedSafeListBtn() + sideBar.checkSafesInPopverList([nestedSafe2Short]) + sideBar.clickOnSafeInPopover(nestedSafe2Short) + cy.url().should('include', staticSafes.SEP_STATIC_SAFE_41.substring(4)) + sideBar.checkParentSafeInBreadcrumb(nestedSafe1, staticSafes.SEP_STATIC_SAFE_40.substring(4)) + sideBar.checkNestedSafeInBreadcrumb(nestedSafe2) + + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_41) + checkExistingSignerCount(1) + checkExistingSignerAddress(0, staticSafes.SEP_STATIC_SAFE_40.substring(4)) + + // Go to nested safe 1 + sideBar.clickOnParentSafeInBreadcrumb() + cy.url().should('include', staticSafes.SEP_STATIC_SAFE_40.substring(4)) + + // Go to main safe + sideBar.clickOnParentSafeInBreadcrumb() + cy.url().should('include', staticSafes.SEP_STATIC_SAFE_39.substring(4)) + }) +}) diff --git a/apps/web/cypress/e2e/regression/nested_safes_fund_asset.cy.js b/apps/web/cypress/e2e/regression/nested_safes_fund_asset.cy.js new file mode 100644 index 0000000000..9d0f90131d --- /dev/null +++ b/apps/web/cypress/e2e/regression/nested_safes_fund_asset.cy.js @@ -0,0 +1,67 @@ +import * as constants from '../../support/constants.js' +import * as sideBar from '../pages/sidebar.pages.js' +import * as nsafes from '../pages/nestedsafes.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as owner from '../pages/owners.pages.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Nested safes fund asset tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.transactionQueueUrl + staticSafes.SEP_STATIC_SAFE_45) + wallet.connectSigner(signer) + sideBar.clickOnOpenNestedSafeListBtn() + nsafes.clickOnAddNestedSafeBtn() + }) + + it('Verify that the token can be selected from the drop-down', () => { + nsafes.clickOnFundAssetBtn() + nsafes.setMaxAmountValue(0) + nsafes.clickOnFundAssetBtn() + nsafes.selectToken(1, constants.tokenNames.cow) + nsafes.setMaxAmountValue(1) + nsafes.verifyMaxAmount(1, constants.tokenNames.cow, constants.tokenAbbreviation.cow) + }) + + it('Verify that the same token can not be selected a few times', () => { + nsafes.clickOnFundAssetBtn() + nsafes.selectToken(0, constants.tokenNames.sepoliaEther) + nsafes.clickOnFundAssetBtn() + nsafes.getTokenList(1).then((tokens) => { + expect(tokens).to.not.include(constants.tokenNames.sepoliaEther) + }) + }) + + it('Verify that the erorr appears if entered amount> available amount of the token', () => { + nsafes.clickOnFundAssetBtn() + nsafes.setSendValue(0, 0.1) + owner.verifyErrorMsgInvalidAddress(constants.amountErrorMsg.largerThanCurrentBalance) + }) + + it('Verify that click on Max adds all available token amount', () => { + nsafes.clickOnFundAssetBtn() + nsafes.setMaxAmountValue(0) + nsafes.verifyMaxAmount(0, constants.tokenNames.sepoliaEther, constants.tokenAbbreviation.sep) + }) + + it('Verify that delete icon removes one line of Fund new asset', () => { + nsafes.clickOnFundAssetBtn() + nsafes.setMaxAmountValue(0) + nsafes.clickOnFundAssetBtn() + nsafes.getAssetCount().then((count) => { + expect(count).to.equal(2) + }) + + nsafes.removeAsset(1) + nsafes.getAssetCount().then((count) => { + expect(count).to.equal(1) + }) + }) +}) diff --git a/apps/web/cypress/e2e/regression/nested_safes_review.cy.js b/apps/web/cypress/e2e/regression/nested_safes_review.cy.js new file mode 100644 index 0000000000..3848845f5a --- /dev/null +++ b/apps/web/cypress/e2e/regression/nested_safes_review.cy.js @@ -0,0 +1,40 @@ +import * as constants from '../../support/constants.js' +import * as sideBar from '../pages/sidebar.pages.js' +import * as nsafes from '../pages/nestedsafes.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as createTx from '../pages/create_tx.pages.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +describe('Nested safes review step tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.transactionQueueUrl + staticSafes.SEP_STATIC_SAFE_45) + wallet.connectSigner(signer) + sideBar.clickOnOpenNestedSafeListBtn() + nsafes.clickOnAddNestedSafeBtn() + }) + + it('Verify middle step with Fund new assets in create nestedsafe tx flow', () => { + nsafes.clickOnFundAssetBtn() + nsafes.setMaxAmountValue(0) + nsafes.clickOnAddNextBtn() + nsafes.actionsExist(nsafes.fundAssetsActions) + createTx.clickOnAdvancedDetails() + createTx.verifytxAccordionDetails(createTx.MultisendData) + }) + + it('Verify review step without Fund new assets in create nestedsafe tx flow', () => { + nsafes.clickOnAddNextBtn() + nsafes.clickOnAdvancedDetails() + createTx.clickOnNoLaterOption() + nsafes.actionsExist(nsafes.nonfundAssetsActions) + //nsafes.checkAddTobatchBtnStatus(constants.enabledStates.enabled) - The button is commented out until we have a final decision about its position. + }) +}) diff --git a/apps/web/cypress/e2e/regression/nfts.cy.js b/apps/web/cypress/e2e/regression/nfts.cy.js index fb848826f8..09d2ac173f 100644 --- a/apps/web/cypress/e2e/regression/nfts.cy.js +++ b/apps/web/cypress/e2e/regression/nfts.cy.js @@ -95,6 +95,8 @@ describe('NFTs tests', () => { nfts.typeRecipientAddress(staticSafes.SEP_STATIC_SAFE_1) createTx.changeNonce(2) nfts.clikOnNextBtn() + createTx.clickOnContinueSignTransactionBtn() + createTx.clickOnAcknowledgement() createTx.clickOnSignTransactionBtn() createTx.waitForProposeRequest() createTx.clickViewTransaction() diff --git a/apps/web/cypress/e2e/regression/nfts_2.cy.js b/apps/web/cypress/e2e/regression/nfts_2.cy.js new file mode 100644 index 0000000000..88488ca35d --- /dev/null +++ b/apps/web/cypress/e2e/regression/nfts_2.cy.js @@ -0,0 +1,34 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as nfts from '../pages/nfts.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' + +let staticSafes = [] + +const nftsName = 'CatFactory' +const nftsAddress = '0x373B...866c' +const nftsTokenID = 'CF' + +describe('NFTs 2 tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.balanceNftsUrl + staticSafes.SEP_STATIC_SAFE_2) + nfts.waitForNftItems(2) + }) + + it('Verify that NFTs exist in the table', () => { + nfts.verifyNFTNumber(10) + }) + + it('Verify NFT row contains data', () => { + nfts.verifyDataInTable(nftsName, nftsAddress, nftsTokenID) + }) + + it('Verify NFT open does not open if no NFT exits', () => { + nfts.clickOnInactiveNFT() + nfts.verifyNFTModalDoesNotExist() + }) +}) diff --git a/apps/web/cypress/e2e/regression/notifications.cy.js b/apps/web/cypress/e2e/regression/notifications.cy.js new file mode 100644 index 0000000000..2902f74284 --- /dev/null +++ b/apps/web/cypress/e2e/regression/notifications.cy.js @@ -0,0 +1,46 @@ +import * as constants from '../../support/constants.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as header from '../pages/header.page.js' +import * as notifications from '../pages/notifications.page.js' + +let staticSafes = [] + +describe('Notifications UI tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + const views = [ + constants.BALANCE_URL, + constants.setupUrl, + constants.swapUrl, + constants.createNewSafeSepoliaUrl, + constants.loadNewSafeSepoliaUrl, + constants.transactionUrl, + constants.transactionsHistoryUrl, + constants.securityUrl, + constants.homeUrl, + + constants.balanceNftsUrl, + constants.welcomeAccountsSepoliaUrl, + constants.addressBookUrl, + constants.appsUrlGeneral, + constants.welcomeSepoliaUrl, + constants.transactionQueueUrl, + constants.transactionsMessagesUrl, + constants.modulesUrl, + constants.appsCustomUrl, + constants.securityUrl, + constants.appearanceSettingsUrl, + constants.dataSettingsUrl, + constants.notificationsUrl, + ] + + views.forEach((link) => { + it(`Verify clicking on notifications center opens notifications modal in view: ${link}`, () => { + cy.visit(link + staticSafes.SEP_STATIC_SAFE_4) + header.openNotificationCenter() + notifications.checkCoreElementsVisible() + }) + }) +}) diff --git a/apps/web/cypress/e2e/regression/proposers_2.cy.js b/apps/web/cypress/e2e/regression/proposers_2.cy.js index 794d4fe54b..4af9a877ab 100644 --- a/apps/web/cypress/e2e/regression/proposers_2.cy.js +++ b/apps/web/cypress/e2e/regression/proposers_2.cy.js @@ -21,21 +21,6 @@ describe('Proposers 2 tests', () => { staticSafes = await getSafes(CATEGORIES.static) }) - it('Verify that an owner that is also a proposer can still execute transactions', () => { - cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_32) - cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) - wallet.connectSigner(signer) - createtx.clickOnNewtransactionBtn() - createtx.clickOnSendTokensBtn() - createtx.typeRecipientAddress(getMockAddress()) - createtx.setSendValue(sendValue) - createtx.clickOnNextBtn() - tx.selectExecuteNow() - createtx.verifySubmitBtnIsEnabled() - tx.selectExecuteLater() - tx.verifySignBtnEnabled() - }) - it('Verify a proposers is capable of propose transactions', () => { cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_33) cy.contains(owner.safeAccountNonceStr, { timeout: 10000 }) diff --git a/apps/web/cypress/e2e/regression/recovery.cy.js b/apps/web/cypress/e2e/regression/recovery.cy.js index 311ac9f303..5b80fd9d0d 100644 --- a/apps/web/cypress/e2e/regression/recovery.cy.js +++ b/apps/web/cypress/e2e/regression/recovery.cy.js @@ -90,6 +90,7 @@ describe('Recovery regression tests', { defaultCommandTimeout: 50000 }, () => { main.acceptCookies() recovery.clickOnRecoverLaterBtn() cy.reload() + owner.waitForConnectionStatus() recovery.verifyRecoveryProposalModalState(constants.elementExistanceStates.not_exist) navigation.clickOnWalletExpandMoreIcon() navigation.clickOnDisconnectBtn() diff --git a/apps/web/cypress/e2e/regression/remove_owner.cy.js b/apps/web/cypress/e2e/regression/remove_owner.cy.js index 928684d574..02b0b051b3 100644 --- a/apps/web/cypress/e2e/regression/remove_owner.cy.js +++ b/apps/web/cypress/e2e/regression/remove_owner.cy.js @@ -58,6 +58,7 @@ describe('Remove Owners tests', () => { owner.getAddressToBeRemoved() owner.verifyOwnerDeletionWindowDisplayed() createTx.changeNonce(10) + createTx.clickOnContinueSignTransactionBtn() createTx.clickOnSignTransactionBtn() createTx.waitForProposeRequest() createTx.clickViewTransaction() diff --git a/apps/web/cypress/e2e/regression/replace_owner.cy.js b/apps/web/cypress/e2e/regression/replace_owner.cy.js index c9355ede96..3f60e92a09 100644 --- a/apps/web/cypress/e2e/regression/replace_owner.cy.js +++ b/apps/web/cypress/e2e/regression/replace_owner.cy.js @@ -95,6 +95,7 @@ describe('Replace Owners tests', () => { owner.typeOwnerAddress(constants.SEPOLIA_OWNER_2) createTx.changeNonce(0) owner.clickOnNextBtn() + createTx.clickOnContinueSignTransactionBtn() createTx.clickOnSignTransactionBtn() createTx.clickViewTransaction() createTx.verifyReplacedSigner(ownerName) diff --git a/apps/web/cypress/e2e/regression/sidebar.cy.js b/apps/web/cypress/e2e/regression/sidebar.cy.js index b973725dc2..3f89ed1c73 100644 --- a/apps/web/cypress/e2e/regression/sidebar.cy.js +++ b/apps/web/cypress/e2e/regression/sidebar.cy.js @@ -51,7 +51,7 @@ describe('Sidebar tests', () => { // Added to prod it('Verify New Transaction button disabled for non-owners', () => { - main.verifyElementsCount(navigation.newTxBtn, 0) + sideBar.verifyNewTxBtnStatus(constants.enabledStates.disabled) }) it('Verify the side menu buttons exist', () => { diff --git a/apps/web/cypress/e2e/regression/sidebar_2.cy.js b/apps/web/cypress/e2e/regression/sidebar_2.cy.js index 3800e48b02..1fb5ac39f7 100644 --- a/apps/web/cypress/e2e/regression/sidebar_2.cy.js +++ b/apps/web/cypress/e2e/regression/sidebar_2.cy.js @@ -38,13 +38,6 @@ describe('Sidebar added sidebar tests', () => { sideBar.verifySafeNameExists(newSafeName) }) - // TODO: Waiting for new tests due to changed functionality - it.skip('Verify a safe can be removed', () => { - sideBar.openSidebar() - sideBar.removeSafeItem(addedSafe900) - sideBar.verifySafeRemoved([addedSafe900]) - }) - it('Verify Fiat currency changes when edited in the assets tab', () => { assets.changeCurrency(assets.currencyCAD) sideBar.checkCurrencyInHeader(assets.currency$) diff --git a/apps/web/cypress/e2e/regression/sidebar_5.cy.js b/apps/web/cypress/e2e/regression/sidebar_5.cy.js index 29f00f128e..650827cdb1 100644 --- a/apps/web/cypress/e2e/regression/sidebar_5.cy.js +++ b/apps/web/cypress/e2e/regression/sidebar_5.cy.js @@ -56,7 +56,7 @@ describe('Sidebar search tests', () => { sideBar.clickOnOpenSidebarBtn() sideBar.searchSafe(sideBar.sideBarSafes.multichain_short_) sideBar.checkMultichainSubSafeExists([ - constants.networks.gnosis, + // constants.networks.gnosis, constants.networks.ethereum, constants.networks.sepolia, ]) @@ -88,6 +88,6 @@ describe('Sidebar search tests', () => { sideBar.checkSearchResults(1) sideBar.clearSearchInput() sideBar.showAllSafes() - sideBar.verifyAccountListSafeCount(6) + sideBar.verifyAccountListSafeCount(5) }) }) diff --git a/apps/web/cypress/e2e/regression/sidebar_6.cy.js b/apps/web/cypress/e2e/regression/sidebar_6.cy.js index 872dfd8cbd..fadf44f50a 100644 --- a/apps/web/cypress/e2e/regression/sidebar_6.cy.js +++ b/apps/web/cypress/e2e/regression/sidebar_6.cy.js @@ -19,7 +19,8 @@ describe('Sidebar sorting tests', () => { staticSafes = await getSafes(CATEGORIES.static) }) - it('Verify the same safe of the different networks is ordered by most recent', () => { + // Unskip when chains are available + it.skip('Verify the same safe of the different networks is ordered by most recent', () => { let safe_eth = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'eth') let safe_gno = main.changeSafeChainName(staticSafes.MATIC_STATIC_SAFE_28, 'gno') cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) @@ -37,7 +38,8 @@ describe('Sidebar sorting tests', () => { sideBar.verifyAddedSafesExistByIndex(2, constants.networks.ethereum) }) - it('Verify the same safe of the different networks is ordered by name', () => { + // Unskip when chains are available + it.skip('Verify the same safe of the different networks is ordered by name', () => { main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.undeployedSet) main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safes2) cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) @@ -46,7 +48,7 @@ describe('Sidebar sorting tests', () => { sideBar.clickOnOpenSidebarBtn() sideBar.searchSafe('96') - sideBar.verifySafeCount(3) + sideBar.verifySafeCount(1) sideBar.expandGroupSafes(0) sideBar.openSortOptionsMenu() sideBar.selectSortOption(sideBar.sortOptions.name) @@ -77,10 +79,10 @@ describe('Sidebar sorting tests', () => { sideBar.openSortOptionsMenu() sideBar.selectSortOption(sideBar.sortOptions.name) - sideBar.verifyAddedSafesExistByIndex(0, safe14) - sideBar.verifyAddedSafesExistByIndex(1, safe15) - sideBar.selectSortOption(sideBar.sortOptions.lastVisited) - sideBar.verifyAddedSafesExistByIndex(0, safe15) sideBar.verifyAddedSafesExistByIndex(1, safe14) + sideBar.verifyAddedSafesExistByIndex(2, safe15) + sideBar.selectSortOption(sideBar.sortOptions.lastVisited) + sideBar.verifyAddedSafesExistByIndex(1, safe15) + sideBar.verifyAddedSafesExistByIndex(2, safe14) }) }) diff --git a/apps/web/cypress/e2e/regression/sidebar_8.cy.js b/apps/web/cypress/e2e/regression/sidebar_8.cy.js new file mode 100644 index 0000000000..86ea7b5221 --- /dev/null +++ b/apps/web/cypress/e2e/regression/sidebar_8.cy.js @@ -0,0 +1,117 @@ +import * as constants from '../../support/constants.js' +import * as sideBar from '../pages/sidebar.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as main from '../pages/main.page.js' +import * as ls from '../../support/localstorage_data.js' +import * as owner from '../pages/owners.pages.js' +import * as navigation from '../pages/navigation.page.js' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY +const signer2 = walletCredentials.OWNER_3_PRIVATE_KEY +const signer3 = walletCredentials.OWNER_1_PRIVATE_KEY + +const currentSafe = '0x9870...fec0' +const currentSafe2 = '0x5912...fFdb' +const multiChainSafe = 'matic:0xC96e...ee3B' + +describe('Sidebar tests 8', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify that the current safe is under the "Current safe account" section', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + sideBar.openSidebar() + sideBar.verifyCurrentSafe(currentSafe) + }) + + it('Verify that for multichain safes, only the safe of the current network is listed in "Current safe acount"', () => { + wallet.connectSigner(signer) + cy.visit(constants.BALANCE_URL + staticSafes.MATIC_STATIC_SAFE_28) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + sideBar.openSidebar() + sideBar.verifyCurrentSafe(multiChainSafe) + }) + + it('Verify that pinning a safe removes "Current safe account" section', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + sideBar.openSidebar() + sideBar.clickOnBookmarkBtn(currentSafe) + sideBar.verifyCurrentSafeDoesNotExist() + }) + + it('Verify the "Not activated" tag for Counterfactual safes', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_9) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__undeployedSafes, ls.undeployedSafe.safe1) + wallet.connectSigner(signer) + sideBar.openSidebar() + sideBar.verifyAccountListSafeData([sideBar.notActivatedStr]) + }) + + it('Verify the "Add another network" shows only for owners of a safe', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_7) + wallet.connectSigner(signer2) + sideBar.openSidebar() + sideBar.clickOnSafeItemOptionsBtn(currentSafe2) + sideBar.checkAddChainDialogDisplayed() + owner.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify the "Add another network" is not displated for non-owners', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_7) + wallet.connectSigner(signer3) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + sideBar.openSidebar() + sideBar.clickOnSafeItemOptionsBtn(currentSafe2) + main.verifyElementsCount(sideBar.safeItemOptionsAddChainBtn, 0) + owner.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify New Transaction button is enabled for proposers', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_31) + wallet.connectSigner(signer3) + navigation.verifyTxBtnStatus(constants.enabledStates.enabled) + owner.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify "Add read-only" button replaces the "New transaction" button for disconnected users', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_7) + navigation.verifyTxBtnStatus(constants.enabledStates.disabled) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + sideBar.openSidebar() + sideBar.verifyCurrentSafeReadOnly(1) + }) + + it('Verify "Add read-only" button replaces the "New transaction" button for connected non-owners', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_7) + wallet.connectSigner(signer3) + navigation.verifyTxBtnStatus(constants.enabledStates.disabled) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + sideBar.openSidebar() + sideBar.verifyCurrentSafeReadOnly(1) + owner.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify Add safe button takes the user to the "Safe load" flow', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_7) + navigation.verifyTxBtnStatus(constants.enabledStates.disabled) + cy.intercept('GET', constants.safeListEndpoint, { 1: [], 100: [], 137: [], 11155111: [] }) + sideBar.openSidebar() + sideBar.clickOnAddSafeBtn() + }) + + it.only('Verify "blockchain sync" status is shown at the bottom pointing to the network statuses', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_7) + sideBar.verifyIndexStatusPresent() + }) +}) diff --git a/apps/web/cypress/e2e/regression/sidebar_9.cy.js b/apps/web/cypress/e2e/regression/sidebar_9.cy.js new file mode 100644 index 0000000000..18b1e27237 --- /dev/null +++ b/apps/web/cypress/e2e/regression/sidebar_9.cy.js @@ -0,0 +1,74 @@ +import * as constants from '../../support/constants.js' +import * as sideBar from '../pages/sidebar.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as swaps from '../pages/swaps.pages.js' +import * as main from '../pages/main.page.js' +import { exchangeStr, clickOnBridgeOption } from '../pages/bridge.pages.js' + +let staticSafes = [] + +describe('Sidebar UI tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + const views = [ + constants.appsUrlGeneral, + constants.homeUrl, + constants.appsCustomUrl, + constants.securityUrl, + constants.transactionUrl, + constants.transactionQueueUrl, + constants.transactionsMessagesUrl, + constants.transactionsHistoryUrl, + constants.swapUrl, + constants.setupUrl, + constants.notificationsUrl, + constants.modulesUrl, + constants.dataSettingsUrl, + constants.appearanceSettingsUrl, + constants.balanceNftsUrl, + constants.BALANCE_URL, + ] + views.forEach((link) => { + it(`Verify sidebar copy address button copies address in view: ${link}`, () => { + cy.visit(link + staticSafes.SEP_STATIC_SAFE_4) + cy.get(sideBar.copyAddressBtn).should('be.visible').should('be.enabled') + cy.wait(2000) + sideBar.clickOnCopyAddressBtn(staticSafes.SEP_STATIC_SAFE_4.substring(4)) + }) + }) + + views.forEach((link) => { + it(`Verify that what's new button is present in view: ${link}`, () => { + cy.visit(link + staticSafes.SEP_STATIC_SAFE_4) + sideBar.whatsNewBtnIsVisible() + }) + }) + + views.forEach((link) => { + it(`Verify that Need Help button contains help link in view: ${link}`, () => { + cy.visit(link + staticSafes.SEP_STATIC_SAFE_4) + sideBar.checkNeedHelpBtnLink() + }) + }) + + views.forEach((link) => { + it(`Verify that clicking on Bridge opens exchange modal in view: ${link}`, () => { + let iframeSelector = `iframe[src*="${constants.bridgeWidget}"]` + cy.visit(link + staticSafes.SEP_STATIC_SAFE_4) + clickOnBridgeOption() + swaps.acceptLegalDisclaimer() + // Wait for iframe to be present and visible + cy.get(iframeSelector).should('be.visible') + cy.wait(2000) // Add delay for iframe to load + + // Try to access iframe content + cy.get(iframeSelector).then(($iframe) => { + const $body = $iframe.contents().find('body') + cy.wrap($body).should('exist') + cy.wrap($body).contains(exchangeStr).should('be.visible') + }) + }) + }) +}) diff --git a/apps/web/cypress/e2e/regression/sidebar_nonowner.cy.js b/apps/web/cypress/e2e/regression/sidebar_nonowner.cy.js index 3104c1174f..e26c65b508 100644 --- a/apps/web/cypress/e2e/regression/sidebar_nonowner.cy.js +++ b/apps/web/cypress/e2e/regression/sidebar_nonowner.cy.js @@ -30,10 +30,9 @@ describe('Sidebar non-owner tests', () => { navigation.verifyTxBtnStatus(constants.enabledStates.enabled) }) - // TODOD: Waiting for endpoint from CGW - it.skip('Verify tag counting queue tx show for owners and non-owners', () => { + it('Verify tag counting queue tx show for owners and non-owners', () => { sideBar.openSidebar() - sideBar.verifyQueuedTx(addedOwner).contains(2) - sideBar.verifyQueuedTx(addedNonowner).contains(2) + sideBar.verifySafeIconData(addedOwner).contains('2/2') + sideBar.verifySafeIconData(addedNonowner).contains('2/2') }) }) diff --git a/apps/web/cypress/e2e/regression/spaces_basicflow.cy.js b/apps/web/cypress/e2e/regression/spaces_basicflow.cy.js new file mode 100644 index 0000000000..a49f6f825d --- /dev/null +++ b/apps/web/cypress/e2e/regression/spaces_basicflow.cy.js @@ -0,0 +1,71 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as space from '../pages/spaces.page.js' +import * as navigation from '../pages/navigation.page' + +let staticSafes = [] +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const admin = walletCredentials.OWNER_4_PRIVATE_KEY +const user = walletCredentials.OWNER_3_PRIVATE_KEY +const user_address = walletCredentials.OWNER_3_WALLET_ADDRESS + +describe('Spaces basic flow tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.spacesUrl) + }) + + it('Verify a user can sign in, create, rename and delete an organisation', () => { + const spaceName = 'Space_' + Math.random().toString(36).substring(2, 12) + const newSpaceName = 'Renamed Space' + + wallet.connectSigner(admin) + space.clickOnSignInBtn() + space.deleteAllSpaces() + main.verifyElementsIsVisible([space.createSpaceBtn]) + space.createSpace(spaceName) + space.clickOnSpaceSelector(spaceName) + space.spaceExists(spaceName) + space.goToSpaceSettings() + space.editSpace(newSpaceName) + space.clickOnSpaceSelector(newSpaceName) + space.spaceExists(newSpaceName) + space.deleteSpace(newSpaceName) + cy.contains(space.deleteSpaceConfirmationMsg(newSpaceName)).should('be.visible') + main.verifyElementsIsVisible([space.createSpaceBtn]) + }) + + it('Verify an account can be added manually', () => { + const spaceName = 'Space_' + Math.random().toString(36).substring(2, 12) + + wallet.connectSigner(admin) + space.clickOnSignInBtn() + space.deleteAllSpaces() + space.createSpace(spaceName) + space.addAccountManually(staticSafes.SEP_STATIC_SAFE_35.substring(4), constants.networks.sepolia) + }) + + it('Verify a new member can be invited and accept the invite', () => { + const spaceName = 'Space_' + Math.random().toString(36).substring(2, 12) + const memberName = 'Member_' + Math.random().toString(36).substring(2, 12) + const newInviteName = 'Invited_memeber_' + Math.random().toString(36).substring(2, 12) + + wallet.connectSigner(admin) + space.clickOnSignInBtn() + space.deleteAllSpaces() + space.createSpace(spaceName) + space.goToSpaceMembers() + space.addMember(memberName, user_address) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + wallet.connectSigner(user) + space.clickOnSignInBtn() + cy.reload() // Required to trigger the invite + space.acceptInvite(newInviteName) + }) +}) diff --git a/apps/web/cypress/e2e/regression/spending_limits_nonowner.cy.js b/apps/web/cypress/e2e/regression/spending_limits_nonowner.cy.js index d6fe2ede2d..6eb487c658 100644 --- a/apps/web/cypress/e2e/regression/spending_limits_nonowner.cy.js +++ b/apps/web/cypress/e2e/regression/spending_limits_nonowner.cy.js @@ -16,7 +16,7 @@ describe('Spending limits non-owner tests', () => { }) it('Verify that where there are no spending limits setup, information images are displayed', () => { - spendinglimit.verifySpendingLimitsIcons + spendinglimit.verifySpendingLimitsIcons() }) it('Verify "New spending limit" button only available for owners', () => { diff --git a/apps/web/cypress/e2e/regression/staking_history.cy.js b/apps/web/cypress/e2e/regression/staking_history.cy.js index 8e4a63ddb0..358f9c3b5f 100644 --- a/apps/web/cypress/e2e/regression/staking_history.cy.js +++ b/apps/web/cypress/e2e/regression/staking_history.cy.js @@ -33,7 +33,7 @@ describe('Staking history tests', { defaultCommandTimeout: 30000 }, () => { staking.checkDataFields(staking.dataFields.netMonthlyRewards, staking.getRewardRegex()) staking.checkDataFields(staking.dataFields.fee, staking.getPercentageRegex()) staking.checkDataFields(staking.dataFields.validators, '1') - staking.checkDataFields(staking.dataFields.activationTime, staking.getActivationTimeRegex()) + staking.checkDataFields(staking.dataFields.activationTime, staking.checkActivationTimeNonEmpty()) staking.checkDataFields(staking.dataFields.rewards, historyData.rewardsValue) }) }) diff --git a/apps/web/cypress/e2e/regression/swaps.cy.js b/apps/web/cypress/e2e/regression/swaps.cy.js index 7b508a85c2..09a45e2de3 100644 --- a/apps/web/cypress/e2e/regression/swaps.cy.js +++ b/apps/web/cypress/e2e/regression/swaps.cy.js @@ -9,6 +9,8 @@ import * as wallet from '../../support/utils/wallet.js' import * as swaps_data from '../../fixtures/swaps_data.json' import * as navigation from '../pages/navigation.page' import { getEvents, events, checkDataLayerEvents } from '../../support/utils/gtag.js' +import { getMockAddress } from '../../support/utils/ethers.js' +import { add } from 'lodash' const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) const signer = walletCredentials.OWNER_4_PRIVATE_KEY @@ -38,6 +40,7 @@ describe('Swaps tests', () => { { defaultCommandTimeout: 30000 }, () => { let isCustomRecipientFound + swaps.getMockQuoteResponse(swaps.quoteResponse.quote1) swaps.acceptLegalDisclaimer() cy.wait(4000) main @@ -54,13 +57,23 @@ describe('Swaps tests', () => { swaps.enableCustomRecipient(isCustomRecipientFound(swaps.customRecipient)) swaps.clickOnSettingsBtn() swaps.enterRecipient(swaps.blockedAddress) + swaps.selectOutputCurrency(swaps.swapTokens.dai) + cy.wait('@mockedQuote').then((interception) => { + expect(interception.response.statusCode).to.eq(200) + cy.log('Intercepted response:', JSON.stringify(interception.response.body)) + }) }) cy.contains(swaps.blockedAddressStr) }, ) it('Verify enabling custom recipient adds that field to the form', { defaultCommandTimeout: 30000 }, () => { + const address = getMockAddress() + const address_ = '0x1234...5678' + + swaps.getMockQuoteResponse(swaps.quoteResponse.quote1) swaps.acceptLegalDisclaimer() + cy.wait(4000) const isCustomRecipientFound = ($frame, customRecipient) => { @@ -83,8 +96,22 @@ describe('Swaps tests', () => { } swaps.clickOnSettingsBtn() - swaps.enterRecipient('1') + swaps.selectOutputCurrency(swaps.swapTokens.dai) + cy.wait('@mockedQuote').then((interception) => { + expect(interception.response.statusCode).to.eq(200) + cy.log('Intercepted response:', JSON.stringify(interception.response.body)) + }) + swaps.enterRecipient(address) + swaps.checkSwapBtnIsVisible() + swaps.isInputGreaterZero(swaps.outputCurrencyInput).then((isGreaterThanZero) => { + cy.wrap(isGreaterThanZero).should('be.true') + }) + swaps.clickOnExceeFeeChkbox() + swaps.clickOnSwapBtn() + swaps.clickOnSwapBtn() + swaps.confirmPriceImpact() }) + cy.contains(address_) }) }) @@ -94,16 +121,19 @@ describe('Swaps tests', () => { const orderID = swaps.getOrderID() const slippage = swaps.getWidgetFee() + swaps.getMockQuoteResponse(swaps.quoteResponse.quote1) swaps.acceptLegalDisclaimer() cy.wait(4000) main.getIframeBody(iframeSelector).within(() => { swaps.selectInputCurrency(swaps.swapTokens.cow) - swaps.clickOnSettingsBtn() - swaps.setSlippage('0.30') - swaps.setExpiry('2') - swaps.clickOnSettingsBtn() swaps.setInputValue(200) swaps.selectOutputCurrency(swaps.swapTokens.dai) + + cy.wait('@mockedQuote').then((interception) => { + expect(interception.response.statusCode).to.eq(200) + cy.log('Intercepted response:', JSON.stringify(interception.response.body)) + }) + swaps.checkSwapBtnIsVisible() swaps.isInputGreaterZero(swaps.outputCurrencyInput).then((isGreaterThanZero) => { cy.wrap(isGreaterThanZero).should('be.true') @@ -111,9 +141,10 @@ describe('Swaps tests', () => { swaps.clickOnExceeFeeChkbox() swaps.clickOnSwapBtn() swaps.clickOnSwapBtn() + swaps.confirmPriceImpact() }) - swaps.verifyOrderDetails(limitPrice, swapOrder.expiry2Mins, slippage, swapOrder.interactWith, orderID, widgetFee) + swaps.verifyOrderDetails(limitPrice, slippage, swapOrder.interactWith, orderID, widgetFee) }) it( @@ -128,14 +159,18 @@ describe('Swaps tests', () => { const element = $frame.find(customRecipient) return element.length > 0 } - + swaps.getMockQuoteResponse(swaps.quoteResponse.quote1) swaps.acceptLegalDisclaimer() cy.wait(4000) main.getIframeBody(iframeSelector).then(($frame) => { cy.wrap($frame).within(() => { swaps.selectInputCurrency(swaps.swapTokens.cow) - swaps.setInputValue(200) + swaps.setInputValue(1000) swaps.selectOutputCurrency(swaps.swapTokens.dai) + cy.wait('@mockedQuote').then((interception) => { + expect(interception.response.statusCode).to.eq(200) + cy.log('Intercepted response:', JSON.stringify(interception.response.body)) + }) swaps.checkSwapBtnIsVisible() swaps.clickOnSettingsBtn() @@ -152,9 +187,8 @@ describe('Swaps tests', () => { swaps.enterRecipient(signer2) swaps.clickOnExceeFeeChkbox() swaps.clickOnSwapBtn() - swaps.clickOnSwapBtn() + swaps.verifyRecipientAlertIsDisplayed() }) - swaps.verifyRecipientAlertIsDisplayed() }) }, ) diff --git a/apps/web/cypress/e2e/regression/swaps_2.cy.js b/apps/web/cypress/e2e/regression/swaps_2.cy.js new file mode 100644 index 0000000000..1a8b26a4d7 --- /dev/null +++ b/apps/web/cypress/e2e/regression/swaps_2.cy.js @@ -0,0 +1,81 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as swaps from '../pages/swaps.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +let staticSafes = [] +let iframeSelector + +describe('Swaps 2 tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.swapUrl + staticSafes.SEP_STATIC_SAFE_1) + main.waitForHistoryCallToComplete() + wallet.connectSigner(signer) + iframeSelector = `iframe[src*="${constants.swapWidget}"]` + }) + + it('Verify Setting the top token first in a swap creates a "Sell order" tx', { defaultCommandTimeout: 30000 }, () => { + const value = '200 COW' + swaps.getMockQuoteResponse(swaps.quoteResponse.quote1) + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).within(() => { + swaps.selectInputCurrency(swaps.swapTokens.cow) + swaps.setInputValue(200) + + swaps.selectOutputCurrency(swaps.swapTokens.dai) + cy.wait('@mockedQuote').then((interception) => { + expect(interception.response.statusCode).to.eq(200) + cy.log('Intercepted response:', JSON.stringify(interception.response.body)) + }) + swaps.checkSwapBtnIsVisible() + swaps.isInputGreaterZero(swaps.outputCurrencyInput).then((isGreaterThanZero) => { + cy.wrap(isGreaterThanZero).should('be.true') + }) + swaps.clickOnExceeFeeChkbox() + swaps.clickOnSwapBtn() + swaps.checkInputCurrencyPreviewValue(value) + swaps.clickOnSwapBtn() + }) + swaps.checkTokenBlockValue(0, value) + }) + + it( + 'Verify Setting the bottom token first in a swap creates a "Buy order" tx', + { defaultCommandTimeout: 30000 }, + () => { + const value = swaps.getTokenValue() + const tokenValue = '600' + swaps.getMockQuoteResponse(swaps.quoteResponse.quote2) + swaps.acceptLegalDisclaimer() + cy.wait(4000) + main.getIframeBody(iframeSelector).within(() => { + swaps.selectOutputCurrency(swaps.swapTokens.dai) + swaps.setOutputValue(tokenValue) + swaps.selectInputCurrency(swaps.swapTokens.cow) + cy.wait('@mockedQuote').then((interception) => { + expect(interception.response.statusCode).to.eq(200) + cy.log('Intercepted response:', JSON.stringify(interception.response.body)) + }) + swaps.checkSwapBtnIsVisible() + swaps.isInputGreaterZero(swaps.outputCurrencyInput).then((isGreaterThanZero) => { + cy.wrap(isGreaterThanZero).should('be.true') + }) + + swaps.clickOnSwapBtn() + swaps.checkOutputCurrencyPreviewValue(value) + swaps.clickOnSwapBtn() + swaps.confirmPriceImpact() + }) + swaps.checkTokenBlockValue(1, tokenValue) + }, + ) +}) diff --git a/apps/web/cypress/e2e/regression/swaps_history_2.cy.js b/apps/web/cypress/e2e/regression/swaps_history_2.cy.js index b2930c77f3..b92664e3ac 100644 --- a/apps/web/cypress/e2e/regression/swaps_history_2.cy.js +++ b/apps/web/cypress/e2e/regression/swaps_history_2.cy.js @@ -11,6 +11,8 @@ let staticSafes = [] const swapsHistory = swaps_data.type.history const typeGeneral = data.type.general +const safe = 'sep:0xF184a243925Bf7fb1D64487339FF4F177Fb75644' + describe('Swaps history tests 2', () => { before(async () => { staticSafes = await getSafes(CATEGORIES.static) @@ -46,6 +48,26 @@ describe('Swaps history tests 2', () => { create_tx.verifyAdvancedDetails([swapsHistory.multiSend, swapsHistory.multiSendCallOnly1_3_0]) }) + it('Verify swap operation with 2 actions: wrap & swap', { defaultCommandTimeout: 30000 }, () => { + cy.visit(constants.transactionUrl + safe + swaps.swapTxs.wrapSwap) + const eq = swaps.createRegex(swapsHistory.COWeqWETH, 'COW') + const atLeast = swaps.createRegex(swapsHistory.forAtLeastFullCow, 'COW') + + create_tx.verifyExpandedDetails([ + swapsHistory.sellOrder, + swapsHistory.sell, + eq, + atLeast, + swapsHistory.cow, + swapsHistory.filled, + swapsHistory.actionDepositEth, + swapsHistory.actionApproveEth, + swapsHistory.actionPreSignature, + ]) + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([swapsHistory.multiSend, swapsHistory.multiSendCallOnly1_3_0]) + }) + it('Verify "Cancelled" status for manually cancelled limit orders', { defaultCommandTimeout: 30000 }, () => { const safe = '0x2a73e61bd15b25B6958b4DA3bfc759ca4db249b9' cy.visit(constants.transactionUrl + safe + swaps.swapTxs.sellCancelled) diff --git a/apps/web/cypress/e2e/regression/swaps_queue.cy.js b/apps/web/cypress/e2e/regression/swaps_queue.cy.js new file mode 100644 index 0000000000..51a0883284 --- /dev/null +++ b/apps/web/cypress/e2e/regression/swaps_queue.cy.js @@ -0,0 +1,28 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as create_tx from '../pages/create_tx.pages.js' +import * as swaps_data from '../../fixtures/swaps_data.json' +import * as data from '../../fixtures/txhistory_data_data.json' +import * as swaps from '../pages/swaps.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' + +let staticSafes = [] + +const swapsHistory = swaps_data.type.history +const typeGeneral = data.type.general + +describe('Swaps queue tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify that the swap tx created via Cowswap safe app has decoding in the queue', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.swapQueue) + const dai = swaps.createRegex(swapsHistory.forAtLeastFullDai, 'DAI') + const eq = swaps.createRegex(swapsHistory.DAIeqCOW, 'COW') + main.verifyValuesExist(create_tx.transactionItem, [swapsHistory.title]) + create_tx.verifySummaryByName(swapsHistory.title, null, [typeGeneral.statusExpired]) + main.verifyElementsExist([create_tx.altImgDai, create_tx.altImgCow], create_tx.altImgSwaps) + create_tx.verifyExpandedDetails([swapsHistory.sell100Cow, dai, eq, swapsHistory.dai, swapsHistory.expired]) + }) +}) diff --git a/apps/web/cypress/e2e/regression/tokens.cy.js b/apps/web/cypress/e2e/regression/tokens.cy.js index 1d48f281eb..3c97ea0f94 100644 --- a/apps/web/cypress/e2e/regression/tokens.cy.js +++ b/apps/web/cypress/e2e/regression/tokens.cy.js @@ -11,7 +11,7 @@ const FIAT_AMOUNT_COLUMN = 2 let staticSafes = [] describe('Tokens tests', () => { - const fiatRegex = assets.fiatRegex + const value = '--' before(async () => { staticSafes = await getSafes(CATEGORIES.static) @@ -33,7 +33,7 @@ describe('Tokens tests', () => { assets.currencyDaiFormat_2, TOKEN_AMOUNT_COLUMN, FIAT_AMOUNT_COLUMN, - fiatRegex, + value, ) assets.verifyBalance(assets.currencyAave, TOKEN_AMOUNT_COLUMN, assets.currencyAaveAlttext) @@ -42,7 +42,7 @@ describe('Tokens tests', () => { assets.currentcyAaveFormat, TOKEN_AMOUNT_COLUMN, FIAT_AMOUNT_COLUMN, - fiatRegex, + value, ) assets.verifyBalance(assets.currencyLink, TOKEN_AMOUNT_COLUMN, assets.currencyLinkAlttext) @@ -51,7 +51,7 @@ describe('Tokens tests', () => { assets.currentcyLinkFormat, TOKEN_AMOUNT_COLUMN, FIAT_AMOUNT_COLUMN, - fiatRegex, + value, ) assets.verifyBalance(assets.currencyTestTokenA, TOKEN_AMOUNT_COLUMN, assets.currencyTestTokenAAlttext) @@ -60,7 +60,7 @@ describe('Tokens tests', () => { assets.currentcyTestTokenAFormat, TOKEN_AMOUNT_COLUMN, FIAT_AMOUNT_COLUMN, - fiatRegex, + value, ) assets.verifyBalance(assets.currencyTestTokenB, TOKEN_AMOUNT_COLUMN, assets.currencyTestTokenBAlttext) @@ -69,7 +69,7 @@ describe('Tokens tests', () => { assets.currentcyTestTokenBFormat, TOKEN_AMOUNT_COLUMN, FIAT_AMOUNT_COLUMN, - fiatRegex, + value, ) assets.verifyBalance(assets.currencyUSDC, TOKEN_AMOUNT_COLUMN, assets.currencyTestUSDCAlttext) @@ -78,7 +78,7 @@ describe('Tokens tests', () => { assets.currentcyTestUSDCFormat, TOKEN_AMOUNT_COLUMN, FIAT_AMOUNT_COLUMN, - fiatRegex, + value, ) }) diff --git a/apps/web/cypress/e2e/regression/twaps.cy.js b/apps/web/cypress/e2e/regression/twaps.cy.js index 5b283a8515..4b931e217f 100644 --- a/apps/web/cypress/e2e/regression/twaps.cy.js +++ b/apps/web/cypress/e2e/regression/twaps.cy.js @@ -24,7 +24,7 @@ describe('Twaps tests', { defaultCommandTimeout: 30000 }, () => { it('Verify list of tokens with balances is displayed in the token selector', () => { const tokens = [ { name: swaps.swapTokenNames.eth, balance: '0' }, - { name: swaps.swapTokenNames.cow, balance: '750' }, + { name: swaps.swapTokenNames.cow, balance: '749' }, { name: swaps.swapTokenNames.daiTest, balance: '0' }, { name: swaps.swapTokenNames.gnoTest, balance: '0' }, { name: swaps.swapTokenNames.uni, balance: '0' }, @@ -59,7 +59,7 @@ describe('Twaps tests', { defaultCommandTimeout: 30000 }, () => { swaps.selectInputCurrency(swaps.swapTokens.cow) swaps.setInputValue(500) swaps.selectOutputCurrency(swaps.swapTokens.dai) - swaps.checkTokenBalanceAndValue('input', '750 COW', tokenValue) + swaps.checkTokenBalanceAndValue('input', '749 COW', tokenValue) }) }) @@ -73,7 +73,7 @@ describe('Twaps tests', { defaultCommandTimeout: 30000 }, () => { main.getIframeBody(iframeSelector).within(() => { swaps.selectInputCurrency(swaps.swapTokens.cow) swaps.clickOnMaxBtn() - swaps.checkInputValue('input', '750') + swaps.checkInputValue('input', '749') }) }) }) diff --git a/apps/web/cypress/e2e/regression/twaps_2.cy.js b/apps/web/cypress/e2e/regression/twaps_2.cy.js index c37923b049..99d613a7d3 100644 --- a/apps/web/cypress/e2e/regression/twaps_2.cy.js +++ b/apps/web/cypress/e2e/regression/twaps_2.cy.js @@ -64,7 +64,7 @@ describe('Twaps 2 tests', { defaultCommandTimeout: 30000 }, () => { it( 'Verify entering a blocked address in the custom recipient input blocks the form', - { defaultCommandTimeout: 30000 }, + { defaultCommandTimeout: 60000 }, () => { let isCustomRecipientFound swaps.acceptLegalDisclaimer() @@ -84,9 +84,10 @@ describe('Twaps 2 tests', { defaultCommandTimeout: 30000 }, () => { main.getIframeBody(iframeSelector).within(() => { swaps.selectInputCurrency(swaps.swapTokens.cow) swaps.clickOnSettingsBtnTwaps() - swaps.enableTwapCustomRecipient() + swaps.enableTwapCustomRecipient(isCustomRecipientFound(swaps.customRecipient)) swaps.clickOnSettingsBtnTwaps() swaps.enterRecipient(swaps.blockedAddress) + swaps.selectOutputCurrency(swaps.swapTokens.dai) }) cy.contains(swaps.blockedAddressStr) }, diff --git a/apps/web/cypress/e2e/regression/twaps_history.cy.js b/apps/web/cypress/e2e/regression/twaps_history.cy.js index fc8445dca0..0b3f1a1ea5 100644 --- a/apps/web/cypress/e2e/regression/twaps_history.cy.js +++ b/apps/web/cypress/e2e/regression/twaps_history.cy.js @@ -5,6 +5,7 @@ import * as create_tx from '../pages/create_tx.pages.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' import * as wallet from '../../support/utils/wallet.js' import * as swaps_data from '../../fixtures/swaps_data.json' +import * as data from '../../fixtures/txhistory_data_data.json' const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) const signer = walletCredentials.OWNER_4_PRIVATE_KEY @@ -15,6 +16,7 @@ let iframeSelector const swapsHistory = swaps_data.type.history const swapOrder = swaps_data.type.orderDetails +const typeGeneral = data.type.general describe('Twaps history tests', { defaultCommandTimeout: 30000 }, () => { before(async () => { @@ -32,23 +34,28 @@ describe('Twaps history tests', { defaultCommandTimeout: 30000 }, () => { iframeSelector = `iframe[src*="${constants.swapWidget}"]` swaps.acceptLegalDisclaimer() main.getIframeBody(iframeSelector).within(() => { + cy.wait(20000) // Need more time to load UI swaps.switchToTwap() swaps.selectInputCurrency(swaps.swapTokens.cow) swaps.setInputValue(500) swaps.selectOutputCurrency(swaps.swapTokens.dai) + swaps.outputInputIsNotEmpty() + swaps.confirmPriceImpact() swaps.verifyReviewOrderBtnIsVisible() swaps.getTwapInitialData().then((formData) => { cy.wrap(formData).as('twapFormData') + cy.wait(5000) swaps.clickOnReviewOrderBtn() swaps.placeTwapOrder() + swaps.confirmPriceImpact() }) }) cy.get('@twapFormData').then((formData) => { swaps.checkTwapValuesInReviewScreen(formData) - cy.get('p').contains(swapsHistory.slippage).parent().next().contains(slippage) - cy.get('p').contains(swapsHistory.widget_fee).parent().next().contains(widgetFee) - cy.get('p').contains(swapsHistory.limitPrice).parent().next().contains(limitPrice) + cy.get('[data-testid="slippage"] [data-testid="tx-data-row"]').invoke('text').should('match', slippage) + cy.get('[data-testid="widget-fee"] [data-testid="tx-data-row"]').invoke('text').should('match', widgetFee) + cy.get('[data-testid="limit-price"] [data-testid="tx-data-row"]').invoke('text').should('match', limitPrice) }) }) @@ -92,6 +99,8 @@ describe('Twaps history tests', { defaultCommandTimeout: 30000 }, () => { const buyAmount = swaps.getTokenPrice('DAI') const tokenSoldPrice = swaps.getTokenPrice('WETH') + create_tx.verifySummaryByName(swapsHistory.twaporder_title, null, [typeGeneral.statusOk]) + main.verifyElementsExist([create_tx.altImgDai, create_tx.altImgWeth], create_tx.altImgTwapOrder) create_tx.verifyExpandedDetails([swapsHistory.sell, weth, eq, swapsHistory.dai, swapsHistory.filled]) swaps.checkNumberOfParts(2) swaps.checkSellAmount(sellAmount) diff --git a/apps/web/cypress/e2e/regression/twaps_queue.cy.js b/apps/web/cypress/e2e/regression/twaps_queue.cy.js new file mode 100644 index 0000000000..d7ac39b130 --- /dev/null +++ b/apps/web/cypress/e2e/regression/twaps_queue.cy.js @@ -0,0 +1,37 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as swaps from '../pages/swaps.pages.js' +import * as create_tx from '../pages/create_tx.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as swaps_data from '../../fixtures/swaps_data.json' + +let staticSafes = [] + +const swapsHistory = swaps_data.type.history + +describe('Twaps queue tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify that Twap Tx create via CowSwap safe app has decoding in the queue', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.sellTwapQLimitOrder) + const dai = swaps.createRegex(swapsHistory.forAtLeastFullDai, 'DAI') + const eq = swaps.createRegex(swapsHistory.DAIeqCOW, 'COW') + const sellAmount = swaps.getTokenPrice('COW') + const buyAmount = swaps.getTokenPrice('DAI') + const tokenSoldPrice = swaps.getTokenPrice('COW') + + create_tx.verifySummaryByName(swapsHistory.twaporder_title) + main.verifyElementsExist([create_tx.altImgDai, create_tx.altImgCow], create_tx.altImgTwapOrder) + + create_tx.verifyExpandedDetails([swapsHistory.sell, dai, eq, swapsHistory.dai, swapsHistory.filled]) + swaps.checkNumberOfParts(2) + swaps.checkSellAmount(sellAmount) + swaps.checkBuyAmount(buyAmount) + swaps.checkPercentageFilled(0, tokenSoldPrice) + + create_tx.clickOnAdvancedDetails() + create_tx.verifyAdvancedDetails([swapsHistory.multiSend, swapsHistory.multiSendCallOnly1_4_1]) + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_details_createtx.cy.js b/apps/web/cypress/e2e/regression/tx_details_createtx.cy.js new file mode 100644 index 0000000000..709e88317a --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_details_createtx.cy.js @@ -0,0 +1,59 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as swaps from '../pages/swaps.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as txs from '../pages/transactions.page.js' +import * as createtx from '../pages/create_tx.pages.js' +import * as safeapps from '../pages/safeapps.pages' +import * as wallet from '../../support/utils/wallet.js' + +let staticSafes = [] + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY + +const contracts = { + illegal: '0xF184a243925Bf7fb1D64487339FF4F177Fb75644', + '1_4_1': '0xfd0732dc9e303f09fcef3a7388ad10a83459ec99', +} +const appUrl = constants.TX_Builder_url +const iframeSelector = `iframe[id="iframe-${encodeURIComponent(appUrl)}"]` +//iframeSelector = `iframe[id="iframe-${encodeURIComponent(constants.safeTestAppurl)}"]` + +describe('Transaction details create tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify that there is an error if tx contain unofficial fallbackhandler on tx confirmation screen', () => { + cy.visit(`/apps/open?safe=${staticSafes.SEP_STATIC_SAFE_36}&appUrl=${encodeURIComponent(appUrl)}`) + cy.enter(iframeSelector).then((getBody) => { + getBody().findByLabelText(safeapps.enterAddressStr).type(staticSafes.SEP_STATIC_SAFE_36) + getBody().findByRole('button', { name: safeapps.useImplementationABI }).click() + getBody().find(safeapps.contractMethodIndex).parent().click() + getBody().findByRole('option', { name: 'setFallbackHandler' }).click() + getBody().find(safeapps.handlerInput).type(contracts.illegal) + getBody().findByText(safeapps.addTransactionStr).click() + getBody().findByText(safeapps.createBatchStr).click() + getBody().findByText(safeapps.sendBatchStr).click() + }) + safeapps.verifyUntrustedHandllerWarningVisible() + }) + + it('Verify that when the tx contains the action with an official 1.4.1 fallbackhandler contract there is no error', () => { + cy.visit(`/apps/open?safe=${staticSafes.SEP_STATIC_SAFE_36}&appUrl=${encodeURIComponent(appUrl)}`) + cy.enter(iframeSelector).then((getBody) => { + getBody().findByLabelText(safeapps.enterAddressStr).type(staticSafes.SEP_STATIC_SAFE_36) + getBody().findByRole('button', { name: safeapps.useImplementationABI }).click() + getBody().find(safeapps.contractMethodIndex).parent().click() + getBody().findByRole('option', { name: 'setFallbackHandler' }).click() + getBody().find(safeapps.handlerInput).type(contracts['1_4_1']) + getBody().findByText(safeapps.addTransactionStr).click() + getBody().findByText(safeapps.createBatchStr).click() + getBody().findByText(safeapps.sendBatchStr).click() + }) + cy.wait(2000) + safeapps.clickOnAdvancedDetails() + safeapps.verifyUntrustedHandllerWarningDoesNotExist() + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_details_queue.cy.js b/apps/web/cypress/e2e/regression/tx_details_queue.cy.js new file mode 100644 index 0000000000..42b034776e --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_details_queue.cy.js @@ -0,0 +1,37 @@ +import * as constants from '../../support/constants.js' +import * as swaps from '../pages/swaps.pages.js' +import * as create_tx from '../pages/create_tx.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as txs from '../pages/transactions.page.js' + +let staticSafes = [] + +const txUrls = { + '1_4_1': + '&id=multisig_0xc36A530ccD728d36a654ccedEB7994473474C018_0x2b68245cc89c3e2c602f8c426d987ec535f2cd7362d5cac20deb9703dc714a0e', +} + +describe('Transaction details queue tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify that when the tx contains action with unofficial fallbackhandler the warning is displayed', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_35 + txs.fallbackhandlerTx.illegalContract) + txs.verifyUntrustedHandllerWarningVisible() + }) + + it('Verify that no error for the COWSwap fallbackhandler on tx details screen', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.sellTwapQLimitOrder) + create_tx.clickOnExpandAllActionsBtn() + create_tx.verifyExpandedDetails([create_tx.txActions.setFallbackHandler]) + txs.verifyUntrustedHandllerWarningDoesNotExist() + }) + + it('Verify that when the tx contains the action with an official 1.4.1 fallbackhandler contract there is no error', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_35 + txUrls['1_4_1']) + create_tx.clickOnAdvancedDetails() + create_tx.verifyExpandedDetails([create_tx.txActions.setFallbackHandler]) + txs.verifyUntrustedHandllerWarningDoesNotExist() + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_history.cy.js b/apps/web/cypress/e2e/regression/tx_history.cy.js index 20b28c5562..87ccc5b307 100644 --- a/apps/web/cypress/e2e/regression/tx_history.cy.js +++ b/apps/web/cypress/e2e/regression/tx_history.cy.js @@ -96,12 +96,7 @@ describe('Tx history tests 1', () => { it('Verify exapanded details for initial spending limits setup', () => { createTx.clickOnTransactionItemByName(typeSpendingLimits.title, typeSpendingLimits.summaryTxInfo) createTx.verifyExpandedDetails( - [ - typeSpendingLimits.contractTitle, - typeSpendingLimits.call_multiSend, - typeSpendingLimits.transactionHash, - typeSpendingLimits.safeTxHash, - ], + [typeSpendingLimits.contractTitle, typeSpendingLimits.call_multiSend, typeSpendingLimits.transactionHash], createTx.delegateCallWarning, ) }) @@ -145,7 +140,6 @@ describe('Tx history tests 1', () => { typeDeleteAllowance.beneficiary, typeDeleteAllowance.beneficiaryAddress, typeDeleteAllowance.transactionHash, - typeDeleteAllowance.safeTxHash, typeDeleteAllowance.token, typeDeleteAllowance.tokenName, ]) @@ -154,7 +148,19 @@ describe('Tx history tests 1', () => { // Added to prod it('Verify advanced details displayed in exapanded details for allowance deletion', () => { createTx.clickOnTransactionItemByName(typeDeleteAllowance.title, typeDeleteAllowance.summaryTxInfo) - createTx.expandAdvancedDetails([typeDeleteAllowance.baseGas]) + createTx.expandAdvancedDetails([ + typeDeleteAllowance.baseGas, + typeDeleteAllowance.operation, + typeDeleteAllowance.zero_call, + ]) createTx.collapseAdvancedDetails([typeDeleteAllowance.baseGas]) }) + + it.skip('Verify address can be copied in advanced details', () => { + const data = + '0x885133e3000000000000000000000000c16db0251654c0a72e91b190d81ead367d2c6fed0000000000000000000000000000000000000000000000000000000000000000' + createTx.clickOnTransactionItemByName(typeDeleteAllowance.title, typeDeleteAllowance.summaryTxInfo) + createTx.expandAdvancedDetails([typeDeleteAllowance.baseGas]) + createTx.clickOnCopyDataBtn(data) + }) }) diff --git a/apps/web/cypress/e2e/regression/tx_history_2.cy.js b/apps/web/cypress/e2e/regression/tx_history_2.cy.js index e132c6702c..170748a22f 100644 --- a/apps/web/cypress/e2e/regression/tx_history_2.cy.js +++ b/apps/web/cypress/e2e/regression/tx_history_2.cy.js @@ -45,11 +45,7 @@ describe('Tx history tests 2', () => { // On-chain rejection it('Verify exapanded details for on-chain rejection', () => { createTx.clickOnTransactionItemByName(typeOnchainRejection.title) - createTx.verifyExpandedDetails([ - typeOnchainRejection.description, - typeOnchainRejection.transactionHash, - typeOnchainRejection.safeTxHash, - ]) + createTx.verifyExpandedDetails([typeOnchainRejection.description, typeOnchainRejection.transactionHash2]) createTx.verifyActionListExists([ typeSideActions.rejectionCreated, typeSideActions.confirmations, @@ -61,10 +57,7 @@ describe('Tx history tests 2', () => { // Batch transaction it('Verify exapanded details for batch', () => { createTx.clickOnTransactionItemByName(typeBatch.title, typeBatch.summaryTxInfo) - createTx.verifyExpandedDetails( - [typeBatch.contractTitle, typeBatch.transactionHash, typeBatch.safeTxHash], - createTx.delegateCallWarning, - ) + createTx.verifyExpandedDetails([typeBatch.contractTitle, typeBatch.transactionHash], createTx.delegateCallWarning) createTx.verifyActions([typeBatch.nativeTransfer.title]) }) @@ -82,7 +75,6 @@ describe('Tx history tests 2', () => { typeAddOwner.requiredConfirmationsTitle, typeAddOwner.ownerAddress, typeAddOwner.transactionHash, - typeAddOwner.safeTxHash, ], createTx.policyChangeWarning, ) @@ -103,9 +95,7 @@ describe('Tx history tests 2', () => { typeChangeOwner.newOwner.ownerAddress, typeChangeOwner.oldOwner.actionTitile, typeChangeOwner.oldOwner.ownerAddress, - typeChangeOwner.transactionHash, - typeChangeOwner.safeTxHash, ]) }) @@ -123,7 +113,6 @@ describe('Tx history tests 2', () => { typeRemoveOwner.requiredConfirmationsTitle, typeRemoveOwner.ownerAddress, typeRemoveOwner.transactionHash, - typeRemoveOwner.safeTxHash, ], createTx.policyChangeWarning, ) @@ -142,7 +131,6 @@ describe('Tx history tests 2', () => { typeDisableOwner.description, typeDisableOwner.address, typeDisableOwner.transactionHash, - typeDisableOwner.safeTxHash, ]) }) @@ -156,11 +144,7 @@ describe('Tx history tests 2', () => { it('Verify exapanded details for changing threshold', () => { createTx.clickOnTransactionItemByName(typeChangeThreshold.title) createTx.verifyExpandedDetails( - [ - typeChangeThreshold.requiredConfirmationsTitle, - typeChangeThreshold.transactionHash, - typeChangeThreshold.safeTxHash, - ], + [typeChangeThreshold.requiredConfirmationsTitle, typeChangeThreshold.transactionHash], createTx.policyChangeWarning, ) createTx.checkRequiredThreshold(2) @@ -171,4 +155,15 @@ describe('Tx history tests 2', () => { createTx.clickOnTransactionItemByName(typeUntrustedToken.summaryTitle, typeUntrustedToken.summaryTxInfo) createTx.verifyAddressNotCopied(0, typeUntrustedToken.senderAddress) }) + + it('Verify tx hashes are grouped in advanced details', () => { + createTx.clickOnTransactionItemByName(typeDisableOwner.title) + createTx.verifyExpandedDetails([ + typeDisableOwner.description, + typeDisableOwner.address, + typeDisableOwner.transactionHash, + ]) + createTx.clickOnAdvancedDetails() + createTx.checkHashesExist(3) + }) }) diff --git a/apps/web/cypress/e2e/regression/tx_history_4.cy.js b/apps/web/cypress/e2e/regression/tx_history_4.cy.js index 39ba1e23e8..2d85211045 100644 --- a/apps/web/cypress/e2e/regression/tx_history_4.cy.js +++ b/apps/web/cypress/e2e/regression/tx_history_4.cy.js @@ -79,6 +79,7 @@ describe('Incoming tx history details tests', () => { address_book.typeInName(senderName) address_book.clickOnSaveEntryBtn() cy.visit(constants.addressBookUrl + safe) + cy.get('body').should('be.visible') cy.contains(senderName) }) }) diff --git a/apps/web/cypress/e2e/regression/tx_history_6.cy.js b/apps/web/cypress/e2e/regression/tx_history_6.cy.js new file mode 100644 index 0000000000..5b8e915e79 --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_history_6.cy.js @@ -0,0 +1,91 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as createTx from '../pages/create_tx.pages.js' +import * as data from '../../fixtures/txhistory_data_data.json' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' + +let staticSafes = [] + +const typeOnchainRejection = data.type.onchainRejection +const typeBatch = data.type.batchNativeTransfer +const typeReceive = data.type.receive +const typeSend = data.type.send +const typeDeleteAllowance = data.type.deleteSpendingLimit +const typeGeneral = data.type.general +const typeUntrustedToken = data.type.untrustedReceivedToken + +describe('Tx history tests 6', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_7) + }) + + // Token receipt + it('Verify summary for token receipt', () => { + createTx.verifySummaryByName( + typeReceive.summaryTitle, + typeReceive.summaryTxInfo, + [typeReceive.summaryTxInfo, typeGeneral.statusOk], + typeReceive.altImage, + ) + }) + + it('Verify exapanded details for token receipt', () => { + createTx.clickOnTransactionItemByName(typeReceive.summaryTitle, typeReceive.summaryTxInfo) + createTx.verifyExpandedDetails([ + typeReceive.title, + typeReceive.receivedFrom, + typeReceive.senderAddress, + typeReceive.transactionHash, + ]) + }) + + it('Verify summary for token send', () => { + createTx.verifySummaryByName( + typeSend.title, + null, + [typeSend.summaryTxInfo2, typeGeneral.statusOk], + typeSend.altImage, + typeSend.altToken, + ) + }) + + it('Verify summary for on-chain rejection', () => { + createTx.verifySummaryByName( + typeOnchainRejection.title, + null, + [typeGeneral.statusOk], + typeOnchainRejection.altImage, + ) + }) + + it('Verify summary for batch', () => { + createTx.verifySummaryByName(typeBatch.title, typeBatch.summaryTxInfo, [ + typeBatch.summaryTxInfo, + typeGeneral.statusOk, + ]) + }) + + it('Verify summary for allowance deletion', () => { + createTx.verifySummaryByName( + typeDeleteAllowance.title, + typeDeleteAllowance.summaryTxInfo, + [typeDeleteAllowance.summaryTxInfo, typeGeneral.statusOk], + typeDeleteAllowance.altImage, + ) + }) + + it('Verify summary for untrusted token', () => { + createTx.toggleUntrustedTxs() + createTx.verifySummaryByName( + typeUntrustedToken.summaryTitle, + typeUntrustedToken.summaryTxInfo, + [typeUntrustedToken.summaryTxInfo, typeGeneral.statusOk], + typeUntrustedToken.altImage, + ) + createTx.verifySpamIconIsDisplayed(typeUntrustedToken.title, typeUntrustedToken.summaryTxInfo) + }) +}) diff --git a/apps/web/cypress/e2e/smoke/tx_history_filter.cy.js b/apps/web/cypress/e2e/regression/tx_history_filter.cy.js similarity index 98% rename from apps/web/cypress/e2e/smoke/tx_history_filter.cy.js rename to apps/web/cypress/e2e/regression/tx_history_filter.cy.js index 44d5b3dd1b..575953145a 100644 --- a/apps/web/cypress/e2e/smoke/tx_history_filter.cy.js +++ b/apps/web/cypress/e2e/regression/tx_history_filter.cy.js @@ -9,7 +9,7 @@ const success = constants.transactionStatus.success.toUpperCase() const txType_outgoing = 'multisig' const txType_incoming = 'incoming' -describe('[SMOKE] API Tx history filter tests', () => { +describe('API Tx history filter tests', () => { before(async () => { staticSafes = await getSafes(CATEGORIES.static) }) @@ -183,7 +183,8 @@ describe('[SMOKE] API Tx history filter tests', () => { it('Verify that when the outgoing date range filter is set to only one day with no transactions, it returns no results', () => { const params = { transactionType: txType_outgoing, - startDate: '2024-07-16T23:00:00.000Z', + startDate: '2024-07-16T00:00:00.000Z', + endDate: '2024-07-16T23:00:00.000Z', token_address: constants.RECIPIENT_ADDRESS, } const url = buildQueryUrl({ chainId, safeAddress, ...params }) diff --git a/apps/web/cypress/e2e/regression/tx_history_filter_2.cy.js b/apps/web/cypress/e2e/regression/tx_history_filter_2.cy.js new file mode 100644 index 0000000000..ebc6baffb1 --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_history_filter_2.cy.js @@ -0,0 +1,58 @@ +/* eslint-disable */ +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import { buildQueryUrl } from '../../support/utils/txquery.js' +import * as constants from '../../support/constants.js' + +let staticSafes = [] +let safeAddress + +const txType_incoming = 'incoming' + +describe('API Tx history decimals filter tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + safeAddress = staticSafes.SEP_STATIC_SAFE_38.substring(4) + }) + + const chainId = constants.networkKeys.sepolia + + // incoming tx + it('Verify incoming USDC can be filtered with decimals', () => { + const params = { + transactionType: txType_incoming, + value: '12.087258546746105003', + } + const url = buildQueryUrl({ chainId, safeAddress, ...params }) + console.log('*****. Generated URL:', url) + + cy.request({ + url: url, + failOnStatusCode: false, + }).then((response) => { + console.log(JSON.stringify(response.body)) + expect(response.body).to.have.property('results').and.to.be.an('array') + expect(response.body.results).to.not.be.empty + }) + }) + + it('Verify incoming ETH can be filtered with decimals', () => { + const params = { + transactionType: txType_incoming, + value: '0.05', + } + const url = buildQueryUrl({ chainId, safeAddress, ...params }) + console.log('*****. Generated URL:', url) + + cy.request({ + url: url, + failOnStatusCode: false, + }).then((response) => { + console.log(JSON.stringify(response.body)) + expect(response.body).to.have.property('results').and.to.be.an('array') + expect(response.body.results).to.not.be.empty + }) + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_notes.cy.js b/apps/web/cypress/e2e/regression/tx_notes.cy.js new file mode 100644 index 0000000000..206410b76d --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_notes.cy.js @@ -0,0 +1,116 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as createtx from '../pages/create_tx.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as messages from '../pages/messages.pages.js' +import * as msg_confirmation_modal from '../pages/modals/message_confirmation.pages.js' +import * as navigation from '../pages/navigation.page' +import * as spendinglimit from '../pages/spending_limits.pages' + +let staticSafes = [] + +const sendValue = 0.00002 +const safe = 'sep:0xF184a243925Bf7fb1D64487339FF4F177Fb75644' + +const txs = { + oneOfoneTx: + '&id=multisig_0xF184a243925Bf7fb1D64487339FF4F177Fb75644_0xccc6945d0d674ceb45f856841bfc3991b4da27ea578ffe9652bbc6835944b323', +} + +const noteCreator = 'sep:0x96D4...5aC5' + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_4_PRIVATE_KEY +const signer2 = walletCredentials.OWNER_1_PRIVATE_KEY +const signerAddress = walletCredentials.OWNER_4_WALLET_ADDRESS + +function happyPathToStepTwo() { + createtx.typeRecipientAddress(constants.EOA) + createtx.clickOnTokenselectorAndSelectSepoliaEth() + createtx.setSendValue(sendValue) + createtx.clickOnNextBtn() +} + +describe('Transaction notes tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify the tx notes field only allows 60 characters', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_6) + wallet.connectSigner(signer) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + happyPathToStepTwo() + createtx.checkMaxNoteLength() + }) + + it('Verify the tx note information message', () => { + cy.visit(constants.BALANCE_URL + staticSafes.SEP_STATIC_SAFE_6) + wallet.connectSigner(signer) + createtx.clickOnNewtransactionBtn() + createtx.clickOnSendTokensBtn() + happyPathToStepTwo() + createtx.checkNoteWarningMsg() + }) + + it('Verify in the transaction details the note is visible', () => { + cy.visit(constants.transactionUrl + safe + txs.oneOfoneTx) + createtx.checkNoteRecordedNote(createtx.recordedTxNote) + }) + + it('Verify hovering over the note tooltip shows note originator', () => { + cy.visit(constants.transactionUrl + safe + txs.oneOfoneTx) + createtx.checkNoteCreator(noteCreator) + }) + + it('Verify that after a tx was executed, the tx note is not editable', () => { + cy.visit(constants.transactionUrl + safe + txs.oneOfoneTx) + createtx.checkNoteRecordedNoteReadOnly() + }) + + it('Verify no tx note field is present when signing a message', () => { + cy.visit(constants.transactionsMessagesUrl + staticSafes.SEP_STATIC_SAFE_26) + wallet.connectSigner(signer2) + messages.clickOnMessageSignBtn(0) + msg_confirmation_modal.verifyMessagePresent(messages.offchainMessage) + main.verifyElementsCount(createtx.noteTextField, 0) + }) + + it('Verify no tx note field is present during the use of a spending limit', () => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_8) + wallet.connectSigner(signer) + navigation.clickOnNewTxBtn() + createtx.clickOnSendTokensBtn() + createtx.typeRecipientAddress(constants.EOA) + spendinglimit.enterSpendingLimitAmount(0.00001) + spendinglimit.selectSpendingLimitOption() + createtx.clickOnNextBtn() + main.verifyElementsCount(createtx.noteTextField, 0) + }) + + it('Verify that in a send funds tx the note field shows up in the execution part of the form', () => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_8) + wallet.connectSigner(signer) + navigation.clickOnNewTxBtn() + createtx.clickOnSendTokensBtn() + createtx.typeRecipientAddress(constants.EOA) + spendinglimit.enterSpendingLimitAmount(0.00001) + spendinglimit.selectStandardOption() + createtx.clickOnNextBtn() + main.verifyElementsCount(createtx.noteTextField, 1) + }) + + it('Verify no tx note is present during a recovery tx', () => { + cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_8) + wallet.connectSigner(signer) + navigation.clickOnNewTxBtn() + createtx.clickOnSendTokensBtn() + createtx.typeRecipientAddress(constants.EOA) + spendinglimit.enterSpendingLimitAmount(0.00001) + spendinglimit.selectStandardOption() + createtx.clickOnNextBtn() + main.verifyElementsCount(createtx.noteTextField, 1) + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_queue.cy.js b/apps/web/cypress/e2e/regression/tx_queue.cy.js index b93a2e4840..454bca61f1 100644 --- a/apps/web/cypress/e2e/regression/tx_queue.cy.js +++ b/apps/web/cypress/e2e/regression/tx_queue.cy.js @@ -81,7 +81,6 @@ describe('Transaction queue tests', { defaultCommandTimeout: 30000 }, () => { swapsHistory.sellOrder, swapsHistory.sell, swapsHistory.executionNeeded, - orderDetails.expiry12Months, dai, eq, ]) diff --git a/apps/web/cypress/e2e/regression/tx_queue_delete_btn.cy.js b/apps/web/cypress/e2e/regression/tx_queue_delete_btn.cy.js new file mode 100644 index 0000000000..9b33f46f64 --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_queue_delete_btn.cy.js @@ -0,0 +1,54 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as create_tx from '../pages/create_tx.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as navigation from '../pages/navigation.page.js' + +let staticSafes = [] + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_3_PRIVATE_KEY +const signer2 = walletCredentials.OWNER_4_PRIVATE_KEY + +const nextTxToBeExecuted = + '&id=multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x539c9c2cd63bae1e4f84f71ef9aa7aea1fd8edb82b089c741cffad99843d0884' + +const previousTx = + '&id=multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x539c9c2cd63bae1e4f84f71ef9aa7aea1fd8edb82b089c741cffad99843d0884' + +describe('Transaction queue Delete button tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify the option to Delete tx is available in the Reject tx modal for the next tx to be executed', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_7 + nextTxToBeExecuted) + wallet.connectSigner(signer2) + create_tx.clickOnRejectBtn() + create_tx.verifyTxRejectModalVisible() + create_tx.verifyDeleteChoiceBtnStatus(constants.enabledStates.enabled) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify the option of Delete tx is disabled for a tx that is not next to be executed', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_7 + previousTx) + wallet.connectSigner(signer2) + create_tx.clickOnRejectBtn() + create_tx.verifyTxRejectModalVisible() + create_tx.verifyDeleteChoiceBtnStatus(constants.enabledStates.disabled) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify that only the owner that proposed the tx has the option to delete it', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_7 + previousTx) + wallet.connectSigner(signer) + create_tx.clickOnRejectBtn() + create_tx.verifyTxRejectModalVisible() + main.verifyElementsCount(create_tx.deleteChoiceBtn, 0) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_queue_reject_btn.cy.js b/apps/web/cypress/e2e/regression/tx_queue_reject_btn.cy.js new file mode 100644 index 0000000000..a2e2a787ef --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_queue_reject_btn.cy.js @@ -0,0 +1,98 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as swaps from '../pages/swaps.pages.js' +import * as create_tx from '../pages/create_tx.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as data from '../../fixtures/txhistory_data_data.json' +import * as wallet from '../../support/utils/wallet.js' +import * as navigation from '../pages/navigation.page' +import { disconnectedUserErrorMsg } from '../pages/owners.pages' +import { comboButtonOptions } from '../pages/create_tx.pages' + +let staticSafes = [] + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer = walletCredentials.OWNER_3_PRIVATE_KEY +const signer2 = walletCredentials.OWNER_4_PRIVATE_KEY + +const typeOnchainRejection = data.type.onchainRejection + +const onchainRejectionTx = + '&id=multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x13037f442aa430867c6f50799382fe42ae788896e2d032a6849bf07bc87d0fe2' + +const onchainRejectionTx2 = + '&id=multisig_0x4B8A8Ca9F0002a850CB2c81b205a6D7429a22DEe_0x66460c1f56c55fc2101565cb968a0cf393be0fe84528d7507a81be7125160034' + +describe('Transaction queue Reject button tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify a tx in queue shows the Reject button', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.sellQLimitOrder) + create_tx.getRejectButton().should('be.visible') + }) + + it('Verify that Reject button is disabled out for non-owners and disconnected users', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.sellQLimitOrder) + create_tx.verifyRejectBtnDisabled() + wallet.connectSigner(signer) + create_tx.verifyRejectBtnDisabled() + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify that clicking a disabled Reject button when not connected opens the Onboard modal', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.sellQLimitOrder) + create_tx.hoverOverRejectBtnBtn() + main.verifyTextVisibility([disconnectedUserErrorMsg]) + }) + + it('Verify that clicking rejection with an owner opens a modal with the Reject option', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.sellQLimitOrder) + wallet.connectSigner(signer2) + create_tx.clickOnRejectBtn() + create_tx.verifyTxRejectModalVisible() + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify that using the Reject option opens a tx modal showing the nonce that will be rejected', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.sellQLimitOrder) + wallet.connectSigner(signer2) + create_tx.clickOnRejectBtn() + create_tx.verifyTxRejectModalVisible() + create_tx.clickOnRejectionChoiceBtn(1) + create_tx.verifyTxNonceDisplayed(0) + create_tx.checkNonceIsReadOnly() + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify a Reject tx name is "On-Chain rejection" in history', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_7 + onchainRejectionTx) + create_tx.verifyTxHeaderDetails([typeOnchainRejection.title]) + }) + + it('Verify a Reject tx cannot be "Added as batch"', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.sellQLimitOrder) + wallet.connectSigner(signer2) + create_tx.clickOnRejectBtn() + create_tx.verifyTxRejectModalVisible() + create_tx.clickOnRejectionChoiceBtn(1) + create_tx.clickOnContinueSignTransactionBtn() + create_tx.checkThatComboButtonOptionIsNotPresent(create_tx.comboButtonOptions.addToBatch) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify 2 Reject tx cannot be created with the same nonce', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_37 + onchainRejectionTx2) + wallet.connectSigner(signer2) + create_tx.clickOnRejectBtn() + create_tx.verifyTxRejectModalVisible() + create_tx.verifyRejecChoiceBtnStatus(constants.enabledStates.disabled) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_queue_replace_btn.cy.js b/apps/web/cypress/e2e/regression/tx_queue_replace_btn.cy.js new file mode 100644 index 0000000000..821a140c95 --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_queue_replace_btn.cy.js @@ -0,0 +1,65 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as swaps from '../pages/swaps.pages.js' +import * as create_tx from '../pages/create_tx.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wallet from '../../support/utils/wallet.js' +import * as navigation from '../pages/navigation.page.js' +import { spendingLimitTxOption } from '../pages/spending_limits.pages' + +let staticSafes = [] + +const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) +const signer2 = walletCredentials.OWNER_4_PRIVATE_KEY + +const sendQueueTx = + '&id=multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x539c9c2cd63bae1e4f84f71ef9aa7aea1fd8edb82b089c741cffad99843d0884' + +describe('Transaction queue Replace button tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify "Reject tx" modal has the Replace tx option', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.sellQLimitOrder) + wallet.connectSigner(signer2) + create_tx.clickOnRejectBtn() + create_tx.verifyTxRejectModalVisible() + create_tx.verifyReplaceChoiceBtnVisible() + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify Replace tx takes to a "Send funds" form with the same nonce as the tx being replaced', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.sellQLimitOrder) + wallet.connectSigner(signer2) + create_tx.clickOnRejectBtn() + create_tx.verifyTxRejectModalVisible() + create_tx.clickOnReplaceTxOption() + create_tx.verifyNonceInputValue('0') + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify there is no "Add to batch" option in a Send funds screen via replace tx', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_34 + swaps.swapTxs.sellQLimitOrder) + wallet.connectSigner(signer2) + create_tx.clickOnRejectBtn() + create_tx.verifyTxRejectModalVisible() + create_tx.clickOnReplaceTxOption() + main.verifyElementsCount(create_tx.addToBatchBtn, 0) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) + + it('Verify there is no spending limit option in Send funds form when replacing a tx', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_7 + sendQueueTx) + wallet.connectSigner(signer2) + create_tx.clickOnRejectBtn() + create_tx.verifyTxRejectModalVisible() + create_tx.clickOnReplaceTxOption() + main.verifyElementsCount(spendingLimitTxOption, 0) + navigation.clickOnWalletExpandMoreIcon() + navigation.clickOnDisconnectBtn() + }) +}) diff --git a/apps/web/cypress/e2e/regression/tx_share_block.cy.js b/apps/web/cypress/e2e/regression/tx_share_block.cy.js new file mode 100644 index 0000000000..769405e185 --- /dev/null +++ b/apps/web/cypress/e2e/regression/tx_share_block.cy.js @@ -0,0 +1,72 @@ +import * as constants from '../../support/constants.js' +import * as main from '../pages/main.page.js' +import * as create_tx from '../pages/create_tx.pages.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import { getEvents, events, checkDataLayerEvents } from '../../support/utils/gtag.js' + +let staticSafes = [] + +const txs = { + tx1: '&id=multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x539c9c2cd63bae1e4f84f71ef9aa7aea1fd8edb82b089c741cffad99843d0884', + tx2: '&id=multisig_0xBf30F749FC027a5d79c4710D988F0D3C8e217A4F_0x329f5c9429ec366e99b4f7c981417267b6718e4896182d614fbc86673e0dd39c', + tx3: '&id=multisig_0x09725D3c2f9bE905F8f9f1b11a771122cf9C9f35_0xd70f2f8b31ae98a7e3064f6cdb437e71d3df083a0709fb82c915fa82767a19eb', + tx4: '&id=multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x35aa6e1de3ebc7c5aebe461b4b16adf28a258c9e78d4eb1a48121f1a0a8a58aa', +} + +describe('Transaction share block tests', { defaultCommandTimeout: 30000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify share tx block URL exists on Tx details in Queued list when additional signature is required', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_7 + txs.tx1) + main.verifyElementsExist([create_tx.txShareBlock]) + create_tx.expandTxShareBlock() + }) + + it('Verify that share block exists in the executed tx', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_7 + txs.tx4) + main.verifyElementsExist([create_tx.txShareBlock]) + create_tx.checkCopyBtnExistsInShareblock() + }) + + it('Verify that share block is displayed for the proposed for signing txs', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_31 + txs.tx3) + main.verifyElementsExist([create_tx.txShareBlock]) + create_tx.expandTxShareBlock() + }) + + it('Verify click on the Copy link, copies the correct URL', () => { + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_31 + txs.tx3) + main.verifyElementsExist([create_tx.txShareBlock]) + create_tx.expandTxShareBlock() + create_tx.verifyCopiedURL() + }) + + it('Verify the tracking for the Share block. GA: Open share block, Copy deeplink', () => { + const shareBlockExpanded = [ + { + eventAction: events.txOpenShareBlock.action, + eventCategory: events.txOpenShareBlock.category, + event: events.txOpenShareBlock.event, + safeAddress: staticSafes.SEP_STATIC_SAFE_31.slice(6), + }, + ] + const shareBlockCopiedLink = [ + { + eventAction: events.txCopyShareBlockLink.action, + eventCategory: events.txCopyShareBlockLink.category, + event: events.txCopyShareBlockLink.event, + safeAddress: staticSafes.SEP_STATIC_SAFE_31.slice(6), + }, + ] + cy.visit(constants.transactionUrl + staticSafes.SEP_STATIC_SAFE_31 + txs.tx3) + main.verifyElementsExist([create_tx.txShareBlock]) + create_tx.expandTxShareBlock() + create_tx.verifyCopiedURL() + + getEvents() + checkDataLayerEvents(shareBlockExpanded) + checkDataLayerEvents(shareBlockCopiedLink) + }) +}) diff --git a/apps/web/cypress/e2e/regression/walletconnect.cy.js b/apps/web/cypress/e2e/regression/walletconnect.cy.js new file mode 100644 index 0000000000..2112ea6e6c --- /dev/null +++ b/apps/web/cypress/e2e/regression/walletconnect.cy.js @@ -0,0 +1,44 @@ +import * as constants from '../../support/constants.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wc from '../pages/walletconnect.page.js' + +let staticSafes = [] + +describe('Walletconnect UI tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + const views = [ + constants.BALANCE_URL, + constants.setupUrl, + constants.swapUrl, + constants.createNewSafeSepoliaUrl, + constants.transactionUrl, + constants.transactionsHistoryUrl, + constants.loadNewSafeSepoliaUrl, + constants.securityUrl, + constants.homeUrl, + constants.balanceNftsUrl, + constants.welcomeAccountsSepoliaUrl, + constants.addressBookUrl, + constants.appsUrlGeneral, + constants.welcomeSepoliaUrl, + constants.transactionQueueUrl, + constants.transactionsMessagesUrl, + constants.modulesUrl, + constants.appsCustomUrl, + constants.securityUrl, + constants.appearanceSettingsUrl, + constants.dataSettingsUrl, + constants.notificationsUrl, + ] + + views.forEach((link) => { + it(`Verify clicking on WC icon shows basic elements in view: ${link}`, () => { + cy.visit(link + staticSafes.SEP_STATIC_SAFE_4) + wc.clickOnWCBtn() + wc.checkBasicElementsVisible() + }) + }) +}) diff --git a/apps/web/cypress/e2e/regression/walletconnect_2.cy.js b/apps/web/cypress/e2e/regression/walletconnect_2.cy.js new file mode 100644 index 0000000000..9417495576 --- /dev/null +++ b/apps/web/cypress/e2e/regression/walletconnect_2.cy.js @@ -0,0 +1,19 @@ +import * as constants from '../../support/constants.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import * as wc from '../pages/walletconnect.page.js' +import * as main from '../pages/main.page.js' + +let staticSafes = [] + +describe('Walletconnect tests', () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + it('Verify that connection via WC is not allowed when no selected safe in URL', () => { + cy.visit(constants.homeUrl) + wc.clickOnWCBtn() + cy.contains(wc.connectWCStr).should('be.visible') + main.verifyElementsCount(wc.wcInput, 0) + }) +}) diff --git a/apps/web/cypress/e2e/safe-apps/apps_list.cy.js b/apps/web/cypress/e2e/safe-apps/apps_list.cy.js index f1dc6d05d2..070b03e3f1 100644 --- a/apps/web/cypress/e2e/safe-apps/apps_list.cy.js +++ b/apps/web/cypress/e2e/safe-apps/apps_list.cy.js @@ -51,17 +51,16 @@ describe('Safe Apps list tests', () => { }) it('Verify there is an error when the app manifest is invalid', () => { - cy.intercept('GET', constants.invalidAppUrl, { - name: constants.testAppData.name, - }) safeapps.clickOnCustomAppsTab() safeapps.clickOnAddCustomApp() - safeapps.typeCustomAppUrl(constants.invalidAppUrl) + safeapps.typeCustomAppUrl(`https://manifest${Math.random()}.tow`) safeapps.verifyAppNotSupportedMsg() }) it('Verify an app can be added to the list within the custom apps section', () => { - cy.intercept('GET', constants.validAppUrlJson, { + const url = `https://manifest${Math.random()}.com` + const manifest = url + '/manifest.json' + cy.intercept('GET', manifest, { name: constants.testAppData.name, description: constants.testAppData.descr, icons: [{ src: 'logo.svg', sizes: 'any', type: 'image/svg+xml' }], @@ -69,7 +68,7 @@ describe('Safe Apps list tests', () => { safeapps.clickOnCustomAppsTab() safeapps.clickOnAddCustomApp() - safeapps.typeCustomAppUrl(constants.validAppUrl) + safeapps.typeCustomAppUrl(url) safeapps.verifyAppTitle(myCustomAppTitle) safeapps.acceptTC() safeapps.clickOnAddBtn() diff --git a/apps/web/cypress/e2e/safe-apps/browser_permissions.cy.js b/apps/web/cypress/e2e/safe-apps/browser_permissions.cy.js index f42f30af64..1ff7e747b0 100644 --- a/apps/web/cypress/e2e/safe-apps/browser_permissions.cy.js +++ b/apps/web/cypress/e2e/safe-apps/browser_permissions.cy.js @@ -1,11 +1,10 @@ import * as constants from '../../support/constants' -import * as main from '../pages/main.page' import * as safeapps from '../pages/safeapps.pages' describe('Browser permissions tests', () => { beforeEach(() => { cy.fixture('safe-app').then((html) => { - cy.intercept('GET', `${constants.testAppUrl}/*`, html) + cy.intercept('GET', `${constants.TX_Builder_url}/*`, html) cy.intercept('GET', `*/manifest.json`, { name: constants.testAppData.name, description: constants.testAppData.descr, @@ -13,22 +12,17 @@ describe('Browser permissions tests', () => { safe_apps_permissions: ['camera', 'microphone'], }) }) - cy.visitSafeApp(`${constants.testAppUrl}/app`) + cy.visitSafeApp(`${constants.TX_Builder_url}`) }) - // @TODO: unknown apps don't have permissions - xit('Verify a permissions slide to the user is displayed', () => { - safeapps.clickOnContinueBtn() + it('Verify a permissions slide to the user is displayed', () => { safeapps.verifyCameraCheckBoxExists() safeapps.verifyMicrofoneCheckBoxExists() }) - xit('Verify the selection can be changed, accepted and stored', () => { + it('Verify the selection can be changed, accepted and stored', () => { safeapps.verifyMicrofoneCheckBoxExists().click() - safeapps.clickOnContinueBtn() - safeapps.verifyWarningDefaultAppMsgIsDisplayed() safeapps.verifyCameraCheckBoxExists() - safeapps.clickOnContinueBtn() safeapps.checkLocalStorage() }) }) diff --git a/apps/web/cypress/e2e/safe-apps/drain_account.spec.cy.js b/apps/web/cypress/e2e/safe-apps/drain_account.spec.cy.js index 1698ae00d8..d7f643a84d 100644 --- a/apps/web/cypress/e2e/safe-apps/drain_account.spec.cy.js +++ b/apps/web/cypress/e2e/safe-apps/drain_account.spec.cy.js @@ -19,12 +19,13 @@ describe('Drain Account tests', { defaultCommandTimeout: 40000 }, () => { beforeEach(() => { const appUrl = constants.drainAccount_url - iframeSelector = `iframe[id="iframe-${appUrl}"]` + iframeSelector = `iframe[id="iframe-${encodeURIComponent(appUrl)}"]` const visitUrl = `/apps/open?safe=${safeAppSafes.SEP_SAFEAPP_SAFE_1}&appUrl=${encodeURIComponent(appUrl)}` cy.intercept(`**//v1/chains/11155111/safes/${safeAppSafes.SEP_SAFEAPP_SAFE_1.substring(4)}/balances/**`, { fixture: 'balances.json', }) cy.visit(visitUrl) + cy.get(iframeSelector, { timeout: 30000 }).should('be.visible') }) it('Verify drain can be created', () => { @@ -54,7 +55,7 @@ describe('Drain Account tests', { defaultCommandTimeout: 40000 }, () => { navigation.clickOnDisconnectBtn() }) - // TODO: ENS does not resolve + // Skip until ENS resolve bug is fixed it.skip('Verify a drain can be created when a ENS is specified', () => { cy.enter(iframeSelector).then((getBody) => { getBody().findByLabelText(safeapps.recipientStr).type(constants.ENS_TEST_SEPOLIA).wait(2000) diff --git a/apps/web/cypress/e2e/safe-apps/permissions_settings.cy.js b/apps/web/cypress/e2e/safe-apps/permissions_settings.cy.js index ee1d65e32b..41f29b5969 100644 --- a/apps/web/cypress/e2e/safe-apps/permissions_settings.cy.js +++ b/apps/web/cypress/e2e/safe-apps/permissions_settings.cy.js @@ -8,8 +8,7 @@ let $dapps, const app1 = 'https://app1.com' const app3 = 'https://app3.com' -// TODO: Skip until connection error is resolved -describe.skip('Permissions settings tests', () => { +describe('Permissions settings tests', () => { before(() => { getSafes(CATEGORIES.static).then((statics) => { staticSafes = statics @@ -51,6 +50,7 @@ describe.skip('Permissions settings tests', () => { cy.visit(`${constants.appSettingsUrl}?safe=${staticSafes.SEP_STATIC_SAFE_2}`, { failOnStatusCode: false, }) + main.acceptCookies2() }) }) diff --git a/apps/web/cypress/e2e/safe-apps/tx-builder.spec.cy.js b/apps/web/cypress/e2e/safe-apps/tx-builder.cy.js similarity index 91% rename from apps/web/cypress/e2e/safe-apps/tx-builder.spec.cy.js rename to apps/web/cypress/e2e/safe-apps/tx-builder.cy.js index 7c018d62ca..b127034d06 100644 --- a/apps/web/cypress/e2e/safe-apps/tx-builder.spec.cy.js +++ b/apps/web/cypress/e2e/safe-apps/tx-builder.cy.js @@ -1,22 +1,13 @@ import 'cypress-file-upload' -import * as constants from '../../support/constants' -import * as safeapps from '../pages/safeapps.pages' -import * as createtx from '../../e2e/pages/create_tx.pages' -import * as navigation from '../pages/navigation.page' +import * as constants from '../../support/constants.js' +import * as safeapps from '../pages/safeapps.pages.js' +import * as navigation from '../pages/navigation.page.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' -import * as ls from '../../support/localstorage_data.js' -import { getEvents, events, checkDataLayerEvents } from '../../support/utils/gtag.js' -import * as wallet from '../../support/utils/wallet.js' import * as utils from '../../support/utils/checkers.js' -import { getMockAddress } from '../../support/utils/ethers.js' let safeAppSafes = [] let iframeSelector -const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) -const signer = walletCredentials.OWNER_4_PRIVATE_KEY -const signer2 = walletCredentials.OWNER_1_PRIVATE_KEY - describe('Transaction Builder tests', { defaultCommandTimeout: 20000 }, () => { before(async () => { safeAppSafes = await getSafes(CATEGORIES.safeapps) @@ -24,9 +15,10 @@ describe('Transaction Builder tests', { defaultCommandTimeout: 20000 }, () => { beforeEach(() => { const appUrl = constants.TX_Builder_url - iframeSelector = `iframe[id="iframe-${appUrl}"]` + iframeSelector = `iframe[id="iframe-${encodeURIComponent(appUrl)}"]` const visitUrl = `/apps/open?safe=${safeAppSafes.SEP_SAFEAPP_SAFE_1}&appUrl=${encodeURIComponent(appUrl)}` cy.visit(visitUrl) + cy.get(iframeSelector, { timeout: 30000 }).should('be.visible') }) it('Verify a simple batch can be created', () => { @@ -37,7 +29,6 @@ describe('Transaction Builder tests', { defaultCommandTimeout: 20000 }, () => { getBody().findByLabelText(safeapps.newAddressValueStr).type(safeAppSafes.SEP_SAFEAPP_SAFE_2) getBody().findByText(safeapps.addTransactionStr).click() getBody().findAllByText(constants.SEPOLIA_CONTRACT_SHORT).should('have.length', 1) - getBody().findByText(safeapps.testAddressValueStr).should('exist') getBody().findByText(safeapps.createBatchStr).click() getBody().findByText(safeapps.sendBatchStr).click() }) @@ -72,7 +63,6 @@ describe('Transaction Builder tests', { defaultCommandTimeout: 20000 }, () => { getBody().findByText(safeapps.sendBatchStr).click() }) cy.get('h4').contains(safeapps.transactionBuilderStr).should('be.visible') - cy.findAllByText(safeapps.testBooleanValue).should('have.length', 6) navigation.clickOnModalCloseBtn(0) cy.enter(iframeSelector).then((getBody) => { getBody().findAllByText(constants.SEPOLIA_CONTRACT_SHORT).should('have.length', 3) @@ -190,11 +180,11 @@ describe('Transaction Builder tests', { defaultCommandTimeout: 20000 }, () => { getBody().findByText(safeapps.sendBatchStr).click() }) cy.get('h4').contains(safeapps.transactionBuilderStr).should('be.visible') - cy.findAllByText(safeapps.testAddressValueStr).should('have.length', 4) + safeapps.checkActions(2, safeapps.basicTypesTestContractStr) navigation.clickOnModalCloseBtn(0) cy.enter(iframeSelector).then((getBody) => { getBody().findAllByText(constants.SEPOLIA_CONTRACT_SHORT).should('have.length', 2) - getBody().findAllByText(safeapps.testAddressValueStr).should('have.length', 2) + getBody().findAllByText(safeapps.testAddressValue2).should('have.length', 2) }) }) diff --git a/apps/web/cypress/e2e/safe-apps/tx-builder.2spec.cy.js b/apps/web/cypress/e2e/safe-apps/tx-builder_2.cy.js similarity index 64% rename from apps/web/cypress/e2e/safe-apps/tx-builder.2spec.cy.js rename to apps/web/cypress/e2e/safe-apps/tx-builder_2.cy.js index 3353fe640e..38b84f4013 100644 --- a/apps/web/cypress/e2e/safe-apps/tx-builder.2spec.cy.js +++ b/apps/web/cypress/e2e/safe-apps/tx-builder_2.cy.js @@ -1,21 +1,13 @@ import 'cypress-file-upload' import * as constants from '../../support/constants.js' import * as safeapps from '../pages/safeapps.pages.js' -import * as createtx from '../pages/create_tx.pages.js' -import * as navigation from '../pages/navigation.page.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' -import * as ls from '../../support/localstorage_data.js' -import { getEvents, events, checkDataLayerEvents } from '../../support/utils/gtag.js' import * as utils from '../../support/utils/checkers.js' import { getMockAddress } from '../../support/utils/ethers.js' let safeAppSafes = [] let iframeSelector -const walletCredentials = JSON.parse(Cypress.env('CYPRESS_WALLET_CREDENTIALS')) -const signer = walletCredentials.OWNER_4_PRIVATE_KEY -const signer2 = walletCredentials.OWNER_1_PRIVATE_KEY - describe('Transaction Builder 2 tests', { defaultCommandTimeout: 20000 }, () => { before(async () => { safeAppSafes = await getSafes(CATEGORIES.safeapps) @@ -23,7 +15,7 @@ describe('Transaction Builder 2 tests', { defaultCommandTimeout: 20000 }, () => beforeEach(() => { const appUrl = constants.TX_Builder_url - iframeSelector = `iframe[id="iframe-${appUrl}"]` + iframeSelector = `iframe[id="iframe-${encodeURIComponent(appUrl)}"]` const visitUrl = `/apps/open?safe=${safeAppSafes.SEP_SAFEAPP_SAFE_1}&appUrl=${encodeURIComponent(appUrl)}` cy.visit(visitUrl) }) @@ -118,55 +110,4 @@ describe('Transaction Builder 2 tests', { defaultCommandTimeout: 20000 }, () => getBody().findByText(safeapps.failedStr).should('be.visible') }) }) - - // TODO: Fix visibility element - it('Verify a simple batch can be created, signed by second signer and deleted. GA tx_confirm, tx_created', () => { - const tx_created = [ - { - eventLabel: events.txCreatedTxBuilder.eventLabel, - eventCategory: events.txCreatedTxBuilder.category, - eventType: events.txCreatedTxBuilder.eventType, - event: events.txCreatedTxBuilder.event, - safeAddress: safeAppSafes.SEP_SAFEAPP_SAFE_1.slice(6), - }, - ] - const tx_confirmed = [ - { - eventLabel: events.txConfirmedTxBuilder.eventLabel, - eventCategory: events.txConfirmedTxBuilder.category, - eventType: events.txConfirmedTxBuilder.eventType, - safeAddress: safeAppSafes.SEP_SAFEAPP_SAFE_1.slice(6), - }, - ] - // wallet.connectSigner(signer) - // cy.enter(iframeSelector).then((getBody) => { - // getBody().findByLabelText(safeapps.enterAddressStr).type(constants.SAFE_APP_ADDRESS) - // getBody().find(safeapps.contractMethodIndex).parent().click() - // getBody().findByRole('option', { name: safeapps.testAddressValue2 }).click() - // getBody().findByLabelText(safeapps.newAddressValueStr).type(safeAppSafes.SEP_SAFEAPP_SAFE_2) - // getBody().findByText(safeapps.addTransactionStr).click() - // getBody().findAllByText(constants.SEPOLIA_CONTRACT_SHORT).should('have.length', 1) - // getBody().findByText(safeapps.testAddressValueStr).should('exist') - // getBody().findByText(safeapps.createBatchStr).click() - // getBody().findByText(safeapps.sendBatchStr).click() - // }) - - // createtx.clickOnSignTransactionBtn() - // createtx.clickViewTransaction() - // navigation.clickOnWalletExpandMoreIcon() - // navigation.clickOnDisconnectBtn() - // wallet.connectSigner(signer2) - - // createtx.clickOnConfirmTransactionBtn() - // createtx.clickOnNoLaterOption() - // createtx.clickOnSignTransactionBtn() - // navigation.clickOnWalletExpandMoreIcon() - // navigation.clickOnDisconnectBtn() - // wallet.connectSigner(signer) - // createtx.deleteTx() - // createtx.verifyNumberOfTransactions(0) - // getEvents() - // checkDataLayerEvents(tx_created) - // checkDataLayerEvents(tx_confirmed) - }) }) diff --git a/apps/web/cypress/e2e/safe-apps/tx-builder_3.cy.js b/apps/web/cypress/e2e/safe-apps/tx-builder_3.cy.js new file mode 100644 index 0000000000..0b4f1d0d98 --- /dev/null +++ b/apps/web/cypress/e2e/safe-apps/tx-builder_3.cy.js @@ -0,0 +1,38 @@ +import 'cypress-file-upload' +import * as constants from '../../support/constants.js' +import * as safeapps from '../pages/safeapps.pages.js' +import * as main from '../pages/main.page.js' +import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' +import { txAccordionDetails } from '../pages/create_tx.pages' +let staticSafes = [] +let iframeSelector + +describe('Transaction Builder 3 tests', { defaultCommandTimeout: 20000 }, () => { + before(async () => { + staticSafes = await getSafes(CATEGORIES.static) + }) + + beforeEach(() => { + const appUrl = constants.TX_Builder_url + iframeSelector = `iframe[id="iframe-${encodeURIComponent(appUrl)}"]` + const visitUrl = `/apps/open?safe=${staticSafes.SEP_STATIC_SAFE_43}&appUrl=${encodeURIComponent(appUrl)}` + cy.visit(visitUrl) + }) + + it('Verify that no error for the COWSwap fallbackhandler on confirm tx screen', () => { + cy.enter(iframeSelector).then((getBody) => { + getBody().findByLabelText(safeapps.enterAddressStr).type(staticSafes.SEP_STATIC_SAFE_43) + getBody().findByRole('button', { name: safeapps.useImplementationABI }).click() + getBody().find(safeapps.contractMethodSelector).click() + getBody().find(safeapps.contractMethodIndex).parent().click() + getBody().findByRole('option', { name: safeapps.cowFallback }).click() + getBody().find(safeapps.handlerInput).type(safeapps.cowFallbackHandler) + getBody().findByText(safeapps.addTransactionStr).click() + getBody().findByText(safeapps.createBatchStr).click() + getBody().findByText(safeapps.sendBatchStr).click() + }) + safeapps.clickOnAdvancedDetails() + main.verifyElementsIsVisible([`${txAccordionDetails} ${safeapps.cowFallBackHandlerTitle}`]) + safeapps.verifyUntrustedHandllerWarningDoesNotExist() + }) +}) diff --git a/apps/web/cypress/e2e/safe-apps/tx_modal.cy.js b/apps/web/cypress/e2e/safe-apps/tx_modal.cy.js deleted file mode 100644 index 3b33dc219f..0000000000 --- a/apps/web/cypress/e2e/safe-apps/tx_modal.cy.js +++ /dev/null @@ -1,33 +0,0 @@ -import * as constants from '../../support/constants' -import * as main from '../pages/main.page' -import * as ls from '../../support/localstorage_data.js' - -const testAppName = 'Cypress Test App' -const testAppDescr = 'Cypress Test App Description' -const unknownApp = 'unknown' -const confirmTx = 'Confirm transaction' - -describe('Transaction modal tests', () => { - beforeEach(() => { - cy.fixture('safe-app').then((html) => { - cy.intercept('GET', `${constants.testAppUrl}/*`, html) - cy.intercept('GET', `*/manifest.json`, { - name: testAppName, - description: testAppDescr, - icons: [{ src: 'logo.svg', sizes: 'any', type: 'image/svg+xml' }], - }) - }) - }) - - it( - 'Verify that the transaction popup is displayed when sending a transaction from an app', - { defaultCommandTimeout: 12000 }, - () => { - cy.visitSafeApp(`${constants.testAppUrl}/dummy`) - cy.findByRole('dialog').within(() => { - cy.findByText(confirmTx) - cy.findByText(unknownApp) - }) - }, - ) -}) diff --git a/apps/web/cypress/e2e/smoke/add_owner.cy.js b/apps/web/cypress/e2e/smoke/add_owner.cy.js index 8eb448ffad..056951c526 100644 --- a/apps/web/cypress/e2e/smoke/add_owner.cy.js +++ b/apps/web/cypress/e2e/smoke/add_owner.cy.js @@ -23,31 +23,34 @@ describe('[SMOKE] Add Owners tests', () => { // TODO: Check if this test is covered with unit tests it('[SMOKE] Verify relevant error messages are displayed in Address input', () => { wallet.connectSigner(signer) - owner.openAddOwnerWindow() - owner.typeOwnerAddress(main.generateRandomString(10)) + owner.openManageSignersWindow() + owner.clickOnAddSignerBtn() + + owner.typeOwnerAddressManage(1, main.generateRandomString(10)) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidFormat) - owner.typeOwnerAddress(constants.addresBookContacts.user1.address.toUpperCase()) + owner.typeOwnerAddressManage(1, constants.addresBookContacts.user1.address.toUpperCase()) owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidChecksum) - owner.typeOwnerAddress(staticSafes.SEP_STATIC_SAFE_4) - owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.ownSafe) + owner.typeOwnerAddressManage(1, staticSafes.SEP_STATIC_SAFE_4) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.ownSafeManage) - owner.typeOwnerAddress(constants.addresBookContacts.user1.address.replace('F', 'f')) + owner.typeOwnerAddressManage(1, constants.addresBookContacts.user1.address.replace('F', 'f')) owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.invalidChecksum) - owner.typeOwnerAddress(constants.DEFAULT_OWNER_ADDRESS) - owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.alreadyAdded) + owner.typeOwnerAddressManage(1, constants.DEFAULT_OWNER_ADDRESS) + owner.verifyErrorMsgInvalidAddress(constants.addressBookErrrMsg.ownerAdded) }) - it('[SMOKE] Verify the presence of "Add Owner" button', () => { + it('[SMOKE] Verify the presence of "Manage Signers" button', () => { wallet.connectSigner(signer) - owner.verifyAddOwnerBtnIsEnabled() + owner.verifyManageSignersBtnIsEnabled() }) - it('[SMOKE] Verify “Add new owner” button is disabled for Non-Owner', () => { + it('[SMOKE] Verify "Manage Signers" button is disabled for Non-Owner', () => { cy.visit(constants.setupUrl + staticSafes.SEP_STATIC_SAFE_3) main.waitForHistoryCallToComplete() - owner.verifyAddOwnerBtnIsDisabled() + owner.verifyManageSignersBtnIsDisabled() }) }) diff --git a/apps/web/cypress/e2e/smoke/assets.cy.js b/apps/web/cypress/e2e/smoke/assets.cy.js index b3a003741a..a3f3aa7b06 100644 --- a/apps/web/cypress/e2e/smoke/assets.cy.js +++ b/apps/web/cypress/e2e/smoke/assets.cy.js @@ -25,7 +25,7 @@ describe('[SMOKE] Assets tests', () => { assets.verifyTokensTabIsSelected('true') }) - it('[SMOKE] Verify that Token list dropdown down options show/hide spam tokens', () => { + it('[SMOKE] Verify that Token list dropdown shows options "Default tokens" and "All tokens"', () => { let spamTokens = [ assets.currencyAave, assets.currencyTestTokenA, @@ -35,14 +35,12 @@ describe('[SMOKE] Assets tests', () => { assets.currencyDaiCap, ] - cy.wrap(null) - .then(() => main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__settings, ls.safeSettings.slimitSettings)) - .then(() => { - cy.reload() - main.verifyValuesDoNotExist(assets.tokenListTable, spamTokens) - assets.selectTokenList(assets.tokenListOptions.allTokens) - spamTokens.push(constants.tokenNames.sepoliaEther) - main.verifyValuesExist(assets.tokenListTable, spamTokens) - }) + assets.selectTokenList(assets.tokenListOptions.default) + main.verifyValuesExist(assets.tokenListTable, [constants.tokenNames.sepoliaEther]) + main.verifyValuesDoNotExist(assets.tokenListTable, spamTokens) + + assets.selectTokenList(assets.tokenListOptions.allTokens) + spamTokens.push(constants.tokenNames.sepoliaEther) + main.verifyValuesExist(assets.tokenListTable, spamTokens) }) }) diff --git a/apps/web/cypress/e2e/smoke/dashboard.cy.js b/apps/web/cypress/e2e/smoke/dashboard.cy.js index 752e2e2c77..76b6913e23 100644 --- a/apps/web/cypress/e2e/smoke/dashboard.cy.js +++ b/apps/web/cypress/e2e/smoke/dashboard.cy.js @@ -1,13 +1,14 @@ import * as constants from '../../support/constants' import * as dashboard from '../pages/dashboard.pages' +import * as main from '../pages/main.page.js' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' let staticSafes = [] -const txData = ['14', 'Send', '-0.00002 ETH', '1 out of 1'] -const txaddOwner = ['5', 'addOwnerWithThreshold', '1 out of 2'] -const txMultiSendCall3 = ['4', 'Batch', '3 actions', '1 out of 2'] -const txMultiSendCall2 = ['6', 'Batch', '2 actions', '1 out of 2'] +const txData = ['Send', '-0.00002 ETH', '1 out of 1'] +const txaddOwner = ['addOwnerWithThreshold', '1 out of 2'] +const txMultiSendCall3 = ['Batch', '3 actions', '1 out of 2'] +const txMultiSendCall2 = ['Batch', '2 actions', '1 out of 2'] describe('[SMOKE] Dashboard tests', { defaultCommandTimeout: 60000 }, () => { before(async () => { @@ -30,22 +31,23 @@ describe('[SMOKE] Dashboard tests', { defaultCommandTimeout: 60000 }, () => { dashboard.verifySafeAppsSection() }) - it('[SMOKE] Verify clicking on Explore Safe apps button opens list of all apps', () => { - dashboard.clickOnExploreAppsBtn() - }) - - it('[SMOKE] Verify there is empty tx string and image when there are no tx queued', () => { - cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_13) - dashboard.verifyEmptyTxSection() - }) - + // mock it('[SMOKE] Verify that the last created tx in conflicting tx is showed in the widget', () => { + cy.fixture('pending_tx/pending_tx.json').then((mockData) => { + cy.intercept('GET', constants.queuedEndpoint, mockData).as('getQueuedTransactions') + }) + cy.wait('@getQueuedTransactions') + cy.get(dashboard.pendingTxWidget, { timeout: 30000 }).should('be.visible') + main.verifyElementsCount(dashboard.pendingTxItem, 1) dashboard.verifyDataInPendingTx(txData) }) + // mock it('[SMOKE] Verify that tx are displayed correctly in Pending tx section', () => { - cy.visit(constants.homeUrl + staticSafes.SEP_STATIC_SAFE_12) - cy.wait(1000) + cy.fixture('pending_tx/pending_tx_order.json').then((mockData) => { + cy.intercept('GET', constants.queuedEndpoint, mockData).as('getQueuedTransactions') + }) + cy.wait('@getQueuedTransactions') dashboard.verifyTxItemInPendingTx(txMultiSendCall3) dashboard.verifyTxItemInPendingTx(txaddOwner) dashboard.verifyTxItemInPendingTx(txMultiSendCall2) diff --git a/apps/web/cypress/e2e/smoke/load_safe.cy.js b/apps/web/cypress/e2e/smoke/load_safe.cy.js index e8c90813df..630889b52a 100644 --- a/apps/web/cypress/e2e/smoke/load_safe.cy.js +++ b/apps/web/cypress/e2e/smoke/load_safe.cy.js @@ -9,7 +9,7 @@ let staticSafes = [] const testSafeName = 'Test safe name' -describe('[SMOKE] Load Safe tests', () => { +describe('[SMOKE] Load Safe tests', { defaultCommandTimeout: 30000 }, () => { before(async () => { staticSafes = await getSafes(CATEGORIES.static) }) @@ -31,12 +31,13 @@ describe('[SMOKE] Load Safe tests', () => { }) it('[SMOKE] Verify names cannot have more than 50 characters', () => { + // Wait due to re-render issues of the element + cy.wait(5000) safe.inputName(main.generateRandomString(51)) safe.verifyNameLengthErrorMessage() }) it('[SMOKE] Verify ENS name is translated to a valid address', () => { - // cy.visit(constants.loadNewSafeEthUrl) safe.inputAddress(constants.ENS_TEST_SEPOLIA) safe.verifyAddressInputValue(staticSafes.SEP_STATIC_SAFE_6) safe.verifyNextButtonStatus('be.enabled') @@ -48,11 +49,6 @@ describe('[SMOKE] Load Safe tests', () => { createwallet.verifyDefaultWalletName(createwallet.defaultSepoliaPlaceholder) }) - it('[SMOKE] Verify there are mandatory networks in dropdown: Eth, Polygon, Sepolia', () => { - safe.clickNetworkSelector(constants.networks.sepolia) - safe.verifyMandatoryNetworksExist() - }) - it('[SMOKE] Verify non-smart contract address is not allowed in safe address', () => { safe.inputAddress(constants.DEFAULT_OWNER_ADDRESS) safe.verifyAddressError() diff --git a/apps/web/cypress/e2e/smoke/messages_offchain.cy.js b/apps/web/cypress/e2e/smoke/messages_offchain.cy.js index 9d99016a5b..e132705c96 100644 --- a/apps/web/cypress/e2e/smoke/messages_offchain.cy.js +++ b/apps/web/cypress/e2e/smoke/messages_offchain.cy.js @@ -15,9 +15,15 @@ describe('[SMOKE] Offchain Messages tests', () => { }) beforeEach(() => { - cy.visit(constants.transactionsMessagesUrl + staticSafes.SEP_STATIC_SAFE_10) + cy.fixture('messages/messages.json').then((mockData) => { + cy.intercept('GET', constants.messagesEndpoint, mockData).as('getMessages') + }) + cy.visit(constants.transactionsMessagesUrl + staticSafes.SEP_STATIC_SAFE_23) + + cy.wait('@getMessages') }) + // mock it('[SMOKE] Verify summary for off-chain unsigned messages', () => { createTx.verifySummaryByIndex(0, [ typeMessagesGeneral.sign, @@ -31,6 +37,7 @@ describe('[SMOKE] Offchain Messages tests', () => { ]) }) + // mock it('[SMOKE] Verify summary for off-chain signed messages', () => { createTx.verifySummaryByIndex(1, [ typeMessagesGeneral.sign, @@ -44,11 +51,13 @@ describe('[SMOKE] Offchain Messages tests', () => { ]) }) + // mock it('[SMOKE] Verify exapanded details for EIP 191 off-chain message', () => { createTx.clickOnTransactionItemByIndex(2) cy.contains(typeMessagesOffchain.message2).should('be.visible') }) + // mock it('[SMOKE] Verify exapanded details for EIP 712 off-chain message', () => { const jsonString = createTx.messageNestedStr const values = [ diff --git a/apps/web/cypress/e2e/smoke/nfts.cy.js b/apps/web/cypress/e2e/smoke/nfts.cy.js index 1ad6a72e80..ec908b86ff 100644 --- a/apps/web/cypress/e2e/smoke/nfts.cy.js +++ b/apps/web/cypress/e2e/smoke/nfts.cy.js @@ -1,5 +1,4 @@ import * as constants from '../../support/constants' -import * as main from '../pages/main.page' import * as nfts from '../pages/nfts.pages' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' @@ -15,25 +14,25 @@ describe('[SMOKE] NFTs tests', () => { }) beforeEach(() => { - cy.visit(constants.balanceNftsUrl + staticSafes.SEP_STATIC_SAFE_2) + cy.fixture('nfts/nfts.json').then((mockData) => { + cy.intercept('GET', constants.collectiblesEndpoint, mockData).as('getNfts') + }) + cy.visit(constants.balanceNftsUrl + staticSafes.SEP_STATIC_SAFE_23) + cy.wait('@getNfts') nfts.waitForNftItems(2) }) + // mock it('[SMOKE] Verify that NFTs exist in the table', () => { nfts.verifyNFTNumber(10) }) + // mock it('[SMOKE] Verify NFT row contains data', () => { nfts.verifyDataInTable(nftsName, nftsAddress, nftsTokenID) }) - it('[SMOKE] Verify NFT preview window can be opened', () => { - nfts.openActiveNFT(0) - nfts.verifyNameInNFTModal(nftsTokenID) - nfts.verifySelectedNetwrokSepolia() - nfts.closeNFTModal() - }) - + // mock it('[SMOKE] Verify NFT open does not open if no NFT exits', () => { nfts.clickOnInactiveNFT() nfts.verifyNFTModalDoesNotExist() diff --git a/apps/web/cypress/e2e/smoke/tx_history.cy.js b/apps/web/cypress/e2e/smoke/tx_history.cy.js index e058e37fb5..30eeb90f7a 100644 --- a/apps/web/cypress/e2e/smoke/tx_history.cy.js +++ b/apps/web/cypress/e2e/smoke/tx_history.cy.js @@ -1,5 +1,4 @@ import * as constants from '../../support/constants' -import * as main from '../pages/main.page' import * as createTx from '../pages/create_tx.pages' import * as data from '../../fixtures/txhistory_data_data.json' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' @@ -14,15 +13,21 @@ const typeDeleteAllowance = data.type.deleteSpendingLimit const typeGeneral = data.type.general const typeUntrustedToken = data.type.untrustedReceivedToken +// TODO: Replace this test with jest (EN-141) describe('[SMOKE] Tx history tests', () => { before(async () => { staticSafes = await getSafes(CATEGORIES.static) }) beforeEach(() => { - cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_7) + cy.visit(constants.transactionsHistoryUrl + staticSafes.SEP_STATIC_SAFE_23) + cy.fixture('history/history_tx_1.json').then((mockData) => { + cy.intercept('GET', constants.transactionHistoryEndpoint, mockData).as('getHistory') + }) + cy.wait('@getHistory') }) + // mock // Token receipt it('[SMOKE] Verify summary for token receipt', () => { createTx.verifySummaryByName( @@ -33,6 +38,7 @@ describe('[SMOKE] Tx history tests', () => { ) }) + // mock it('[SMOKE] Verify exapanded details for token receipt', () => { createTx.clickOnTransactionItemByName(typeReceive.summaryTitle, typeReceive.summaryTxInfo) createTx.verifyExpandedDetails([ @@ -43,16 +49,18 @@ describe('[SMOKE] Tx history tests', () => { ]) }) + // mock it('[SMOKE] Verify summary for token send', () => { createTx.verifySummaryByName( typeSend.title, null, - [typeSend.summaryTxInfo, typeGeneral.statusOk], + [typeSend.summaryTxInfo2, typeGeneral.statusOk], typeSend.altImage, typeSend.altToken, ) }) + // mock it('[SMOKE] Verify summary for on-chain rejection', () => { createTx.verifySummaryByName( typeOnchainRejection.title, @@ -62,6 +70,7 @@ describe('[SMOKE] Tx history tests', () => { ) }) + // mock it('[SMOKE] Verify summary for batch', () => { createTx.verifySummaryByName(typeBatch.title, typeBatch.summaryTxInfo, [ typeBatch.summaryTxInfo, @@ -69,6 +78,7 @@ describe('[SMOKE] Tx history tests', () => { ]) }) + // mock it('[SMOKE] Verify summary for allowance deletion', () => { createTx.verifySummaryByName( typeDeleteAllowance.title, @@ -78,6 +88,7 @@ describe('[SMOKE] Tx history tests', () => { ) }) + // mock it('[SMOKE] Verify summary for untrusted token', () => { createTx.toggleUntrustedTxs() createTx.verifySummaryByName( diff --git a/apps/web/cypress/fixtures/history/history_tx_1.json b/apps/web/cypress/fixtures/history/history_tx_1.json new file mode 100644 index 0000000000..5e2274eed1 --- /dev/null +++ b/apps/web/cypress/fixtures/history/history_tx_1.json @@ -0,0 +1,811 @@ +{ + "count": 23, + "next": "https://safe-client.staging.5afe.dev/v1/chains/11155111/safes/0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb/transactions/history?timezone=Europe%2FBerlin&trusted=false&imitation=true&cursor=limit%3D20%26offset%3D20", + "previous": null, + "results": [ + { "type": "DATE_LABEL", "timestamp": 1742302260000 }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Custom", + "humanDescription": null, + "to": { "value": "0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", "name": "GnosisSafeProxy", "logoUri": null }, + "dataSize": "0", + "value": "0", + "methodName": null, + "actionCount": null, + "isCancellation": true + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x9b4ee6ef9271fa2f2a4e97c3b5165dc7844a124accbf02cddaf91393ef2687da", + "timestamp": 1742302260000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 16, + "confirmationsRequired": 2, + "confirmationsSubmitted": 2, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0x636e46b37283725b92f878cc504275f0254a627901c416ec6c0fe21304bf0524" + }, + "conflictType": "None" + }, + { "type": "DATE_LABEL", "timestamp": 1742204292000 }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { "value": "0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", "name": null, "logoUri": null }, + "recipient": { "value": "0x96D4c6fFC338912322813a77655fCC926b9A5aC5", "name": null, "logoUri": null }, + "direction": "OUTGOING", + "transferInfo": { "type": "NATIVE_COIN", "value": "100000000000000" } + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0xcfbe040521dd80d43f408c7fd3ce7d80f21e8916a04a56ff0fe5cd14eb1a508f", + "timestamp": 1742204292000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 15, + "confirmationsRequired": 2, + "confirmationsSubmitted": 2, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0x2a4abc4b31e339fd170b6b482c4c1b605fac1a0ffddb8259fe4c0552ba996d66" + }, + "conflictType": "None" + }, + { "type": "DATE_LABEL", "timestamp": 1720167780000 }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "SettingsChange", + "humanDescription": "Add new owner 0x4fe7...dC90 with threshold 2", + "dataDecoded": { + "method": "addOwnerWithThreshold", + "parameters": [ + { + "name": "owner", + "type": "address", + "value": "0x4fe7164d7cA511Ab35520bb14065F1693240dC90", + "valueDecoded": null + }, + { "name": "_threshold", "type": "uint256", "value": "2", "valueDecoded": null } + ] + }, + "settingsInfo": { + "type": "ADD_OWNER", + "owner": { "value": "0x4fe7164d7cA511Ab35520bb14065F1693240dC90", "name": null, "logoUri": null }, + "threshold": 2 + } + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x85835820c4119ea7e44537dd4a447264cc08d80ea1467b545b500670573c3d2d", + "timestamp": 1720167780000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 14, + "confirmationsRequired": 2, + "confirmationsSubmitted": 2, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0xfcad9959d5f194c129d16f91f61a28719287e9beefb39cc5fe81a368f400ab35" + }, + "conflictType": "None" + }, + { "type": "DATE_LABEL", "timestamp": 1702636380000 }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { "value": "0x96D4c6fFC338912322813a77655fCC926b9A5aC5", "name": null, "logoUri": null }, + "recipient": { + "value": "0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "direction": "INCOMING", + "transferInfo": { + "type": "ERC20", + "tokenAddress": "0x7CB180dE9BE0d8935EbAAc9b4fc533952Df128Ae", + "value": "1000000000000000000000", + "tokenName": "QATRUSTED", + "tokenSymbol": "QTRUST", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0x7CB180dE9BE0d8935EbAAc9b4fc533952Df128Ae.png", + "decimals": 18, + "trusted": true, + "imitation": false + } + }, + "id": "transfer_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_ed89d768d24991bca3a9eed93f369a3fa10727accc3d02aacb5c38794ba5b9136119", + "timestamp": 1702636380000, + "txStatus": "SUCCESS", + "executionInfo": null, + "safeAppInfo": null, + "txHash": "0xd89d768d24991bca3a9eed93f369a3fa10727accc3d02aacb5c38794ba5b9136" + }, + "conflictType": "None" + }, + { "type": "DATE_LABEL", "timestamp": 1701851760000 }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "SettingsChange", + "humanDescription": "Change threshold to 2", + "dataDecoded": { + "method": "changeThreshold", + "parameters": [{ "name": "_threshold", "type": "uint256", "value": "2", "valueDecoded": null }] + }, + "settingsInfo": { "type": "CHANGE_THRESHOLD", "threshold": 2 } + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x46eb9b5370d38a676d0bc895dbd8e7e3c9544f121c7019bfb0e632597d7be7d5", + "timestamp": 1701851760000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 13, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0xf3d2062541580f5f1b2a0c9e587d4696bdeecbaaa8d92a413c750ea86aed26df" + }, + "conflictType": "None" + }, + { "type": "DATE_LABEL", "timestamp": 1701418524000 }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { + "value": "0x06373d5e45AD31BD354CeBfA8dB4eD2c75B8708e", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "recipient": { + "value": "0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "direction": "INCOMING", + "transferInfo": { + "type": "ERC20", + "tokenAddress": "0x4463F6662be2fdb319Dc3C491A004DEAe39Dc70a", + "value": "5000000000000000000", + "tokenName": "test-token-type-one", + "tokenSymbol": "TTONE", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0x4463F6662be2fdb319Dc3C491A004DEAe39Dc70a.png", + "decimals": 18, + "trusted": false, + "imitation": false + } + }, + "id": "transfer_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_e54e7e766b08d4210bc1cfcc84d84ca4782a0cc1efe9e7d9c032d305060ed2a7c3044", + "timestamp": 1701418524000, + "txStatus": "SUCCESS", + "executionInfo": null, + "safeAppInfo": null, + "txHash": "0x54e7e766b08d4210bc1cfcc84d84ca4782a0cc1efe9e7d9c032d305060ed2a7c" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { + "value": "0x06373d5e45AD31BD354CeBfA8dB4eD2c75B8708e", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "recipient": { + "value": "0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "direction": "INCOMING", + "transferInfo": { + "type": "ERC20", + "tokenAddress": "0x4463F6662be2fdb319Dc3C491A004DEAe39Dc70a", + "value": "19000000000000000000", + "tokenName": "test-token-type-one", + "tokenSymbol": "TTONE", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0x4463F6662be2fdb319Dc3C491A004DEAe39Dc70a.png", + "decimals": 18, + "trusted": false, + "imitation": false + } + }, + "id": "transfer_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_e54e7e766b08d4210bc1cfcc84d84ca4782a0cc1efe9e7d9c032d305060ed2a7c3043", + "timestamp": 1701418524000, + "txStatus": "SUCCESS", + "executionInfo": null, + "safeAppInfo": null, + "txHash": "0x54e7e766b08d4210bc1cfcc84d84ca4782a0cc1efe9e7d9c032d305060ed2a7c" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { + "value": "0x06373d5e45AD31BD354CeBfA8dB4eD2c75B8708e", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "recipient": { + "value": "0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "direction": "INCOMING", + "transferInfo": { + "type": "ERC20", + "tokenAddress": "0x4463F6662be2fdb319Dc3C491A004DEAe39Dc70a", + "value": "12000000000000000000", + "tokenName": "test-token-type-one", + "tokenSymbol": "TTONE", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0x4463F6662be2fdb319Dc3C491A004DEAe39Dc70a.png", + "decimals": 18, + "trusted": false, + "imitation": false + } + }, + "id": "transfer_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_e54e7e766b08d4210bc1cfcc84d84ca4782a0cc1efe9e7d9c032d305060ed2a7c3042", + "timestamp": 1701418524000, + "txStatus": "SUCCESS", + "executionInfo": null, + "safeAppInfo": null, + "txHash": "0x54e7e766b08d4210bc1cfcc84d84ca4782a0cc1efe9e7d9c032d305060ed2a7c" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { + "value": "0x06373d5e45AD31BD354CeBfA8dB4eD2c75B8708e", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "recipient": { + "value": "0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "direction": "INCOMING", + "transferInfo": { + "type": "ERC20", + "tokenAddress": "0x4463F6662be2fdb319Dc3C491A004DEAe39Dc70a", + "value": "10000000000000000000", + "tokenName": "test-token-type-one", + "tokenSymbol": "TTONE", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0x4463F6662be2fdb319Dc3C491A004DEAe39Dc70a.png", + "decimals": 18, + "trusted": false, + "imitation": false + } + }, + "id": "transfer_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_e54e7e766b08d4210bc1cfcc84d84ca4782a0cc1efe9e7d9c032d305060ed2a7c3041", + "timestamp": 1701418524000, + "txStatus": "SUCCESS", + "executionInfo": null, + "safeAppInfo": null, + "txHash": "0x54e7e766b08d4210bc1cfcc84d84ca4782a0cc1efe9e7d9c032d305060ed2a7c" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { + "value": "0x06373d5e45AD31BD354CeBfA8dB4eD2c75B8708e", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "recipient": { + "value": "0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "direction": "INCOMING", + "transferInfo": { "type": "NATIVE_COIN", "value": "1000000000000000" } + }, + "id": "transfer_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_i1f54e48bccaa3d304908eee98e2d6ba3258c841bd39a84fedc112d5c6f049d910,0,0,0,0,0,1", + "timestamp": 1701417900000, + "txStatus": "SUCCESS", + "executionInfo": null, + "safeAppInfo": null, + "txHash": "0x1f54e48bccaa3d304908eee98e2d6ba3258c841bd39a84fedc112d5c6f049d91" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { + "value": "0x06373d5e45AD31BD354CeBfA8dB4eD2c75B8708e", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "recipient": { + "value": "0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "direction": "INCOMING", + "transferInfo": { "type": "NATIVE_COIN", "value": "1000000000000000" } + }, + "id": "transfer_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_i1f54e48bccaa3d304908eee98e2d6ba3258c841bd39a84fedc112d5c6f049d910,0,0,0,0,0,0", + "timestamp": 1701417900000, + "txStatus": "SUCCESS", + "executionInfo": null, + "safeAppInfo": null, + "txHash": "0x1f54e48bccaa3d304908eee98e2d6ba3258c841bd39a84fedc112d5c6f049d91" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Custom", + "humanDescription": null, + "to": { + "value": "0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B", + "name": "Safe: MultiSendCallOnly 1.3.0", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/contracts/logos/0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B.png" + }, + "dataSize": "260", + "value": "0", + "methodName": "multiSend", + "actionCount": 2, + "isCancellation": false + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x4dd0916d9fea51b6b724ff8155c3e7cd616f2c54634c0769b9dc0edfd4f6b2b8", + "timestamp": 1701417276000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 12, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0xa5dd2ebff8c270268f218c70e3aaae29f9570cf2a29fb4bdb17638653852b064" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { "value": "0x96D4c6fFC338912322813a77655fCC926b9A5aC5", "name": null, "logoUri": null }, + "recipient": { + "value": "0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "direction": "INCOMING", + "transferInfo": { "type": "NATIVE_COIN", "value": "500000000000000000" } + }, + "id": "transfer_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_i9cf7595977c9087f31aedc68c2554c7c898486aa132aa6bd063906a5d8564110", + "timestamp": 1701417156000, + "txStatus": "SUCCESS", + "executionInfo": null, + "safeAppInfo": null, + "txHash": "0x9cf7595977c9087f31aedc68c2554c7c898486aa132aa6bd063906a5d8564110" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "SettingsChange", + "humanDescription": null, + "dataDecoded": { + "method": "disableModule", + "parameters": [ + { + "name": "prevModule", + "type": "address", + "value": "0x0000000000000000000000000000000000000001", + "valueDecoded": null + }, + { + "name": "module", + "type": "address", + "value": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", + "valueDecoded": null + } + ] + }, + "settingsInfo": { + "type": "DISABLE_MODULE", + "module": { + "value": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", + "name": "AllowanceModule", + "logoUri": null + } + } + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0xb82070c01297392754b4b972c064f48f4ab944c5979813c01b871c8f8d57bc4a", + "timestamp": 1701416244000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 11, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0x8b3972a18cdba64348961ecd7d04f98fa155cd471e7a6fd981653177d049adeb" + }, + "conflictType": "None" + }, + { "type": "DATE_LABEL", "timestamp": 1701345096000 }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "SettingsChange", + "humanDescription": "Change threshold to 1", + "dataDecoded": { + "method": "changeThreshold", + "parameters": [{ "name": "_threshold", "type": "uint256", "value": "1", "valueDecoded": null }] + }, + "settingsInfo": { "type": "CHANGE_THRESHOLD", "threshold": 1 } + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x94d7f35334ef6f52f442ed2b498b085bd2a4d5b3b66d7878a5d2bb85e384b88e", + "timestamp": 1701345096000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 10, + "confirmationsRequired": 2, + "confirmationsSubmitted": 2, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0x34280fafb7edd2ea9ca09a7a35d14c8fb01a4f915e958572fcbc94f09876972e" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "SettingsChange", + "humanDescription": "Change threshold to 2", + "dataDecoded": { + "method": "changeThreshold", + "parameters": [{ "name": "_threshold", "type": "uint256", "value": "2", "valueDecoded": null }] + }, + "settingsInfo": { "type": "CHANGE_THRESHOLD", "threshold": 2 } + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x87163acb6b21520a00b70f7d42bdbdf92b7970d3672e0d97d1072dbb6e13c85e", + "timestamp": 1701344928000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 9, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0x8d1bb1a8cb53dcfebf58fa53fdea131bc8233fd1e7900e4dc7c3780ff437a8f9" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "SettingsChange", + "humanDescription": "Remove owner 0x8a39...8E4E with threshold 1", + "dataDecoded": { + "method": "removeOwner", + "parameters": [ + { + "name": "prevOwner", + "type": "address", + "value": "0x0000000000000000000000000000000000000001", + "valueDecoded": null + }, + { + "name": "owner", + "type": "address", + "value": "0x8a39cE4E27C326B87B75AaFf820D442311CD8E4E", + "valueDecoded": null + }, + { "name": "_threshold", "type": "uint256", "value": "1", "valueDecoded": null } + ] + }, + "settingsInfo": { + "type": "REMOVE_OWNER", + "owner": { "value": "0x8a39cE4E27C326B87B75AaFf820D442311CD8E4E", "name": null, "logoUri": null }, + "threshold": 1 + } + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0xafd58598ec10f89395d57c4d914480e50a568416e3226bc4d1853fae06697929", + "timestamp": 1701344808000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 8, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0xac665db3053c6e0ef0b0bba8d508117be28667aa96fee858cbc514c57371a4b8" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "SettingsChange", + "humanDescription": "Swap owner 0x01A9...C46f with 0x8a39...8E4E", + "dataDecoded": { + "method": "swapOwner", + "parameters": [ + { + "name": "prevOwner", + "type": "address", + "value": "0x0000000000000000000000000000000000000001", + "valueDecoded": null + }, + { + "name": "oldOwner", + "type": "address", + "value": "0x01A9F68e339da12565cfBc47fe7D6EdEcB11C46f", + "valueDecoded": null + }, + { + "name": "newOwner", + "type": "address", + "value": "0x8a39cE4E27C326B87B75AaFf820D442311CD8E4E", + "valueDecoded": null + } + ] + }, + "settingsInfo": { + "type": "SWAP_OWNER", + "oldOwner": { "value": "0x01A9F68e339da12565cfBc47fe7D6EdEcB11C46f", "name": null, "logoUri": null }, + "newOwner": { "value": "0x8a39cE4E27C326B87B75AaFf820D442311CD8E4E", "name": null, "logoUri": null } + } + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0xd7a2578ba2e610959961ad9816115d33521d7e7904797cece08051c38fa3af6f", + "timestamp": 1701344748000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 7, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0x8cf95d03bcf824b5251636bfb827c8e97f5f17fd4932b6ff343553c449472f17" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { "value": "0x96D4c6fFC338912322813a77655fCC926b9A5aC5", "name": null, "logoUri": null }, + "recipient": { + "value": "0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", + "name": "GnosisSafeProxy", + "logoUri": null + }, + "direction": "INCOMING", + "transferInfo": { "type": "NATIVE_COIN", "value": "500000000000000" } + }, + "id": "transfer_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_i2339acf27d3ed017e8b25bb4d9f83439a11a37a4b1a121ecb46284c5143c058a", + "timestamp": 1701344064000, + "txStatus": "SUCCESS", + "executionInfo": null, + "safeAppInfo": null, + "txHash": "0x2339acf27d3ed017e8b25bb4d9f83439a11a37a4b1a121ecb46284c5143c058a" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Custom", + "humanDescription": null, + "to": { + "value": "0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B", + "name": "Safe: MultiSendCallOnly 1.3.0", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/contracts/logos/0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B.png" + }, + "dataSize": "452", + "value": "0", + "methodName": "multiSend", + "actionCount": 2, + "isCancellation": false + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0xfac5d6ee5e07a48c5313e059da5828f9f16cdbb3bcacfd479ac8c121eb6cc86e", + "timestamp": 1701343740000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 6, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0x580a9ee45b5a3e15691e283f7cec0df95f4f7b60da64bb35420595f484d9005d" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "SettingsChange", + "humanDescription": "Add new owner 0x01A9...C46f with threshold 1", + "dataDecoded": { + "method": "addOwnerWithThreshold", + "parameters": [ + { + "name": "owner", + "type": "address", + "value": "0x01A9F68e339da12565cfBc47fe7D6EdEcB11C46f", + "valueDecoded": null + }, + { "name": "_threshold", "type": "uint256", "value": "1", "valueDecoded": null } + ] + }, + "settingsInfo": { + "type": "ADD_OWNER", + "owner": { "value": "0x01A9F68e339da12565cfBc47fe7D6EdEcB11C46f", "name": null, "logoUri": null }, + "threshold": 1 + } + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0xdcc572db274bb0dbf704ec0dc173c484726709fda240a453a2477db973f6e1b2", + "timestamp": 1701343644000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 5, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0x51d526bb4588b06106d39f28e51bb32f8473fcea127c2b1ad4ad71336ddeda62" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Custom", + "humanDescription": null, + "to": { + "value": "0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B", + "name": "Safe: MultiSendCallOnly 1.3.0", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/contracts/logos/0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B.png" + }, + "dataSize": "260", + "value": "0", + "methodName": "multiSend", + "actionCount": 2, + "isCancellation": false + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x417227c10599d67ed82ffd8424979991fd4b5185ace6256e9a1157570d9c665d", + "timestamp": 1701343440000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 4, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0x833ee631f939f149b3fe7f30beec0fb89b40ee327f84a22cc7d86eac5793b88c" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Custom", + "humanDescription": null, + "to": { "value": "0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb", "name": "GnosisSafeProxy", "logoUri": null }, + "dataSize": "0", + "value": "0", + "methodName": null, + "actionCount": null, + "isCancellation": true + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x13037f442aa430867c6f50799382fe42ae788896e2d032a6849bf07bc87d0fe2", + "timestamp": 1701343044000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 3, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0x4fbfb61f83380aa165155f87ef49407a109a922bbdb8bebd8cb6539f78e0067d" + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Custom", + "humanDescription": null, + "to": { "value": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", "name": "AllowanceModule", "logoUri": null }, + "dataSize": "68", + "value": "0", + "methodName": "deleteAllowance", + "actionCount": null, + "isCancellation": false + }, + "id": "multisig_0x5912f6616c84024cD1aff0D5b55bb36F5180fFdb_0x4380e4deadbd29fb0d098bdfe80572bc22985586e0b400732af8371a5595b84c", + "timestamp": 1701342528000, + "txStatus": "SUCCESS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 2, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": "0xd6e886cc910a9aceb57f9e55c20b9f8edf4442527336df2b8b4328cad4f5de8b" + }, + "conflictType": "None" + } + ] +} diff --git a/apps/web/cypress/fixtures/messages/messages.json b/apps/web/cypress/fixtures/messages/messages.json new file mode 100644 index 0000000000..bc0830f6e1 --- /dev/null +++ b/apps/web/cypress/fixtures/messages/messages.json @@ -0,0 +1,160 @@ +{ + "count": 4, + "next": null, + "previous": null, + "results": [ + { "type": "DATE_LABEL", "timestamp": 1715040000000 }, + { + "messageHash": "0x8909de146d538c1fb7822986a159867c463e9608400e16b11b805d9d22179941", + "status": "NEEDS_CONFIRMATION", + "logoUri": null, + "name": null, + "message": "Test message 1 on-chain", + "creationTimestamp": 1715086753709, + "modifiedTimestamp": 1727866579699, + "confirmationsSubmitted": 1, + "confirmationsRequired": 2, + "proposedBy": { "value": "0x96D4c6fFC338912322813a77655fCC926b9A5aC5", "name": null, "logoUri": null }, + "confirmations": [ + { + "owner": { "value": "0xC16Db0251654C0a72E91B190d81eAD367d2C6fED", "name": null, "logoUri": null }, + "signature": "0xdded1fdbea1db9bf2945c57d4b537084a46d654f31d30b5a0d46a4347f60d4f05f2d4af4b022bf55fefd46a6c72a0d133be5064eea5ff1374c0c542e223cd2d01c" + } + ], + "preparedSignature": null, + "origin": "{}", + "type": "MESSAGE" + }, + { + "messageHash": "0x02ad2289c179a65032e675f2f927b792ff3cff72b2e19465ccac24cce0b26f3b", + "status": "NEEDS_CONFIRMATION", + "logoUri": null, + "name": null, + "message": { + "domain": { + "name": "EIP-1271 Example DApp", + "version": "1.0", + "chainId": 11155111, + "verifyingContract": "0xc2F3645bfd395516d1a18CA6ad9298299d328C01" + }, + "primaryType": "Example", + "types": { + "Nested": [ + { "name": "nestedString", "type": "string" }, + { "name": "nestedAddress", "type": "address" }, + { "name": "nestedUint256", "type": "uint256" }, + { "name": "nestedUint32", "type": "uint32" }, + { "name": "nestedBytes32", "type": "bytes32" }, + { "name": "nestedBoolean", "type": "bool" } + ], + "Example": [ + { "name": "testString", "type": "string" }, + { "name": "testAddress", "type": "address" }, + { "name": "testUint256", "type": "uint256" }, + { "name": "testUint32", "type": "uint32" }, + { "name": "testBytes32", "type": "bytes32" }, + { "name": "testBoolean", "type": "bool" }, + { "name": "testNested", "type": "Nested" }, + { "name": "testNestedArray", "type": "Nested[]" } + ], + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ] + }, + "message": { + "testNested": { + "nestedString": "Test message 3 off-chain", + "nestedUint32": "1", + "nestedAddress": "0x0000000000000000000000000000000000000002", + "nestedBoolean": false, + "nestedBytes32": "0x000000000000000000000000000000000000000000000000000000000000da7a", + "nestedUint256": "0" + }, + "testString": "Test message 3 off-chain", + "testUint32": "123", + "testAddress": "0xc2f3645bfd395516d1a18ca6ad9298299d328c01", + "testBoolean": true, + "testBytes32": "0x00000000000000000000000000000000000000000000000000000000deadbeef", + "testUint256": "115792089237316195423570985008687907853269984665640564039457584007908834671663", + "testNestedArray": [ + { + "nestedString": "Test message 3 off-chain", + "nestedUint32": "1", + "nestedAddress": "0x0000000000000000000000000000000000000002", + "nestedBoolean": false, + "nestedBytes32": "0x000000000000000000000000000000000000000000000000000000000000da7a", + "nestedUint256": "0" + }, + { + "nestedString": "Test message 3 off-chain", + "nestedUint32": "1", + "nestedAddress": "0x0000000000000000000000000000000000000002", + "nestedBoolean": false, + "nestedBytes32": "0x000000000000000000000000000000000000000000000000000000000000da7a", + "nestedUint256": "0" + } + ] + } + }, + "creationTimestamp": 1715086486401, + "modifiedTimestamp": 1715086524391, + "confirmationsSubmitted": 1, + "confirmationsRequired": 2, + "proposedBy": { "value": "0x96D4c6fFC338912322813a77655fCC926b9A5aC5", "name": null, "logoUri": null }, + "confirmations": [ + { + "owner": { "value": "0xC16Db0251654C0a72E91B190d81eAD367d2C6fED", "name": null, "logoUri": null }, + "signature": "0x8a6d033296d4c0838a39e6fe9f7020868f20615250e0769ae6cf1da89759e7f96d19c8e46dd3faca50a031e1ba3ec5abe5e8096ef6e5c211a306ec7a79ef381e1c" + } + ], + "preparedSignature": null, + "origin": "{}", + "type": "MESSAGE" + }, + { + "messageHash": "0x387087299d66f4a1b04a46cfce054d7e3fa11a9d9b73233faced1770bab7cf26", + "status": "NEEDS_CONFIRMATION", + "logoUri": null, + "name": null, + "message": "Test message 2 off-chain", + "creationTimestamp": 1715086431411, + "modifiedTimestamp": 1727866596492, + "confirmationsSubmitted": 1, + "confirmationsRequired": 2, + "proposedBy": { "value": "0x96D4c6fFC338912322813a77655fCC926b9A5aC5", "name": null, "logoUri": null }, + "confirmations": [ + { + "owner": { "value": "0xC16Db0251654C0a72E91B190d81eAD367d2C6fED", "name": null, "logoUri": null }, + "signature": "0x211db8b4cfcbd1e482977f19db209fbd037b7c2daee28114caa79cfae4884695453a91148b4323496f00287dc16c3411331e919cb4835f57980845ea3cd0aea61c" + } + ], + "preparedSignature": null, + "origin": "{}", + "type": "MESSAGE" + }, + { + "messageHash": "0x43b54447554ef2646387eacae03776108dbb67455d5a487a383006412206723f", + "status": "NEEDS_CONFIRMATION", + "logoUri": null, + "name": null, + "message": "Test message 1 off-chain", + "creationTimestamp": 1715086361111, + "modifiedTimestamp": 1715086404022, + "confirmationsSubmitted": 1, + "confirmationsRequired": 2, + "proposedBy": { "value": "0xC16Db0251654C0a72E91B190d81eAD367d2C6fED", "name": null, "logoUri": null }, + "confirmations": [ + { + "owner": { "value": "0xC16Db0251654C0a72E91B190d81eAD367d2C6fED", "name": null, "logoUri": null }, + "signature": "0x5d51bafda8a9fb51f3ff25ae6c792b5739228945b133b4f2cbf4dd707a76a08978c84662f35705a5e5f8403f797a78cbe920e9fd0a17c2ed22aacff357a4a3181b" + } + ], + "preparedSignature": null, + "origin": "{}", + "type": "MESSAGE" + } + ] +} diff --git a/apps/web/cypress/fixtures/nfts/nfts.json b/apps/web/cypress/fixtures/nfts/nfts.json new file mode 100644 index 0000000000..362d03186c --- /dev/null +++ b/apps/web/cypress/fixtures/nfts/nfts.json @@ -0,0 +1,127 @@ +{ + "count": 16, + "next": "https://safe-client.staging.5afe.dev/v2/chains/11155111/safes/0xBd69b0a9DC90eB6F9bAc3E4a5875f437348b6415/collectibles?cursor=limit%3D10%26offset%3D10", + "previous": null, + "results": [ + { + "address": "0x373Ba1E9E2fe272B4Ee3144198Fe2994ac9e866c", + "tokenName": "CatFactory", + "tokenSymbol": "CF", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0x373Ba1E9E2fe272B4Ee3144198Fe2994ac9e866c.png", + "id": "0", + "uri": "ipfs://QmdmA3gwGukA8QDPH7Ypq1WAoVfX82nx7SaXFvh1T7UmvZ/0", + "name": null, + "description": null, + "imageUri": null, + "metadata": {} + }, + { + "address": "0x373Ba1E9E2fe272B4Ee3144198Fe2994ac9e866c", + "tokenName": "CatFactory", + "tokenSymbol": "CF", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0x373Ba1E9E2fe272B4Ee3144198Fe2994ac9e866c.png", + "id": "1", + "uri": "ipfs://QmdmA3gwGukA8QDPH7Ypq1WAoVfX82nx7SaXFvh1T7UmvZ/1", + "name": null, + "description": null, + "imageUri": null, + "metadata": {} + }, + { + "address": "0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334", + "tokenName": "CatTestImages", + "tokenSymbol": "CTI", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334.png", + "id": "0", + "uri": "ipfs://QmQdYF6ntmjdE8vSSTbDPjVaRd7zk98Wm3HbQdGyFWMXVF/0", + "name": null, + "description": null, + "imageUri": null, + "metadata": {} + }, + { + "address": "0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334", + "tokenName": "CatTestImages", + "tokenSymbol": "CTI", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334.png", + "id": "1", + "uri": "ipfs://QmQdYF6ntmjdE8vSSTbDPjVaRd7zk98Wm3HbQdGyFWMXVF/1", + "name": null, + "description": null, + "imageUri": null, + "metadata": {} + }, + { + "address": "0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334", + "tokenName": "CatTestImages", + "tokenSymbol": "CTI", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334.png", + "id": "2", + "uri": "ipfs://QmQdYF6ntmjdE8vSSTbDPjVaRd7zk98Wm3HbQdGyFWMXVF/2", + "name": null, + "description": null, + "imageUri": null, + "metadata": {} + }, + { + "address": "0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334", + "tokenName": "CatTestImages", + "tokenSymbol": "CTI", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334.png", + "id": "3", + "uri": "ipfs://QmQdYF6ntmjdE8vSSTbDPjVaRd7zk98Wm3HbQdGyFWMXVF/3", + "name": null, + "description": null, + "imageUri": null, + "metadata": {} + }, + { + "address": "0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334", + "tokenName": "CatTestImages", + "tokenSymbol": "CTI", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334.png", + "id": "4", + "uri": "ipfs://QmQdYF6ntmjdE8vSSTbDPjVaRd7zk98Wm3HbQdGyFWMXVF/4", + "name": null, + "description": null, + "imageUri": null, + "metadata": {} + }, + { + "address": "0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334", + "tokenName": "CatTestImages", + "tokenSymbol": "CTI", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334.png", + "id": "5", + "uri": "ipfs://QmQdYF6ntmjdE8vSSTbDPjVaRd7zk98Wm3HbQdGyFWMXVF/5", + "name": null, + "description": null, + "imageUri": null, + "metadata": {} + }, + { + "address": "0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334", + "tokenName": "CatTestImages", + "tokenSymbol": "CTI", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0xC2B7414eAc24D98a38F5Bebdb4CCb59b6b0a3334.png", + "id": "6", + "uri": "ipfs://QmQdYF6ntmjdE8vSSTbDPjVaRd7zk98Wm3HbQdGyFWMXVF/6", + "name": null, + "description": null, + "imageUri": null, + "metadata": {} + }, + { + "address": "0xD13F683CE031B66fe19D4765B932B21F1Bf76c10", + "tokenName": "NFTSafeTest", + "tokenSymbol": "NFTST", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/tokens/logos/0xD13F683CE031B66fe19D4765B932B21F1Bf76c10.png", + "id": "0", + "uri": "https://example.com/nft/0", + "name": null, + "description": null, + "imageUri": null, + "metadata": {} + } + ] +} diff --git a/apps/web/cypress/fixtures/pending_tx/pending_tx.json b/apps/web/cypress/fixtures/pending_tx/pending_tx.json new file mode 100644 index 0000000000..1be39441d3 --- /dev/null +++ b/apps/web/cypress/fixtures/pending_tx/pending_tx.json @@ -0,0 +1,100 @@ +{ + "count": 4, + "next": null, + "previous": null, + "results": [ + { + "type": "LABEL", + "label": "Next" + }, + { + "type": "CONFLICT_HEADER", + "nonce": 14 + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "SettingsChange", + "humanDescription": "Add new owner 0x0D65...1b2e with threshold 1", + "dataDecoded": { + "method": "addOwnerWithThreshold", + "parameters": [ + { + "name": "owner", + "type": "address", + "value": "0x0D65139Da4B36a8A39BF1b63e950038D42231b2e", + "valueDecoded": null + }, + { + "name": "_threshold", + "type": "uint256", + "value": "1", + "valueDecoded": null + } + ] + }, + "settingsInfo": { + "type": "ADD_OWNER", + "owner": { + "value": "0x0D65139Da4B36a8A39BF1b63e950038D42231b2e", + "name": null, + "logoUri": null + }, + "threshold": 1 + } + }, + "id": "multisig_0xBd69b0a9DC90eB6F9bAc3E4a5875f437348b6415_0xedb578c125c7872f817e38500f19ef5bf7f3c0ba75cde9ae86b56d4986e842fb", + "timestamp": 1698618050333, + "txStatus": "AWAITING_EXECUTION", + "executionInfo": { + "type": "MULTISIG", + "nonce": 14, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": null + }, + "conflictType": "HasNext" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Transfer", + "humanDescription": null, + "sender": { + "value": "0xBd69b0a9DC90eB6F9bAc3E4a5875f437348b6415", + "name": null, + "logoUri": null + }, + "recipient": { + "value": "0x0D65139Da4B36a8A39BF1b63e950038D42231b2e", + "name": "MetaMultiSigWallet", + "logoUri": null + }, + "direction": "OUTGOING", + "transferInfo": { + "type": "NATIVE_COIN", + "value": "20000000000000" + } + }, + "id": "multisig_0xBd69b0a9DC90eB6F9bAc3E4a5875f437348b6415_0xad3609798810096b53b4515e1e68b93265ab1db76632c73f1fd36278e77f1e5e", + "timestamp": 1700820219720, + "txStatus": "AWAITING_EXECUTION", + "executionInfo": { + "type": "MULTISIG", + "nonce": 14, + "confirmationsRequired": 1, + "confirmationsSubmitted": 1, + "missingSigners": null + }, + "safeAppInfo": null, + "txHash": null + }, + "conflictType": "End" + } + ] +} diff --git a/apps/web/cypress/fixtures/pending_tx/pending_tx_order.json b/apps/web/cypress/fixtures/pending_tx/pending_tx_order.json new file mode 100644 index 0000000000..9bad2e3eff --- /dev/null +++ b/apps/web/cypress/fixtures/pending_tx/pending_tx_order.json @@ -0,0 +1,112 @@ +{ + "count": 5, + "next": null, + "previous": null, + "results": [ + { "type": "LABEL", "label": "Next" }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Custom", + "humanDescription": null, + "to": { + "value": "0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B", + "name": "Safe: MultiSendCallOnly 1.3.0", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/contracts/logos/0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B.png" + }, + "dataSize": "580", + "value": "0", + "methodName": "multiSend", + "actionCount": 3, + "isCancellation": false + }, + "id": "multisig_0xFFfaC243A24EecE6553f0Da278322aCF1Fb6CeF1_0xdd83250c3ba9add601062d9750504f37a89ef27b134cc185cdc044ec70213075", + "timestamp": 1707916165884, + "txStatus": "AWAITING_CONFIRMATIONS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 4, + "confirmationsRequired": 2, + "confirmationsSubmitted": 1, + "missingSigners": [{ "value": "0xC16Db0251654C0a72E91B190d81eAD367d2C6fED", "name": null, "logoUri": null }] + }, + "safeAppInfo": null, + "txHash": null + }, + "conflictType": "None" + }, + { "type": "LABEL", "label": "Queued" }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "SettingsChange", + "humanDescription": "Add new owner 0x4c8b...5F4a with threshold 2", + "dataDecoded": { + "method": "addOwnerWithThreshold", + "parameters": [ + { + "name": "owner", + "type": "address", + "value": "0x4c8bF5541D21288836F5B7AdE01E102F074c5F4a", + "valueDecoded": null + }, + { "name": "_threshold", "type": "uint256", "value": "2", "valueDecoded": null } + ] + }, + "settingsInfo": { + "type": "ADD_OWNER", + "owner": { "value": "0x4c8bF5541D21288836F5B7AdE01E102F074c5F4a", "name": null, "logoUri": null }, + "threshold": 2 + } + }, + "id": "multisig_0xFFfaC243A24EecE6553f0Da278322aCF1Fb6CeF1_0xdffd24c0107e9c10e2542ca14b7f6aca35363d442f7af3609cd99b79fb4d045b", + "timestamp": 1707916293395, + "txStatus": "AWAITING_CONFIRMATIONS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 5, + "confirmationsRequired": 2, + "confirmationsSubmitted": 1, + "missingSigners": [{ "value": "0xC16Db0251654C0a72E91B190d81eAD367d2C6fED", "name": null, "logoUri": null }] + }, + "safeAppInfo": null, + "txHash": null + }, + "conflictType": "None" + }, + { + "type": "TRANSACTION", + "transaction": { + "txInfo": { + "type": "Custom", + "humanDescription": null, + "to": { + "value": "0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B", + "name": "Safe: MultiSendCallOnly 1.3.0", + "logoUri": "https://safe-transaction-assets.staging.5afe.dev/contracts/logos/0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B.png" + }, + "dataSize": "388", + "value": "0", + "methodName": "multiSend", + "actionCount": 2, + "isCancellation": false + }, + "id": "multisig_0xFFfaC243A24EecE6553f0Da278322aCF1Fb6CeF1_0x03e5f606fe1d1015eb621ba1a80f7ab24819290edb4a9cbd20c14b2783ee7243", + "timestamp": 1707916363084, + "txStatus": "AWAITING_CONFIRMATIONS", + "executionInfo": { + "type": "MULTISIG", + "nonce": 6, + "confirmationsRequired": 2, + "confirmationsSubmitted": 1, + "missingSigners": [{ "value": "0xC16Db0251654C0a72E91B190d81eAD367d2C6fED", "name": null, "logoUri": null }] + }, + "safeAppInfo": null, + "txHash": null + }, + "conflictType": "None" + } + ] +} diff --git a/apps/web/cypress/fixtures/safes/static.json b/apps/web/cypress/fixtures/safes/static.json index f0face4b31..e1f45ccb39 100644 --- a/apps/web/cypress/fixtures/safes/static.json +++ b/apps/web/cypress/fixtures/safes/static.json @@ -34,5 +34,17 @@ "SEP_STATIC_SAFE_31": "sep:0x09725D3c2f9bE905F8f9f1b11a771122cf9C9f35", "SEP_STATIC_SAFE_32": "sep:0x698C8D95D7B6b0B50338c2885d9583737546768f", "SEP_STATIC_SAFE_33": "sep:0x597D644b1F2b66B84F2C56f0D40D0314E8D5895b", - "SEP_STATIC_SAFE_34": "sep:0xD8b85a669413b25a8BE7D7698f88b7bFA20889d2" + "SEP_STATIC_SAFE_34": "sep:0xD8b85a669413b25a8BE7D7698f88b7bFA20889d2", + "SEP_STATIC_SAFE_35": "sep:0xc36A530ccD728d36a654ccedEB7994473474C018", + "SEP_STATIC_SAFE_36": "sep:0xD9BD8a5F97A003f948d684695667BB8Ff9F3d61E", + "SEP_STATIC_SAFE_37": "sep:0x4B8A8Ca9F0002a850CB2c81b205a6D7429a22DEe", + "SEP_STATIC_SAFE_38": "sep:0x30aeC11779d29dB096C434D4e72E77276EB01BdE", + "SEP_STATIC_SAFE_39": "sep:0xAD5e4a366cc840120701384fca4Ec9b8bEb47cAD", + "SEP_STATIC_SAFE_40": "sep:0x22e5093F4A75c2E99A8EcabfBF8c5c7fDcaDCf9d", + "SEP_STATIC_SAFE_41": "sep:0xE5577b9E75F94C4a900E74F63F79A7968e812208", + "SEP_STATIC_SAFE_42": "sep:0x7AaE77F475E718AdD032C7665427C6d4e6104D3c", + "SEP_STATIC_SAFE_43": "sep:0xC5AaBf061f2412F9D84585755dc517EF040becF9", + "SEP_STATIC_SAFE_44": "sep:0x8A3faB996b721d68357B42eD0D6328eBE6113e00", + "SEP_STATIC_SAFE_45": "sep:0x5958B92f412408bF12Bbc8638d524ebe5878E795", + "MATIC_STATIC_SAFE_29": "matic:0x5E9242FD52c4c4A60d874E8ff4Ba25657dd6e551" } diff --git a/apps/web/cypress/fixtures/swaps/quoteresponse1.json b/apps/web/cypress/fixtures/swaps/quoteresponse1.json new file mode 100644 index 0000000000..0f561b92c8 --- /dev/null +++ b/apps/web/cypress/fixtures/swaps/quoteresponse1.json @@ -0,0 +1,22 @@ +{ + "quote": { + "sellToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", + "buyToken": "0xb4f1737af37711e9a5890d9510c9bb60e170cb0d", + "receiver": "0x03042b890b99552b60a073f808100517fb148f60", + "sellAmount": "199466511674735744576", + "buyAmount": "695718540364483621176", + "validTo": 1743602782, + "appData": "{\"appCode\":\"Safe Wallet Swaps\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"partnerFee\":{\"bps\":35,\"recipient\":\"0x63695Eee2c3141BDE314C5a6f89B98E62808d716\"},\"quote\":{\"slippageBips\":30,\"smartSlippage\":false},\"widget\":{\"appCode\":\"CoW Swap-SafeApp\",\"environment\":\"production\"}},\"version\":\"1.3.0\"}", + "appDataHash": "0x08f06654c9e374ad6c8bf316b9eba772bd2cc9e54503e0fce31890b109446f0c", + "feeAmount": "533488325264255424", + "kind": "sell", + "partiallyFillable": false, + "sellTokenBalance": "erc20", + "buyTokenBalance": "erc20", + "signingScheme": "eip712" + }, + "from": "0x03042b890b99552b60a073f808100517fb148f60", + "expiration": "1970-01-01T00:00:00Z", + "id": null, + "verified": false +} diff --git a/apps/web/cypress/fixtures/swaps/quoteresponse2.json b/apps/web/cypress/fixtures/swaps/quoteresponse2.json new file mode 100644 index 0000000000..46ae85e123 --- /dev/null +++ b/apps/web/cypress/fixtures/swaps/quoteresponse2.json @@ -0,0 +1,22 @@ +{ + "quote": { + "sellToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", + "buyToken": "0xb4f1737af37711e9a5890d9510c9bb60e170cb0d", + "receiver": "0x03042b890b99552b60a073f808100517fb148f60", + "sellAmount": "108978992606022873342", + "buyAmount": "600000000000000000000", + "validTo": 1743756219, + "appData": "{\"appCode\":\"Safe Wallet Swaps\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"partnerFee\":{\"bps\":35,\"recipient\":\"0xE344241493D573428076c022835856a221dB3E26\"},\"quote\":{\"slippageBips\":30,\"smartSlippage\":false},\"widget\":{\"appCode\":\"CoW Swap-SafeApp\",\"environment\":\"production\"}},\"version\":\"1.3.0\"}", + "appDataHash": "0xaf770d59f29b652477aeccb4e40f703b9fb615374b77fa61095b32e0723302bd", + "feeAmount": "103301303449504384", + "kind": "buy", + "partiallyFillable": false, + "sellTokenBalance": "erc20", + "buyTokenBalance": "erc20", + "signingScheme": "eip712" + }, + "from": "0x03042b890b99552b60a073f808100517fb148f60", + "expiration": "2025-04-04T08:15:39.117769292Z", + "id": 720106, + "verified": true +} diff --git a/apps/web/cypress/fixtures/swaps_data.json b/apps/web/cypress/fixtures/swaps_data.json index 2ec3037bf0..c3b8d8a29c 100644 --- a/apps/web/cypress/fixtures/swaps_data.json +++ b/apps/web/cypress/fixtures/swaps_data.json @@ -43,15 +43,18 @@ "status": "Status", "sellFull": "Sell 1 COW", "sell10Cow": "Sell 10 COW", + "sell100Cow": "Sell 100 COW", "sell": "Sell", "oneCOW": "1 COW", "forAtLeast": "for at least", + "forAtLeastFullCow": "for at least COW", "forAtLeastFullDai": "for at least DAI", "forAtLeastFullUni": "for at least UNI", "forAtLeastFullUSDT": "for at least USDT", "forAtLeastFullWETH": "for at least WETH", "daiSold": "DAI sold", "WETHeqDAI": "1 WETH = 1.32K DAI", + "COWeqWETH": "1 COW = 0.003 WETH", "DAIeqCOW": "1 DAI = COW", "UNIeqCOW": "1 UNI = K COW", "DAIeqWETH": "1 DAI = WETH", diff --git a/apps/web/cypress/fixtures/txhistory_data_data.json b/apps/web/cypress/fixtures/txhistory_data_data.json index 9bdab8a0ed..f896e975cb 100644 --- a/apps/web/cypress/fixtures/txhistory_data_data.json +++ b/apps/web/cypress/fixtures/txhistory_data_data.json @@ -1,7 +1,8 @@ { "type": { "general": { - "statusOk": "Success" + "statusOk": "Success", + "statusExpired": "Expired" }, "sideActions": { "created": "Created", @@ -58,15 +59,16 @@ "txHashDAI": "0x7156...3e47", "nftHash": "0x3873...6d0b", "txHashEth": "0xdc6b...f250", - "executionDateDAI": "10/30/2023, 2:37:11 AM", - "executionDateNFT": "9/28/2023, 2:48:11 AM", - "executionDateEth": "8/19/2020, 8:51:31 AM" + "executionDateDAI": "10/30/2023", + "executionDateNFT": "9/28/2023", + "executionDateEth": "8/19/2020" }, "send": { "title": "Sent", "summaryTxInfo": "-< 0.00001 ETH", + "summaryTxInfo2": "-0.0001 ETH", "summaryTime": "11:02 AM", - "sentTo": "Sent 0.000000000001 ETH to:", + "sentTo": "Sent 0.000000000001 ETH to", "recipientAddress": "sep:0x06373d5e45AD31BD354CeBfA8dB4eD2c75B8708e", "transactionHash": "0x6a59...6a98", "altImage": "Sent", @@ -78,8 +80,9 @@ "title": "On-chain rejection", "summaryTime": "11:17 AM", "altImage": "On-chain rejection", - "description": "This is an on-chain rejection that didn't send any funds. This on-chain rejection replaced all transactions with nonce 3.", + "description": "This is an on-chain rejection that didn't send any funds. This on-chain rejection replaced all transactions with nonce 16.", "transactionHash": "0x4fbf...067d", + "transactionHash2": "0x636e...0524", "safeTxHash": "0x1303...0fe2" }, "batchNativeTransfer": { @@ -163,7 +166,9 @@ "token": "Token", "tokenName": "ETH", "tokenAlt": "ETH", - "baseGas": "baseGas" + "baseGas": "BaseGas", + "operation": "Operation", + "zero_call": "0 (call)" }, "spendingLimits": { "title": "Batch", diff --git a/apps/web/cypress/support/commands.js b/apps/web/cypress/support/commands.js index d30653b333..e5be412105 100644 --- a/apps/web/cypress/support/commands.js +++ b/apps/web/cypress/support/commands.js @@ -219,3 +219,33 @@ Cypress.Commands.add('setupInterceptors', () => { req.continue() }).as('headers') }) + +Cypress.Commands.overwrite('visit', (originalFn, url, options = {}) => { + const maxRetries = 3 + let attempt = 0 + + function attemptVisit(resolve, reject) { + originalFn(url, options) + .then((response) => { + if (response && response.status === 429) { + if (attempt < maxRetries) { + attempt++ + const waitTime = 6000 + console.warn( + `Rate limit (429) detected! Retrying in ${waitTime / 1000} seconds... Attempt ${attempt}/${maxRetries}`, + ) + cy.wait(waitTime).then(() => attemptVisit(resolve, reject)) + } else { + reject(new Error(`cy.visit failed after ${maxRetries + 1} attempts due to 429 rate limit.`)) + } + } else { + resolve(response) + } + }) + .catch(reject) + } + + return new Cypress.Promise((resolve, reject) => { + attemptVisit(resolve, reject) + }) +}) diff --git a/apps/web/cypress/support/constants.js b/apps/web/cypress/support/constants.js index d1e6438099..977565c11a 100644 --- a/apps/web/cypress/support/constants.js +++ b/apps/web/cypress/support/constants.js @@ -37,15 +37,18 @@ export const goerlySafeName = /g(ö|oe)rli-safe/ export const sepoliaSafeName = 'sepolia-safe' export const goerliToken = /G(ö|oe)rli Ether/ +export const spaceUrl = '/spaces/settings?spaceId=' +export const spaceMembersUrl = '/spaces/members?spaceId=' export const prodbaseUrl = 'https://app.safe.global' export const swapWidget = 'https://swap.cow.fi/#/11155111/widget/swap/' +export const bridgeWidget = 'https://iframe.jumper.exchange/bridge?fromChain=11155111&theme=light' export const safeTestAppurl = 'https://safe-apps-test-app.pages.dev' -export const TX_Builder_url = 'https://safe-apps.dev.5afe.dev/tx-builder' -export const drainAccount_url = 'https://safe-apps.dev.5afe.dev/drain-safe' -export const testAppUrl = 'https://safe-test-app.com' +export const TX_Builder_url = 'https://safe-apps.dev.5afe.dev/tx-builder/' +export const drainAccount_url = 'https://safe-apps.dev.5afe.dev/drain-safe/' +export const testAppUrl = 'https://safe-test-app.com/' export const swapUrl = '/swap?safe=' export const addressBookUrl = '/address-book?safe=' -export const appsUrlGeneral = '/apps?=safe=' +export const appsUrlGeneral = '/apps?safe=' export const stakingUrl = '/stake?safe=' export const appsCustomUrl = 'apps/custom?safe=' export const BALANCE_URL = '/balances?safe=' @@ -57,10 +60,15 @@ export const transactionsQueued = 'transactions/queued' export const transactionUrl = '/transactions/tx?safe=' export const openAppsUrl = '/apps/open?safe=' export const homeUrl = '/home?safe=' +export const spacesUrl = '/welcome/spaces' export const welcomeUrl = '/welcome' +export const welcomeAccountUrl = 'welcome/accounts' +export const welcomeAccountsSepoliaUrl = 'welcome/accounts?chain=sep' +export const welcomeSepoliaUrl = '/welcome?chain=sep' export const chainMaticUrl = '/welcome?chain=matic' export const createNewSafeSepoliaUrl = '/new-safe/create?chain=sep' export const loadNewSafeSepoliaUrl = '/new-safe/load?chain=sep' +export const loadNewSafeUrl = '/new-safe/load' export const appsUrl = '/apps' export const requestPermissionsUrl = '/request-permissions' export const getPermissionsUrl = '/get-permissions' @@ -70,14 +78,12 @@ export const dataSettingsUrl = '/settings/data?safe=' export const securityUrl = '/settings/security?safe=' export const modulesUrl = '/settings/modules?safe=' export const notificationsUrl = '/settings/notifications?safe=' -export const invalidAppUrl = 'https://my-invalid-custom-app.com/manifest.json' -export const validAppUrlJson = 'https://my-valid-custom-app.com/manifest.json' export const validAppUrl = 'https://my-valid-custom-app.com' export const etherscanlLink = 'etherscan.io' export const stagingTxServiceUrl = 'https://safe-transaction-sepolia.staging.5afe.dev/api' export const stagingTxServiceSafesUrl = '/safes/' export const stagingTxServiceBalancesUrl = '/balances/' - +export const appearanceSettingsUrl = '/settings/appearance?safe=' export const stagingCGWUrl = 'https://safe-client.staging.5afe.dev/' export const stagingCGWUrlv1 = 'https://safe-client.staging.5afe.dev/v1' export const stagingCGWUrlv2 = 'https://safe-client.staging.5afe.dev/v2' @@ -89,10 +95,16 @@ export const stagingCGWDelegatesUrl = '/delegates?safe=' export const relayPath = '/relay/' export const stagingCGWAllTokensBalances = '/balances/USD?trusted=false&exclude_spam=false' +export const spacesEndpoint = '**/**/spaces' export const proposeEndpoint = '/**/propose' export const appsEndpoint = '/**/safe-apps' -export const transactionHistoryEndpoint = '**/history*' +export const transactionHistoryEndpoint = '**/history**' export const safeListEndpoint = '**/safes' +export const queuedEndpoint = '**/queued' +export const messagesEndpoint = '**/messages' +export const collectiblesEndpoint = '**/collectibles' + +export const indexStatusUrl = 'https://status.safe.global' export const VALID_QR_CODE_PATH = '../fixtures/sepolia_test_safe_QR.png' export const INVALID_QR_CODE_PATH = '../fixtures/invalid_image_QR_test.png' @@ -132,6 +144,7 @@ export const networks = { sepolia: 'Sepolia', polygon: 'Polygon', gnosis: 'Gnosis', + berachain: 'Berachain', zkSync: 'zkSync Era', base: 'Base', optimism: 'Optimism', @@ -197,12 +210,14 @@ export const tokenNames = { wrappedEther: 'Wrapped Ether', sepoliaEther: 'Sepolia Ether', qaToken: 'QAtest10', + cow: 'CoW Protocol Token', } export const addressBookErrrMsg = { invalidFormat: 'Invalid address format', invalidChecksum: 'Invalid address checksum', exceedChars: 'Maximum 50 symbols', + ownSafeManage: 'The Safe Account cannot own itself', ownSafe: 'Cannot use Safe Account itself as signer', alreadyAdded: 'Address already added', ownerAdded: 'Signer is already added', diff --git a/apps/web/cypress/support/e2e.js b/apps/web/cypress/support/e2e.js index 49a4bbe54f..64640078ba 100644 --- a/apps/web/cypress/support/e2e.js +++ b/apps/web/cypress/support/e2e.js @@ -19,7 +19,6 @@ import './commands' import './safe-apps-commands' import * as constants from './constants' import * as ls from './localstorage_data' -import { acceptCookies2 } from '../e2e/pages/main.page' // Alternatively you can use CommonJS syntax: // require('./commands') @@ -77,6 +76,7 @@ beforeEach(() => { const beamerKey1 = `_BEAMER_FIRST_VISIT_${productID}` const beamerKey2 = `_BEAMER_BOOSTED_ANNOUNCEMENT_DATE_${productID}` const cookiesKey = 'SAFE_v2__cookies_terms' + const outreachWindowKey = 'SAFE_v2__outreachPopup_session_v2' window.localStorage.setItem(beamerKey1, getDate()) window.localStorage.setItem(beamerKey2, getDate()) window.localStorage.setItem(cookiesKey, ls.cookies.acceptedCookies) @@ -84,8 +84,7 @@ beforeEach(() => { constants.localStorageKeys.SAFE_v2__SafeApps__infoModal, ls.appPermissions(constants.safeTestAppurl).infoModalAccepted, ) + window.sessionStorage.setItem(outreachWindowKey, Date.now()) cy.wrap(window.localStorage).invoke('getItem', cookiesKey).should('equal', ls.cookies.acceptedCookies) }) - cy.visit(constants.setupUrl + 'sep:0xBb26E3717172d5000F87DeFd391994f789D80aEB') - acceptCookies2() }) diff --git a/apps/web/cypress/support/localstorage_data.js b/apps/web/cypress/support/localstorage_data.js index e442bc8e39..7c2644647d 100644 --- a/apps/web/cypress/support/localstorage_data.js +++ b/apps/web/cypress/support/localstorage_data.js @@ -292,6 +292,13 @@ export const addressBookData = { proposers: { 11155111: { '0xC16Db0251654C0a72E91B190d81eAD367d2C6fED': 'AD Proposer1' }, }, + nestedsafes: { + 11155111: { + '0xAD5e4a366cc840120701384fca4Ec9b8bEb47cAD': 'Main nested safe', + '0x22e5093F4A75c2E99A8EcabfBF8c5c7fDcaDCf9d': 'Nested safe1', + '0xE5577b9E75F94C4a900E74F63F79A7968e812208': 'Nested safe2', + }, + }, addedSafesImport: { 11155111: { '0x6d0b6F96f665Bb4490f9ddb2e450Da2f7e546dC1': 'imported-safe' }, }, diff --git a/apps/web/cypress/support/utils/gtag.js b/apps/web/cypress/support/utils/gtag.js index fe6e9fd07d..b4671144f4 100644 --- a/apps/web/cypress/support/utils/gtag.js +++ b/apps/web/cypress/support/utils/gtag.js @@ -1,6 +1,11 @@ export function getEvents() { cy.window().then((win) => { - cy.wrap(win.dataLayer).as('dataLayer') + cy.wrap(win.dataLayer) + .as('dataLayer') + .then((dataLayer) => { + console.log('DataLayer:', dataLayer) + cy.task('log', JSON.stringify(dataLayer, null, 2)) + }) }) } @@ -9,7 +14,7 @@ export const checkDataLayerEvents = (expectedEvents) => { expectedEvents.forEach((expectedEvent) => { const eventExists = dataLayer.some((event) => { return Object.keys(expectedEvent).every((key) => { - return event[key] === expectedEvent[key] + return event[2]?.[key] === expectedEvent[key] }) }) expect(eventExists, `Expected event matching fields: ${JSON.stringify(expectedEvent)} not found`).to.be.true @@ -69,4 +74,16 @@ export const events = { eventType: 'tx_confirmed', event: 'tx_confirmed', }, + + txOpenShareBlock: { + action: 'Open share block', + event: 'customClick', + category: 'tx-list', + }, + + txCopyShareBlockLink: { + action: 'Copy deeplink', + event: 'customClick', + category: 'tx-list', + }, } diff --git a/apps/web/cypress/support/utils/wallet.js b/apps/web/cypress/support/utils/wallet.js index 9a75493d36..b0e511d274 100644 --- a/apps/web/cypress/support/utils/wallet.js +++ b/apps/web/cypress/support/utils/wallet.js @@ -8,6 +8,8 @@ const connectWalletBtn = '[data-testid="connect-wallet-btn"]' const privateKeyStr = 'Private key' export function connectSigner(signer) { + let retryCount = 0 + const actions = { privateKey: () => { cy.wait(2000) @@ -23,6 +25,10 @@ export function connectSigner(signer) { }) }, retry: () => { + retryCount++ + if (retryCount > 20) { + throw new Error('Failed to connect after 20 retries') + } cy.wait(1000).then(enterPrivateKey) }, } @@ -36,8 +42,8 @@ export function connectSigner(signer) { $input.val(signer) cy.wrap($input).trigger('input').trigger('change') }) - cy.get(pkConnectBtn).click() + cy.wait(2000) } }) } @@ -58,7 +64,7 @@ export function connectSigner(signer) { cy.get(connectWalletBtn) .eq(0) .should('be.enabled') - .click() + .click({ force: true }) .then(() => { const actionKey = $body.find(onboardv2).length > 0 ? 'privateKey' : 'retry' actions[actionKey]() diff --git a/apps/web/docs/release-procedure.md b/apps/web/docs/release-procedure.md index 1842269acd..ff630b7a52 100644 --- a/apps/web/docs/release-procedure.md +++ b/apps/web/docs/release-procedure.md @@ -2,18 +2,10 @@ The code is being actively developed on the `dev` branch. Pull requests are made against this branch. -When it's time to make a release, we "freeze" the code by creating a release branch off of the `dev` branch. A release PR is created from that branch, and sent to QA. - -After the PR is tested and approved by QA, it's merged into the `main` branch. `Main` is automatically deployed to the staging environment. - -Schematically: - -``` -<feature branches> –> dev -> release -> main -``` - We prepare at least one release every sprint. Sprints are two weeks long. +When it's time to make a release, we "freeze" the code by creating a release branch off of the `dev` branch. A release PR is created from that branch, and sent to QA. + ### Preparing a release branch - Create a code-freeze branch named `release` @@ -28,7 +20,23 @@ We prepare at least one release every sprint. Sprints are two weeks long. > git log origin/main..origin/dev --pretty=format:'* %s' > ``` -- Add the PR to the Project `Web Squad` and set the status to `Ready for QA` +```bash +git checkout release # switch to the release branch +git fetch --all; git reset --hard origin/dev # sync it with dev +``` + +Change the version in `app/web/package.json` to the new version. + +```bash +git add . +git commit -m '1.54.0' # where 1.54.0 is the new version +git push +``` + +Once pushed: + +- Create a PR from `release` to `main`. +- Add the PR to the Wallet project and set the status to `Ready for QA` ### QA @@ -38,7 +46,7 @@ We prepare at least one release every sprint. Sprints are two weeks long. ### Releasing to production -Wait for all the checks on GitHub to pass. +After the PR is tested and approved by QA: - Switch to the main branch and make sure it's up to date: @@ -60,14 +68,14 @@ git pull origin release git push ``` -A deployment workflow will kick in and do the following things: +A deployment workflow will be triggered and it will do the following things: -- Deploy the code to staging -- Create a new git tag from the version in package.json +- Deploy the build to [staging](https://safe-wallet-web.staging.5afe.dev/) +- Create a new git tag from the version in `package.json` - Create a draft [GitHub release](https://github.com/safe-global/safe-wallet-web/releases) linked to this tag, with a changelog taken from the release PR After that, the release manager should: -- Create a final release from the draft release. This will trigger a build and upload the code to an S3 bucket +- Publish the draft release. This will trigger a build and upload the code to an S3 bucket – wait for the job to finish - Notify devops on Slack and send them the release link to deploy to production - Back-merge `main` into the `dev` branch to keep them in sync unless the release branch was based on `dev` diff --git a/apps/web/docs/update-patch.md b/apps/web/docs/update-patch.md new file mode 100644 index 0000000000..9eec12e50c --- /dev/null +++ b/apps/web/docs/update-patch.md @@ -0,0 +1,26 @@ +# Update yarn patches + +You can find all patches that are currently applied inside the `.yarn/patches` directory. The name of the package that the patch applies to can be found in the file name of the patch. + +Following are the steps to update a patch in case an update to the dependency needs to be done. + +1. Run `yarn add <package-name>@<version>` to update the dependency + +### If the file you are patching hasn't changed after the update: + +1. Update the patch file name inside `.yarn/patches` to reflect the new version + 1. e.g. `next-npm-15.2.3-06a6671f62.patch` -> `next-npm-15.2.4-06a6671f62.patch` +2. Update the dependency inside `package.json` again to apply the patch + 1. e.g. `"next": "15.2.4"` -> `"next": "patch:next@15.2.4#../../.yarn/patches/next-npm-15.2.4-06a6671f62.patch"` +3. Run `yarn install` to update lock file +4. Go to the package directory inside `node_modules` and check that the patch is applied + +### If the file you are patching has changed after the update: + +1. Run `yarn patch <package-name>` e.g. `yarn patch next@npm:15.2.4` +2. Follow the instructions from your CLI +3. Check the generated patch file inside `.yarn/patches` and make sure it contains the expected changes +4. Update the dependency inside `package.json` + 1. e.g. `"next": "15.2.4"` -> `"next": "patch:next@15.2.4#../../.yarn/patches/next-npm-15.2.4-06a6671f62.patch"` +5. Run `yarn install` to update lock file +6. Go to the package directory inside `node_modules` and check that the patch is applied diff --git a/apps/web/jest.config.cjs b/apps/web/jest.config.cjs index 36ee0b7244..6bc183901f 100644 --- a/apps/web/jest.config.cjs +++ b/apps/web/jest.config.cjs @@ -11,11 +11,11 @@ const customJestConfig = { moduleNameMapper: { // Handle module aliases (this will be automatically configured for you soon) '^@/(.*)$': '<rootDir>/src/$1', - '^react$': '<rootDir>/node_modules/react', '^react-dom$': '<rootDir>/node_modules/react-dom', '^.+\\.(svg)$': '<rootDir>/mocks/svg.js', '^.+/markdown/terms/terms\\.md$': '<rootDir>/mocks/terms.md.js', isows: '<rootDir>/node_modules/isows/_cjs/index.js', + '^@safe-global/utils/(.*)$': '<rootDir>/../../packages/utils/src/$1', }, // https://github.com/mswjs/jest-fixed-jsdom // without this environment it is basically impossible to run tests with msw diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 4ede22b482..32e7cb09a3 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -6,21 +6,43 @@ import remarkHeadingId from 'remark-heading-id' import createMDX from '@next/mdx' import remarkFrontmatter from 'remark-frontmatter' import remarkMdxFrontmatter from 'remark-mdx-frontmatter' +import { readFile } from 'fs/promises' +import { fileURLToPath } from 'url' const SERVICE_WORKERS_PATH = './src/service-workers' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const pkgPath = path.join(__dirname, 'package.json') +const data = await readFile(pkgPath, 'utf-8') +const pkg = JSON.parse(data) + const withPWA = withPWAInit({ dest: 'public', workboxOptions: { mode: 'production', }, reloadOnOnline: false, - /* Do not precache anything */ - publicExcludes: ['**/*'], + publicExcludes: [], buildExcludes: [/./], customWorkerSrc: SERVICE_WORKERS_PATH, // Prefer InjectManifest for Web Push swSrc: `${SERVICE_WORKERS_PATH}/index.ts`, + + runtimeCaching: [ + { + urlPattern: /\.(js|css|png|jpg|jpeg|gif|webp|svg|ico|ttf|woff|woff2|eot)$/, + handler: 'CacheFirst', + options: { + cacheName: 'static-assets', + expiration: { + maxEntries: 1000, + maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year + }, + }, + }, + ], + + cacheId: pkg.version, }) /** @type {import('next').NextConfig} */ @@ -79,7 +101,6 @@ const nextConfig = { 'bn.js': path.resolve('../../node_modules/bn.js/lib/bn.js'), 'mainnet.json': path.resolve('../..node_modules/@ethereumjs/common/dist.browser/genesisStates/mainnet.json'), '@mui/material$': path.resolve('./src/components/common/Mui'), - react: path.resolve('./node_modules/react'), 'react-dom': path.resolve('./node_modules/react-dom'), } diff --git a/apps/web/package.json b/apps/web/package.json index d0e17b22e9..a16da1243b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -2,7 +2,7 @@ "name": "@safe-global/web", "homepage": "https://github.com/safe-global/safe-wallet-web", "license": "GPL-3.0", - "version": "1.51.3", + "version": "1.66.0", "type": "module", "scripts": { "dev": "next dev", @@ -18,7 +18,7 @@ "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": "npx -y tsx ./scripts/css-vars.ts > ./src/styles/vars.css && prettier -w src/styles/vars.css", - "generate-types": "typechain --target ethers-v6 --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", + "generate-types": "typechain --target ethers-v6 --out-dir ../../packages/utils/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", "after-install": "yarn generate-types", "postinstall": "yarn after-install", "analyze": "cross-env ANALYZE=true yarn build", @@ -28,69 +28,76 @@ "cypress:ci": "yarn cypress:run --config baseUrl=http://localhost:8080 --spec cypress/e2e/smoke/*.cy.js", "serve": "sh -c 'npx -y serve out -p ${REVERSE_PROXY_UI_PORT:=8080}'", "static-serve": "yarn build && yarn serve", - "prepare": "husky", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build --quiet" + "build-storybook": "storybook build --quiet", + "integrity": "node scripts/integrity-hashes.cjs" }, "engines": { "node": ">=18" }, "dependencies": { "@cowprotocol/widget-react": "^0.13.0", + "@datadog/browser-logs": "^6.6.3", "@ducanh2912/next-pwa": "^10.2.9", "@emotion/cache": "^11.13.5", "@emotion/react": "^11.13.5", "@emotion/server": "^11.11.0", "@emotion/styled": "^11.13.5", "@gnosis.pm/zodiac": "^4.0.3", + "@ledgerhq/context-module": "^1.3.1", + "@ledgerhq/device-management-kit": "^0.6.5", + "@ledgerhq/device-signer-kit-ethereum": "^1.3.3", + "@ledgerhq/device-transport-kit-web-hid": "^1.1.0", "@mui/icons-material": "^6.1.6", "@mui/material": "^6.3.0", "@mui/x-date-pickers": "^7.23.3", - "@reduxjs/toolkit": "^2.5.0", - "@reown/walletkit": "^1.1.1", + "@next/third-parties": "^15.2.0", + "@reduxjs/toolkit": "^2.8.2", + "@reown/walletkit": "^1.2.7", "@safe-global/api-kit": "^2.4.6", - "@safe-global/protocol-kit": "patch:@safe-global/protocol-kit@npm%3A4.1.5#~/.yarn/patches/@safe-global-protocol-kit-npm-4.1.5-15df80b7bc.patch", + "@safe-global/protocol-kit": "^5.2.12", "@safe-global/safe-apps-sdk": "^9.1.0", "@safe-global/safe-client-gateway-sdk": "v1.60.1", - "@safe-global/safe-deployments": "patch:@safe-global/safe-deployments@npm%3A1.37.28#~/.yarn/patches/@safe-global-safe-deployments-npm-1.37.28-060d6063bb.patch", - "@safe-global/safe-gateway-typescript-sdk": "3.22.7", - "@safe-global/safe-modules-deployments": "^2.2.6", + "@safe-global/safe-deployments": "patch:@safe-global/safe-deployments@npm%3A1.37.40#~/.yarn/patches/@safe-global-safe-deployments-npm-1.37.40-a424a8f650.patch", + "@safe-global/safe-gateway-typescript-sdk": "3.23.1", + "@safe-global/safe-modules-deployments": "patch:@safe-global/safe-modules-deployments@npm%3A2.2.12#~/.yarn/patches/@safe-global-safe-modules-deployments-npm-2.2.12-c30bb15dee.patch", "@safe-global/store": "workspace:^", "@sentry/react": "^7.91.0", - "@spindl-xyz/attribution-lite": "^1.4.0", - "@walletconnect/core": "^2.17.2", + "@spindl-xyz/attribution-lite": "^1.8.1", + "@walletconnect/core": "^2.20.2", "@walletconnect/utils": "^2.17.3", "@web3-onboard/coinbase": "^2.4.2", - "@web3-onboard/core": "2.21.4", - "@web3-onboard/injected-wallets": "^2.11.2", + "@web3-onboard/core": "2.24.0", + "@web3-onboard/hw-common": "^2.3.3", + "@web3-onboard/injected-wallets": "^2.11.3", "@web3-onboard/walletconnect": "^2.6.1", "blo": "^1.1.1", "classnames": "^2.5.1", "date-fns": "^2.30.0", - "ethers": "^6.13.4", + "ethers": "6.14.3", "exponential-backoff": "^3.1.0", "firebase": "^11.1.0", - "fuse.js": "^7.0.0", + "fuse.js": "^7.1.0", "idb-keyval": "^6.2.1", "js-cookie": "^3.0.1", "lodash": "^4.17.21", - "next": "^15.1.2", + "mixpanel-browser": "^2.66.0", + "next": "patch:next@15.2.4#../../.yarn/patches/next-npm-15.2.4-06a6671f62.patch", "papaparse": "^5.3.2", "qrcode.react": "^3.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-dropzone": "^14.2.3", - "react-gtm-module": "^2.0.11", "react-hook-form": "7.41.1", "react-papaparse": "^4.0.2", "react-redux": "^9.1.2", - "semver": "^7.6.3", + "semver": "^7.7.1", "zodiac-roles-deployments": "^2.3.4" }, "devDependencies": { "@chromatic-com/storybook": "^1.3.1", - "@cowprotocol/app-data": "^2.4.0", - "@eslint/eslintrc": "^3.1.0", + "@cowprotocol/app-data": "^3.1.0", + "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.18.0", "@faker-js/faker": "^9.0.3", "@mdx-js/loader": "^3.0.1", @@ -98,8 +105,8 @@ "@next/bundle-analyzer": "^15.0.4", "@next/mdx": "^15.0.4", "@openzeppelin/contracts": "^4.9.6", - "@safe-global/safe-core-sdk-types": "^5.0.1", "@safe-global/test": "workspace:^", + "@safe-global/types-kit": "^1.0.5", "@sentry/types": "^7.74.0", "@storybook/addon-designs": "^8.0.3", "@storybook/addon-essentials": "^8.0.6", @@ -117,23 +124,23 @@ "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "@typechain/ethers-v6": "^0.5.1", - "@types/jest": "^29.5.4", + "@types/jest": "^29.5.14", "@types/js-cookie": "^3.0.6", "@types/lodash": "^4.14.182", "@types/mdx": "^2.0.13", - "@types/node": "18.11.18", + "@types/node": "22.13.1", "@types/qrcode": "^1.5.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@types/react-gtm-module": "^2.0.3", "@types/semver": "^7.3.10", "@typescript-eslint/eslint-plugin": "^7.6.0", "@typescript-eslint/parser": "^8.18.1", + "cheerio": "^1.0.0", "cross-env": "^7.0.3", "cypress": "^13.15.2", "cypress-file-upload": "^5.0.8", "cypress-visual-regression": "^5.2.2", - "eslint": "^9.19.0", + "eslint": "^9.29.0", "eslint-config-next": "^15.0.4", "eslint-config-prettier": "^9.1.0", "eslint-plugin-no-only-tests": "^3.3.0", @@ -141,24 +148,22 @@ "eslint-plugin-storybook": "^0.11.0", "eslint-plugin-unused-imports": "^4.1.4", "fake-indexeddb": "^4.0.2", - "gray-matter": "^4.0.3", "husky": "^9.0.11", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-fixed-jsdom": "^0.0.9", "mockdate": "^3.0.5", - "msw": "^2.7.0", - "prettier": "^3.3.3", + "msw": "^2.7.3", + "prettier": "^3.4.2", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-heading-id": "^1.0.1", - "remark-mdx-frontmatter": "^5.0.0", + "remark-mdx-frontmatter": "^5.2.0", "storybook": "^8.3.4", "ts-prune": "^0.10.3", "typechain": "^8.3.2", - "typescript": "^5.4.5", - "typescript-plugin-css-modules": "^4.2.2", - "webpack": "^5.97.1" + "typescript": "5.8.3", + "typescript-plugin-css-modules": "^4.2.2" }, "nextBundleAnalysis": { "budget": null, diff --git a/apps/web/public/firebase-messaging-sw.js b/apps/web/public/firebase-messaging-sw.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/web/public/fonts/DMSans600.woff2 b/apps/web/public/fonts/DMSans600.woff2 new file mode 100644 index 0000000000..e1904ebd67 Binary files /dev/null and b/apps/web/public/fonts/DMSans600.woff2 differ diff --git a/apps/web/public/fonts/fonts.css b/apps/web/public/fonts/fonts.css index 4c1a1dc164..faaae2f77c 100644 --- a/apps/web/public/fonts/fonts.css +++ b/apps/web/public/fonts/fonts.css @@ -13,3 +13,11 @@ /** check that the font is loaded on the website. IDEs fail to find the file */ src: url('/fonts/DMSans700.woff2') format('woff2'); } + +@font-face { + font-family: 'DM Sans'; + font-display: swap; + font-weight: 600; + /** check that the font is loaded on the website. IDEs fail to find the file */ + src: url('/fonts/DMSans600.woff2') format('woff2'); +} diff --git a/apps/web/public/images/apps/csv-airdrop-app-logo.svg b/apps/web/public/images/apps/csv-airdrop-app-logo.svg new file mode 100644 index 0000000000..5c75c1cc3d --- /dev/null +++ b/apps/web/public/images/apps/csv-airdrop-app-logo.svg @@ -0,0 +1,15 @@ +<svg width="141" height="141" viewBox="0 0 141 141" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_13672_21587)"> +<path opacity="0.997" d="M70.9392 140.132C109.458 140.132 140.683 108.907 140.683 70.3885C140.683 31.87 109.458 0.644531 70.9392 0.644531C32.4207 0.644531 1.19531 31.87 1.19531 70.3885C1.19531 108.907 32.4207 140.132 70.9392 140.132Z" fill="#7293A0"/> +<path opacity="0.997" d="M70.9078 40.3106C62.936 40.319 55.2936 43.4917 49.6597 49.1315C44.0258 54.7714 40.8613 62.417 40.8613 70.3887C40.8613 74.3386 41.6393 78.2499 43.1509 81.8991C44.6624 85.5484 46.878 88.8642 49.6711 91.6572C52.4641 94.4502 55.78 96.6657 59.4293 98.1773C63.0786 99.6889 66.9899 100.467 70.9399 100.467C78.9171 100.467 86.5675 97.2976 92.2082 91.6569C97.8488 86.0161 101.018 78.3657 101.018 70.3887C101.018 62.4116 97.8488 54.7612 92.2082 49.1205C86.5675 43.4798 78.9171 40.3107 70.9399 40.3106C70.9292 40.3105 70.9185 40.3105 70.9078 40.3106ZM70.94 42.8607C78.2409 42.8608 85.2428 45.7611 90.4054 50.9236C95.5679 56.086 98.4683 63.0878 98.4684 70.3887C98.4685 74.0038 97.7565 77.5835 96.3731 80.9234C94.9897 84.2634 92.962 87.2981 90.4057 89.8544C87.8494 92.4107 84.8147 94.4385 81.4748 95.822C78.1348 97.2055 74.5551 97.9176 70.9399 97.9176C67.3247 97.9177 63.7449 97.2057 60.4048 95.8223C57.0647 94.4388 54.0299 92.4111 51.4735 89.8548C48.9172 87.2984 46.8894 84.2636 45.5059 80.9236C44.1225 77.5836 43.4105 74.0038 43.4105 70.3887C43.4106 66.7736 44.1227 63.1939 45.5062 59.854C46.8897 56.5141 48.9175 53.4794 51.4739 50.9232C54.0302 48.367 57.065 46.3393 60.405 44.9559C63.745 43.5726 67.3248 42.8606 70.94 42.8607Z" fill="white"/> +<path opacity="0.997" d="M70.8991 32.1074C60.7532 32.1182 51.0265 36.1562 43.8561 43.3341C36.6857 50.5121 32.6581 60.2429 32.6582 70.3887C32.6582 75.4158 33.6483 80.3938 35.5722 85.0383C37.496 89.6828 40.3158 93.9029 43.8706 97.4577C47.4254 101.012 51.6456 103.832 56.2902 105.756C60.9348 107.68 65.9128 108.67 70.9401 108.67C81.0929 108.67 90.8297 104.636 98.0087 97.4573C105.188 90.2782 109.221 80.5413 109.221 70.3887C109.221 60.236 105.188 50.4992 98.0087 43.3201C90.8297 36.141 81.0929 32.1077 70.9401 32.1074C70.9264 32.1074 70.9128 32.1074 70.8991 32.1074ZM70.9401 35.353C80.2323 35.3532 89.1438 39.0445 95.7143 45.6149C102.285 52.1853 105.976 61.0967 105.976 70.3887C105.976 74.9897 105.07 79.5457 103.31 83.7965C101.549 88.0474 98.9681 91.9098 95.7147 95.1632C92.4613 98.4167 88.5989 100.998 84.348 102.758C80.0972 104.519 75.5412 105.425 70.9401 105.426C66.3389 105.426 61.7827 104.519 57.5317 102.759C53.2807 100.998 49.4182 98.4172 46.1647 95.1637C42.9111 91.9102 40.3303 88.0477 38.5695 83.7968C36.8088 79.5459 35.9026 74.9898 35.9027 70.3887C35.9027 65.7876 36.809 61.2317 38.5699 56.9809C40.3307 52.7301 42.9116 48.8678 46.1651 45.6144C49.4186 42.3611 53.2811 39.7804 57.532 38.0197C61.7829 36.2591 66.339 35.3529 70.9401 35.353Z" fill="white"/> +<path opacity="0.997" d="M70.8904 23.9043C58.5704 23.9174 46.7595 28.8206 38.0526 37.5367C29.3457 46.2528 24.455 58.0688 24.4551 70.3887C24.455 76.4931 25.6574 82.5378 27.9935 88.1775C30.3295 93.8173 33.7536 98.9417 38.0702 103.258C42.3867 107.575 47.5112 110.999 53.1511 113.335C58.7909 115.671 64.8357 116.873 70.9402 116.873C83.2686 116.873 95.0919 111.975 103.809 103.258C112.527 94.5402 117.424 82.7169 117.424 70.3887C117.424 58.0605 112.527 46.2372 103.809 37.5197C95.0919 28.8022 83.2686 23.9046 70.9402 23.9043C70.9236 23.9043 70.9071 23.9043 70.8904 23.9043ZM70.9403 27.8454C82.2236 27.8456 93.0447 32.3279 101.023 40.3062C109.002 48.2846 113.484 59.1056 113.484 70.3887C113.484 75.9756 112.384 81.5079 110.246 86.6696C108.108 91.8314 104.974 96.5214 101.024 100.472C97.0731 104.423 92.383 107.557 87.2213 109.695C82.0596 111.833 76.5273 112.933 70.9402 112.933C65.353 112.934 59.8205 111.833 54.6586 109.695C49.4967 107.557 44.8065 104.423 40.8558 100.473C36.9051 96.522 33.7712 91.8318 31.6331 86.67C29.4951 81.5082 28.3947 75.9758 28.3948 70.3887C28.3949 64.8017 29.4954 59.2695 31.6335 54.1078C33.7717 48.9462 36.9056 44.2562 40.8563 40.3057C44.807 36.3552 49.4972 33.2215 54.659 31.0835C59.8208 28.9456 65.3532 27.8453 70.9403 27.8454Z" fill="white"/> +<path opacity="0.997" d="M95.1309 58.418V82.3604H127.937V82.2022C128.072 82.307 128.208 82.3599 128.345 82.3604C128.677 82.3604 129.006 82.0508 129.313 81.4492C129.62 80.8476 129.899 79.9658 130.134 78.8541C130.369 77.7425 130.556 76.4228 130.683 74.9704C130.81 73.518 130.875 71.9613 130.875 70.3892C130.875 67.2142 130.609 64.1693 130.134 61.9243C129.66 59.6792 129.016 58.418 128.345 58.418C128.208 58.4185 128.072 58.4714 127.937 58.5762V58.418H95.1309Z" fill="#7293A0"/> +<path opacity="0.997" d="M70.9394 92.2637C83.0207 92.2637 92.8144 82.4699 92.8144 70.3887C92.8144 58.3074 83.0207 48.5137 70.9394 48.5137C58.8582 48.5137 49.0645 58.3074 49.0645 70.3887C49.0645 82.4699 58.8582 92.2637 70.9394 92.2637Z" fill="white"/> +</g> +<defs> +<clipPath id="clip0_13672_21587"> +<rect width="140" height="140" fill="white" transform="translate(0.75 0.75)"/> +</clipPath> +</defs> +</svg> diff --git a/apps/web/public/images/balances/change-down.svg b/apps/web/public/images/balances/change-down.svg new file mode 100644 index 0000000000..1ff1f09a1d --- /dev/null +++ b/apps/web/public/images/balances/change-down.svg @@ -0,0 +1,3 @@ +<svg width="9" height="6" viewBox="0 0 9 6" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M3.85758 5.25361C4.21287 5.58213 4.78798 5.58213 5.14242 5.25361L8.73198 1.9346C9.30454 1.40519 8.89897 0.5 8.08956 0.5L0.910442 0.500788C0.10103 0.500788 -0.304537 1.40598 0.268021 1.9346L3.85758 5.25361Z" fill="currentColor"/> +</svg> \ No newline at end of file diff --git a/apps/web/public/images/balances/change-up.svg b/apps/web/public/images/balances/change-up.svg new file mode 100644 index 0000000000..5167051721 --- /dev/null +++ b/apps/web/public/images/balances/change-up.svg @@ -0,0 +1,3 @@ +<svg width="9" height="6" viewBox="0 0 9 6" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M5.14242 0.746385C4.78713 0.417871 4.21202 0.417871 3.85758 0.746385L0.268022 4.0654C-0.304537 4.59481 0.101027 5.5 0.910443 5.5L8.08956 5.49921C8.89897 5.49921 9.30454 4.59402 8.73198 4.0654L5.14242 0.746385Z" fill="currentColor"/> +</svg> \ No newline at end of file diff --git a/apps/web/public/images/common/bridge.svg b/apps/web/public/images/common/bridge.svg index db47ec7f69..c1bf4e43b7 100644 --- a/apps/web/public/images/common/bridge.svg +++ b/apps/web/public/images/common/bridge.svg @@ -1,6 +1,6 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> -<rect x="14" y="14" width="8" height="8" rx="2" fill="none"/> -<rect x="2" y="2" width="8" height="8" rx="2" fill="none"/> -<path d="M7 14v1a2 2 0 0 0 2 2h1"/> -<path d="M14 7h1a2 2 0 0 1 2 2v1"/> -</svg> \ No newline at end of file +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M5.90039 1C6.67341 1.00021 7.2998 1.62732 7.2998 2.40039V5.90039C7.29959 6.67328 6.67328 7.29959 5.90039 7.2998H2.40039C1.62732 7.2998 1.00021 6.67341 1 5.90039V2.40039C1 1.62719 1.62719 1 2.40039 1H5.90039ZM2.40039 5.90039H5.90039V2.40039H2.40039V5.90039Z" fill="currentColor"/> +<path d="M4.6001 9.00513C4.26951 9.0023 4 9.26952 4 9.60012V10.7661C4 11.4476 4.53279 12 5.19002 12H6.37815C6.71695 12 6.99236 11.7268 6.99506 11.388C6.99778 11.0454 6.72079 10.7661 6.37815 10.7661H5.79751H5.49376H5.48753L5.19002 10.4814V10.1966V9.60012C5.19002 9.27349 4.92672 9.00792 4.6001 9.00513Z" fill="currentColor"/> +<path d="M11.3999 6.99487C11.7305 6.9977 12 6.73048 12 6.39988V5.23385C12 4.55241 11.4672 4 10.81 4H9.62185C9.28305 4 9.00764 4.27323 9.00494 4.61202C9.00222 4.95465 9.27921 5.23385 9.62185 5.23385H10.2025H10.5062H10.5125L10.81 5.51861V5.80338V6.39988C10.81 6.72651 11.0733 6.99208 11.3999 6.99487Z" fill="currentColor"/> +<path d="M13.6004 8.70001C14.3734 8.70022 14.9998 9.32733 14.9998 10.1004V13.6004C14.9996 14.3733 14.3733 14.9996 13.6004 14.9998H10.1004C9.32733 14.9998 8.70022 14.3734 8.70001 13.6004V10.1004C8.70001 9.3272 9.3272 8.70001 10.1004 8.70001H13.6004ZM10.1004 13.6004H13.6004V10.1004H10.1004V13.6004Z" fill="currentColor"/> +</svg> diff --git a/apps/web/public/images/common/cross.svg b/apps/web/public/images/common/cross.svg new file mode 100644 index 0000000000..16817ca06e --- /dev/null +++ b/apps/web/public/images/common/cross.svg @@ -0,0 +1,4 @@ +<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M9.13099 0.64564C9.4393 0.953949 9.4393 1.45382 9.13099 1.76212L1.7622 9.13092C1.45389 9.43923 0.954019 9.43923 0.64571 9.13092C0.337402 8.82261 0.337402 8.32274 0.645711 8.01444L8.01451 0.64564C8.32282 0.337332 8.82268 0.337332 9.13099 0.64564Z" fill="#A1A3A7"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M9.35429 9.13092C9.04598 9.43923 8.54611 9.43923 8.2378 9.13092L0.869008 1.76212C0.5607 1.45382 0.5607 0.953948 0.869009 0.64564C1.17732 0.337331 1.67718 0.337331 1.98549 0.64564L9.35429 8.01444C9.6626 8.32275 9.6626 8.82261 9.35429 9.13092Z" fill="#A1A3A7"/> +</svg> diff --git a/apps/web/public/images/common/earn-illustration-light.png b/apps/web/public/images/common/earn-illustration-light.png new file mode 100644 index 0000000000..d4f3326c54 Binary files /dev/null and b/apps/web/public/images/common/earn-illustration-light.png differ diff --git a/apps/web/public/images/common/earn-light.svg b/apps/web/public/images/common/earn-light.svg new file mode 100644 index 0000000000..10288ec26f --- /dev/null +++ b/apps/web/public/images/common/earn-light.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M8.45644 0.849363C12.2123 1.06813 15.184 4.16134 15.184 7.98511V8.15991H15.1771C15.0828 11.9886 11.9067 15.1246 8.07266 15.1248C4.26935 15.1246 1.07581 12.1258 0.846093 8.4812L0.833398 8.12671C0.791374 3.90931 4.15076 0.805885 8.09023 0.837644L8.45644 0.849363ZM12.2484 3.8562C10.0046 1.56277 6.29855 1.50324 3.95547 3.69409L3.73281 3.91382C1.52188 6.20661 1.82396 9.50534 3.13906 11.2605C3.87522 10.2471 4.60992 9.23559 5.34414 8.22632L5.47109 8.06225C5.51287 8.01273 5.55517 7.96903 5.59707 7.93139C5.6823 7.85488 5.77828 7.79673 5.89199 7.77807C6.00593 7.75949 6.11545 7.78394 6.22012 7.82983C6.32278 7.8749 6.43135 7.94525 6.54922 8.031L8.00723 9.09643C8.77393 8.08941 9.53879 7.08576 10.3334 6.04272C9.90879 6.13109 9.51523 6.21381 9.12148 6.29077L9.12246 6.29175C8.94049 6.32778 8.76429 6.30326 8.62051 6.21557C8.47612 6.1273 8.37807 5.98393 8.33828 5.81225L8.3373 5.81128C8.26363 5.48294 8.45352 5.15575 8.81289 5.08178L10.2006 4.79565C10.6633 4.70178 11.1273 4.61041 11.5912 4.52221C11.7787 4.4874 11.9614 4.49358 12.1049 4.6062C12.2429 4.71471 12.3017 4.88879 12.3324 5.073L12.5346 6.29175C12.6021 6.69747 12.6702 7.10317 12.7377 7.50952L12.7533 7.65893C12.7598 7.80491 12.7367 7.94076 12.6723 8.05542C12.5817 8.21621 12.4262 8.30739 12.2406 8.33276C12.0557 8.35696 11.8821 8.30569 11.7484 8.18725C11.6177 8.07119 11.536 7.90184 11.5033 7.70776C11.4527 7.4024 11.4024 7.09597 11.349 6.77417C10.4995 7.88211 9.65022 8.99027 8.80312 10.1003C8.70375 10.23 8.61281 10.3399 8.5248 10.4197C8.43554 10.5005 8.33543 10.564 8.21426 10.5808C8.09333 10.5974 7.97869 10.5639 7.86953 10.5105C7.81571 10.4841 7.75845 10.4509 7.69863 10.4119L7.50723 10.2781C7.0516 9.94292 6.59368 9.60984 6.12637 9.26928C5.39622 10.2652 4.67377 11.253 3.95156 12.239C6.0355 14.2154 9.6022 14.5768 12.1059 12.2185L12.3207 12.0056C14.3939 9.83289 14.4567 6.35076 12.4555 4.07983L12.2484 3.8562ZM3.92422 12.4539C3.95123 12.4784 3.97977 12.5011 4.00723 12.5251C3.97769 12.4993 3.94738 12.4744 3.91836 12.448L3.92422 12.4539ZM2.83535 11.1287C2.89181 11.2151 2.94942 11.2993 3.01016 11.3796L3.1459 11.5505L3.01504 11.3855C2.95267 11.3033 2.89323 11.2172 2.83535 11.1287ZM8.69668 5.31323C8.74097 5.28629 8.79174 5.26537 8.84902 5.25366L8.75723 5.28198C8.73575 5.29093 8.7157 5.30166 8.69668 5.31323Z" fill="white"/> +</svg> diff --git a/apps/web/public/images/common/earn.svg b/apps/web/public/images/common/earn.svg new file mode 100644 index 0000000000..6551d236fe --- /dev/null +++ b/apps/web/public/images/common/earn.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M8.45644 0.849363C12.2123 1.06813 15.184 4.16134 15.184 7.98511V8.15991H15.1771C15.0828 11.9886 11.9067 15.1246 8.07266 15.1248C4.26935 15.1246 1.07581 12.1258 0.846093 8.4812L0.833398 8.12671C0.791374 3.90931 4.15076 0.805885 8.09023 0.837644L8.45644 0.849363ZM12.2484 3.8562C10.0046 1.56277 6.29855 1.50324 3.95547 3.69409L3.73281 3.91382C1.52188 6.20661 1.82396 9.50534 3.13906 11.2605C3.87522 10.2471 4.60992 9.23559 5.34414 8.22632L5.47109 8.06225C5.51287 8.01273 5.55517 7.96903 5.59707 7.93139C5.6823 7.85488 5.77828 7.79673 5.89199 7.77807C6.00593 7.75949 6.11545 7.78394 6.22012 7.82983C6.32278 7.8749 6.43135 7.94525 6.54922 8.031L8.00723 9.09643C8.77393 8.08941 9.53879 7.08576 10.3334 6.04272C9.90879 6.13109 9.51523 6.21381 9.12148 6.29077L9.12246 6.29175C8.94049 6.32778 8.76429 6.30326 8.62051 6.21557C8.47612 6.1273 8.37807 5.98393 8.33828 5.81225L8.3373 5.81128C8.26363 5.48294 8.45352 5.15575 8.81289 5.08178L10.2006 4.79565C10.6633 4.70178 11.1273 4.61041 11.5912 4.52221C11.7787 4.4874 11.9614 4.49358 12.1049 4.6062C12.2429 4.71471 12.3017 4.88879 12.3324 5.073L12.5346 6.29175C12.6021 6.69747 12.6702 7.10317 12.7377 7.50952L12.7533 7.65893C12.7598 7.80491 12.7367 7.94076 12.6723 8.05542C12.5817 8.21621 12.4262 8.30739 12.2406 8.33276C12.0557 8.35696 11.8821 8.30569 11.7484 8.18725C11.6177 8.07119 11.536 7.90184 11.5033 7.70776C11.4527 7.4024 11.4024 7.09597 11.349 6.77417C10.4995 7.88211 9.65022 8.99027 8.80312 10.1003C8.70375 10.23 8.61281 10.3399 8.5248 10.4197C8.43554 10.5005 8.33543 10.564 8.21426 10.5808C8.09333 10.5974 7.97869 10.5639 7.86953 10.5105C7.81571 10.4841 7.75845 10.4509 7.69863 10.4119L7.50723 10.2781C7.0516 9.94292 6.59368 9.60984 6.12637 9.26928C5.39622 10.2652 4.67377 11.253 3.95156 12.239C6.0355 14.2154 9.6022 14.5768 12.1059 12.2185L12.3207 12.0056C14.3939 9.83289 14.4567 6.35076 12.4555 4.07983L12.2484 3.8562ZM3.92422 12.4539C3.95123 12.4784 3.97977 12.5011 4.00723 12.5251C3.97769 12.4993 3.94738 12.4744 3.91836 12.448L3.92422 12.4539ZM2.83535 11.1287C2.89181 11.2151 2.94942 11.2993 3.01016 11.3796L3.1459 11.5505L3.01504 11.3855C2.95267 11.3033 2.89323 11.2172 2.83535 11.1287ZM8.69668 5.31323C8.74097 5.28629 8.79174 5.26537 8.84902 5.25366L8.75723 5.28198C8.73575 5.29093 8.7157 5.30166 8.69668 5.31323Z" fill="currentColor"/> +</svg> diff --git a/apps/web/public/images/common/edit-owner.svg b/apps/web/public/images/common/edit-owner.svg new file mode 100644 index 0000000000..7ab7752185 --- /dev/null +++ b/apps/web/public/images/common/edit-owner.svg @@ -0,0 +1,5 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M12.003 4C13.657 4 15.003 5.345 15.003 7C15.003 8.654 13.657 10 12.003 10C10.349 10 9.00302 8.654 9.00302 7C9.00302 5.345 10.349 4 12.003 4ZM16.314 12.069C15.734 11.746 15.112 11.504 14.459 11.328C15.97 10.467 17.003 8.86 17.003 7C17.003 4.243 14.76 2 12.003 2C9.24602 2 7.00302 4.243 7.00302 7C7.00302 8.853 8.02802 10.455 9.53002 11.319C5.04702 12.491 3.17002 16.848 2.04002 20.725C1.88602 21.255 2.19002 21.811 2.72002 21.965C2.81402 21.993 2.90802 22.005 3.00002 22.005C3.43402 22.005 3.83302 21.722 3.96002 21.286C5.65202 15.482 8.05702 13.005 12 13.005C13.272 13.005 14.363 13.27 15.337 13.815C15.818 14.083 16.428 13.911 16.698 13.43C16.968 12.948 16.795 12.338 16.314 12.069Z" fill="currentColor"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M13.3987 20.3853L13.4067 21.7273C13.4067 21.8783 13.5297 22.0003 13.6807 22.0003H15.0137C15.0867 22.0003 15.1567 21.9713 15.2087 21.9193L19.2367 17.8913C19.3947 17.7333 19.3947 17.4773 19.2367 17.3193L18.0547 16.1363C17.9107 15.9923 17.6767 15.9923 17.5327 16.1363L13.4797 20.1903C13.4277 20.2413 13.3987 20.3123 13.3987 20.3853Z" fill="currentColor"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M20.4414 16.6861L21.9204 15.2071C22.0314 15.0971 22.0314 14.9161 21.9204 14.8051L20.5924 13.4791C20.4824 13.3681 20.3014 13.3681 20.1904 13.4791L18.7124 14.9571C18.6004 15.0681 18.6004 15.2491 18.7124 15.3601L20.0384 16.6861C20.1494 16.7971 20.3304 16.7971 20.4414 16.6861Z" fill="currentColor"/> +</svg> diff --git a/apps/web/public/images/common/eye.svg b/apps/web/public/images/common/eye.svg new file mode 100644 index 0000000000..c10c9866a6 --- /dev/null +++ b/apps/web/public/images/common/eye.svg @@ -0,0 +1,4 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M12.0002 4.00024C6.80485 4.00024 2.36833 7.31678 0.571777 12.0002C2.36833 16.6835 6.80485 20.0001 12.0002 20.0001C17.1944 20.0001 21.631 16.6835 23.4287 12.0002C21.631 7.31678 17.1944 4.00024 12.0002 4.00024ZM12.0002 6.28593C15.8322 6.28593 19.2539 8.50076 20.9407 12.0002C19.2539 15.4995 15.8322 17.7144 12.0002 17.7144C8.16715 17.7144 4.74547 15.4995 3.05978 12.0002C4.74547 8.50076 8.16715 6.28593 12.0002 6.28593Z" fill="currentColor"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M12.0016 7.41089C9.47587 7.41089 7.43018 9.45772 7.43018 11.9823C7.43018 14.508 9.47587 16.5536 12.0016 16.5536C14.5261 16.5536 16.5729 14.508 16.5729 11.9823C16.5729 9.45772 14.5261 7.41089 12.0016 7.41089ZM12.0016 9.69658C13.2622 9.69658 14.2873 10.7217 14.2873 11.9823C14.2873 13.244 13.2622 14.268 12.0016 14.268C10.741 14.268 9.71592 13.244 9.71592 11.9823C9.71592 10.7217 10.741 9.69658 12.0016 9.69658Z" fill="currentColor"/> +</svg> diff --git a/apps/web/public/images/common/fiat.svg b/apps/web/public/images/common/fiat.svg new file mode 100644 index 0000000000..110d02a14b --- /dev/null +++ b/apps/web/public/images/common/fiat.svg @@ -0,0 +1,4 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M10.9995 18V17H7.99854V15H13.9995V12.999H8.99854C8.44654 12.999 7.99854 12.552 7.99854 11.999V7.99902C7.99854 7.44602 8.44654 6.99902 8.99854 6.99902H10.9995V5.99902H12.9995V6.99902H15.9995V8.99902H9.99854V10.999H14.9995C15.5525 10.999 15.9995 11.447 15.9995 11.999V16C15.9995 16.553 15.5525 17 14.9995 17H12.9995V18H10.9995Z" fill="currentColor"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9995 2C6.47751 2 1.99951 6.478 1.99951 12C1.99951 17.522 6.47751 22 11.9995 22C17.5225 22 21.9995 17.522 21.9995 12C21.9995 6.478 17.5225 2 11.9995 2ZM11.9995 4C16.4105 4 19.9995 7.589 19.9995 12C19.9995 16.411 16.4105 20 11.9995 20C7.58851 20 3.99951 16.411 3.99951 12C3.99951 7.589 7.58851 4 11.9995 4Z" fill="currentColor"/> +</svg> diff --git a/apps/web/public/images/common/fiat2.svg b/apps/web/public/images/common/fiat2.svg new file mode 100644 index 0000000000..96707aae51 --- /dev/null +++ b/apps/web/public/images/common/fiat2.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M7.001 15.0025V14.0025H4V12.0025H10.002V9.00149H5C4.448 9.00149 4 8.55349 4 8.00149V3.00049C4 2.44749 4.448 2.00049 5 2.00049H7.001V1.00049H9.001V2.00049H12.002V4.00049H6V7.00149H11.002C11.554 7.00149 12.002 7.44849 12.002 8.00149V13.0025C12.002 13.5555 11.554 14.0025 11.002 14.0025H9.001V15.0025H7.001Z" fill="currentColor"/> +</svg> diff --git a/apps/web/public/images/common/kiln-symbol.svg b/apps/web/public/images/common/kiln-symbol.svg new file mode 100644 index 0000000000..abaf2c03f6 --- /dev/null +++ b/apps/web/public/images/common/kiln-symbol.svg @@ -0,0 +1,4 @@ +<svg width="850" height="850" viewBox="0 0 850 850" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M98.1725 314.383C104.283 308.273 114.193 308.281 120.293 314.402L414.383 609.467C420.491 615.595 430.414 615.595 436.522 609.467L730.606 314.41C736.706 308.29 746.616 308.281 752.726 314.392L819.58 381.245C843.994 405.659 843.994 445.242 819.58 469.655L469.65 819.585C445.236 843.999 405.654 843.999 381.24 819.585L31.3103 469.655C6.89655 445.242 6.89656 405.659 31.3103 381.245L98.1725 314.383Z" fill="black"/> +<path d="M290.738 365.58C284.63 371.708 274.706 371.708 268.598 365.58L168.995 265.646C162.909 259.54 162.917 249.658 169.014 243.562L381.265 31.3103C405.679 6.89655 445.261 6.89656 469.675 31.3103L681.935 243.57C688.032 249.667 688.04 259.548 681.954 265.655L582.358 365.58C576.25 371.708 566.326 371.708 560.219 365.58L436.547 241.499C430.439 235.371 420.516 235.371 414.408 241.499L290.738 365.58Z" fill="#FF6521"/> +</svg> diff --git a/apps/web/public/images/common/morpho-symbol.svg b/apps/web/public/images/common/morpho-symbol.svg new file mode 100644 index 0000000000..6ec9a08527 --- /dev/null +++ b/apps/web/public/images/common/morpho-symbol.svg @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg width="74" height="69" viewBox="0 0 74 69" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_1345_521)"> +<path d="M8.64069 46.6257V66.4908C8.64069 67.7137 9.67564 68.2213 9.99762 68.3366C10.3196 68.4751 11.4005 68.775 12.3664 67.8752L27.3944 53.4333C28.6742 52.2036 29.9093 50.9109 30.8375 49.3984C31.2744 48.6874 31.4555 48.2869 31.4555 48.2869C32.3755 46.418 32.3755 44.6184 31.4782 42.8187C30.1448 40.1424 26.7407 37.4198 21.6118 34.8357L12.8494 39.7271C10.2506 41.2037 8.64069 43.8109 8.64069 46.6257Z" fill="#004EC3"/> +<path d="M0 2.44212V23.2764C0 25.8835 1.74794 28.1907 4.23184 28.929C12.6954 31.3747 27.4375 36.6351 31.0024 44.3644C31.462 45.3795 31.7381 46.3717 31.8307 47.4099C34.1994 43.0954 35.2803 38.1348 34.7744 33.1051C34.0841 25.9758 30.3122 19.4925 24.4248 15.3626L3.42686 0.66555C3.05883 0.388682 2.62187 0.250244 2.1849 0.250244C1.81688 0.250244 1.4949 0.319463 1.14994 0.504042C0.459945 0.896269 0 1.61152 0 2.44212Z" fill="#2470FF"/> +<path d="M64.5352 46.6257V66.4908C64.5352 67.7137 63.5006 68.2213 63.1782 68.3366C62.8567 68.4751 61.775 68.775 60.8095 67.8752L45.4317 53.0975C44.3849 52.0913 43.3853 51.0273 42.5968 49.808C41.9723 48.843 41.7206 48.2869 41.7206 48.2869C40.8005 46.418 40.8005 44.6184 41.697 42.8187C43.0312 40.1424 46.4354 37.4198 51.5635 34.8357L60.3263 39.7271C62.9484 41.2037 64.5352 43.8109 64.5352 46.6257Z" fill="#004EC3"/> +<path d="M73.19 2.44065V23.2749C73.19 25.882 71.4416 28.1892 68.9576 28.9276C60.4944 31.3732 45.7524 36.6337 42.1875 44.3629C41.7271 45.3781 41.451 46.3702 41.3592 47.4085C38.9905 43.094 37.9096 38.1334 38.4155 33.1037C39.1049 25.9743 42.8769 19.4911 48.7651 15.3611L69.7631 0.664077C70.131 0.387209 70.5679 0.248779 71.0047 0.248779C71.3726 0.248779 71.695 0.317998 72.0393 0.502577C72.7295 0.894804 73.19 1.61005 73.19 2.44065Z" fill="#2470FF"/> +</g> +<defs> +<clipPath id="clip0_1345_521"> +<rect width="74" height="69" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/apps/web/public/images/common/no-assets.svg b/apps/web/public/images/common/no-assets.svg new file mode 100644 index 0000000000..95e05110de --- /dev/null +++ b/apps/web/public/images/common/no-assets.svg @@ -0,0 +1,13 @@ +<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M46.7256 57.1451C60.2124 57.1451 71.1456 46.2119 71.1456 32.7251C71.1456 19.2383 60.2124 8.30514 46.7256 8.30514C33.2388 8.30514 22.3056 19.2383 22.3056 32.7251C22.3056 46.2119 33.2388 57.1451 46.7256 57.1451ZM71.8106 32.7251C71.8106 46.5792 60.5797 57.8101 46.7256 57.8101C32.8716 57.8101 21.6406 46.5792 21.6406 32.7251C21.6406 18.8711 32.8716 7.64014 46.7256 7.64014C60.5797 7.64014 71.8106 18.8711 71.8106 32.7251Z" fill="#A1A3A7"/> +<path d="M32.9098 71.9401C46.4545 71.9401 57.4348 60.9599 57.4348 47.4151C57.4348 33.8704 46.4545 22.8901 32.9098 22.8901C19.365 22.8901 8.38477 33.8704 8.38477 47.4151C8.38477 60.9599 19.365 71.9401 32.9098 71.9401Z" fill="#F4F4F4"/> +<path d="M33.0256 72.0752H32.9056C32.7256 72.0752 32.5806 71.9252 32.5806 71.7502C32.5806 71.5702 32.7106 71.4452 32.9056 71.4252H33.0106C33.0106 71.4252 33.0106 71.4252 33.0156 71.4252C33.7956 71.4252 34.5856 71.3852 35.3656 71.3102C35.5406 71.2902 35.7006 71.4252 35.7206 71.6002C35.7406 71.7802 35.6056 71.9402 35.4306 71.9552C34.6356 72.0302 33.8206 72.0702 33.0206 72.0702L33.0256 72.0752ZM30.9156 71.9852C30.9156 71.9852 30.8956 71.9852 30.8856 71.9852C30.0456 71.9152 29.2006 71.8002 28.3756 71.6402C28.2006 71.6052 28.0856 71.4352 28.1156 71.2602C28.1506 71.0852 28.3206 70.9652 28.4956 71.0002C29.3006 71.1502 30.1206 71.2652 30.9356 71.3352C31.1156 71.3502 31.2456 71.5102 31.2306 71.6852C31.2156 71.8552 31.0756 71.9802 30.9056 71.9802L30.9156 71.9852ZM37.3856 71.6852C37.2306 71.6852 37.0956 71.5752 37.0656 71.4202C37.0356 71.2452 37.1506 71.0752 37.3256 71.0402C38.1306 70.8952 38.9406 70.7052 39.7256 70.4802C39.9006 70.4302 40.0806 70.5302 40.1256 70.7002C40.1756 70.8752 40.0756 71.0552 39.9056 71.1052C39.1006 71.3402 38.2706 71.5302 37.4406 71.6802C37.4206 71.6802 37.4006 71.6852 37.3806 71.6852H37.3856ZM26.4906 71.1952C26.4606 71.1952 26.4306 71.1952 26.4056 71.1852C25.5956 70.9602 24.7856 70.6902 24.0056 70.3852C23.8406 70.3202 23.7556 70.1302 23.8206 69.9652C23.8856 69.8002 24.0756 69.7152 24.2406 69.7802C25.0056 70.0802 25.7906 70.3402 26.5806 70.5602C26.7556 70.6102 26.8556 70.7852 26.8056 70.9602C26.7656 71.1052 26.6356 71.2002 26.4906 71.2002V71.1952ZM41.7106 70.4852C41.5806 70.4852 41.4556 70.4052 41.4056 70.2752C41.3406 70.1052 41.4256 69.9202 41.5956 69.8552C42.3606 69.5652 43.1206 69.2302 43.8506 68.8602C44.0156 68.7802 44.2056 68.8452 44.2856 69.0052C44.3656 69.1652 44.3006 69.3602 44.1406 69.4402C43.3906 69.8202 42.6106 70.1652 41.8256 70.4602C41.7856 70.4752 41.7506 70.4802 41.7106 70.4802V70.4852ZM22.2856 69.6002C22.2356 69.6002 22.1906 69.5902 22.1406 69.5652C21.3856 69.1952 20.6406 68.7802 19.9256 68.3352C19.7756 68.2402 19.7256 68.0402 19.8206 67.8852C19.9156 67.7352 20.1156 67.6852 20.2706 67.7802C20.9656 68.2152 21.6906 68.6152 22.4256 68.9752C22.5856 69.0552 22.6556 69.2502 22.5756 69.4102C22.5206 69.5252 22.4056 69.5902 22.2856 69.5902V69.6002ZM45.7406 68.5052C45.6306 68.5052 45.5256 68.4502 45.4606 68.3502C45.3656 68.1952 45.4156 67.9952 45.5706 67.9052C46.2656 67.4802 46.9506 67.0102 47.6056 66.5152C47.7456 66.4052 47.9506 66.4352 48.0606 66.5752C48.1706 66.7202 48.1406 66.9202 48.0006 67.0302C47.3306 67.5402 46.6256 68.0202 45.9106 68.4602C45.8556 68.4902 45.8006 68.5102 45.7406 68.5102V68.5052ZM18.4506 67.2652C18.3856 67.2652 18.3156 67.2452 18.2556 67.2002C17.5806 66.7002 16.9206 66.1552 16.3056 65.5902C16.1756 65.4702 16.1656 65.2652 16.2856 65.1302C16.4056 64.9952 16.6106 64.9902 16.7456 65.1102C17.3506 65.6652 17.9906 66.1902 18.6456 66.6802C18.7906 66.7852 18.8206 66.9902 18.7106 67.1352C18.6456 67.2202 18.5506 67.2652 18.4506 67.2652ZM49.3456 65.8202C49.2556 65.8202 49.1656 65.7852 49.1056 65.7102C48.9856 65.5752 48.9956 65.3702 49.1306 65.2502C49.7406 64.7052 50.3256 64.1202 50.8756 63.5102C50.9956 63.3752 51.2006 63.3702 51.3356 63.4902C51.4706 63.6102 51.4806 63.8152 51.3556 63.9502C50.7906 64.5752 50.1856 65.1752 49.5606 65.7352C49.5006 65.7902 49.4206 65.8202 49.3456 65.8202ZM15.1056 64.2652C15.0206 64.2652 14.9306 64.2302 14.8656 64.1602C14.2956 63.5452 13.7456 62.8902 13.2406 62.2202C13.1306 62.0752 13.1606 61.8752 13.3056 61.7652C13.4506 61.6552 13.6556 61.6852 13.7606 61.8302C14.2556 62.4852 14.7856 63.1202 15.3456 63.7202C15.4656 63.8502 15.4606 64.0552 15.3306 64.1802C15.2706 64.2402 15.1906 64.2652 15.1106 64.2652H15.1056ZM52.3956 62.5252C52.3256 62.5252 52.2556 62.5052 52.1956 62.4602C52.0556 62.3502 52.0256 62.1452 52.1356 62.0052C52.6356 61.3552 53.1056 60.6702 53.5356 59.9752C53.6306 59.8252 53.8306 59.7752 53.9806 59.8702C54.1356 59.9652 54.1806 60.1652 54.0856 60.3152C53.6456 61.0302 53.1606 61.7302 52.6456 62.4002C52.5806 62.4852 52.4856 62.5252 52.3856 62.5252H52.3956ZM12.3656 60.7052C12.2556 60.7052 12.1506 60.6502 12.0906 60.5552C11.6406 59.8452 11.2206 59.1052 10.8456 58.3502C10.7656 58.1902 10.8306 57.9952 10.9906 57.9152C11.1556 57.8352 11.3456 57.9002 11.4256 58.0602C11.7906 58.7952 12.1956 59.5152 12.6356 60.2052C12.7306 60.3552 12.6856 60.5602 12.5356 60.6552C12.4806 60.6902 12.4206 60.7052 12.3606 60.7052H12.3656ZM54.7956 58.7252C54.7456 58.7252 54.6956 58.7152 54.6506 58.6902C54.4906 58.6102 54.4256 58.4152 54.5106 58.2552C54.8806 57.5252 55.2206 56.7652 55.5156 56.0052C55.5806 55.8352 55.7706 55.7552 55.9356 55.8202C56.1006 55.8852 56.1856 56.0752 56.1206 56.2402C55.8156 57.0252 55.4706 57.8002 55.0906 58.5502C55.0306 58.6652 54.9206 58.7252 54.8006 58.7252H54.7956ZM10.3206 56.7052C10.1906 56.7052 10.0706 56.6302 10.0206 56.5002C9.70563 55.7152 9.43563 54.9102 9.20563 54.1052C9.15563 53.9302 9.25562 53.7552 9.43062 53.7052C9.60562 53.6552 9.78563 53.7552 9.83063 53.9302C10.0556 54.7152 10.3206 55.5002 10.6256 56.2652C10.6906 56.4302 10.6106 56.6202 10.4456 56.6852C10.4056 56.7002 10.3656 56.7102 10.3256 56.7102L10.3206 56.7052ZM56.4606 54.5502C56.4306 54.5502 56.4006 54.5502 56.3706 54.5352C56.2006 54.4852 56.1006 54.3052 56.1506 54.1302C56.3806 53.3452 56.5756 52.5352 56.7256 51.7352C56.7556 51.5602 56.9256 51.4402 57.1056 51.4752C57.2806 51.5102 57.4006 51.6752 57.3656 51.8552C57.2106 52.6802 57.0156 53.5102 56.7806 54.3152C56.7406 54.4552 56.6106 54.5502 56.4706 54.5502H56.4606ZM9.05063 52.4002C8.90063 52.4002 8.76063 52.2952 8.73063 52.1402C8.57063 51.3152 8.44563 50.4752 8.37063 49.6352C8.35563 49.4552 8.48563 49.3002 8.66563 49.2802C8.83563 49.2702 9.00063 49.3952 9.02063 49.5752C9.09563 50.3902 9.21563 51.2152 9.37063 52.0152C9.40562 52.1902 9.29062 52.3602 9.11562 52.3952C9.09562 52.3952 9.07563 52.4002 9.05063 52.4002ZM57.3256 50.1452C57.3256 50.1452 57.3056 50.1452 57.2906 50.1452C57.1106 50.1252 56.9806 49.9702 57.0006 49.7902C57.0856 48.9802 57.1256 48.1502 57.1256 47.3302C57.1256 47.1452 57.1256 46.9602 57.1206 46.7752C57.1206 46.5952 57.2606 46.4452 57.4356 46.4402C57.6356 46.4402 57.7656 46.5802 57.7706 46.7552C57.7706 46.9452 57.7756 47.1352 57.7756 47.3302C57.7756 48.1752 57.7306 49.0252 57.6456 49.8602C57.6306 50.0252 57.4856 50.1502 57.3206 50.1502L57.3256 50.1452ZM8.59063 47.9352C8.41063 47.9352 8.26562 47.7902 8.26562 47.6152V47.3302C8.26562 46.5802 8.29562 45.8252 8.36562 45.0902C8.38062 44.9102 8.54562 44.7802 8.72062 44.7952C8.90062 44.8102 9.03062 44.9702 9.01562 45.1502C8.95063 45.8702 8.92063 46.6052 8.92063 47.3352V47.6152C8.92063 47.7952 8.78063 47.9402 8.60063 47.9452L8.59063 47.9352ZM57.3156 45.0952C57.1506 45.0952 57.0106 44.9702 56.9906 44.8052C56.9056 43.9902 56.7806 43.1702 56.6106 42.3702C56.5756 42.1952 56.6856 42.0202 56.8606 41.9852C57.0406 41.9502 57.2106 42.0602 57.2456 42.2352C57.4156 43.0602 57.5506 43.9002 57.6356 44.7352C57.6556 44.9152 57.5256 45.0752 57.3456 45.0902C57.3356 45.0902 57.3206 45.0902 57.3106 45.0902L57.3156 45.0952ZM8.94563 43.4602C8.94563 43.4602 8.91062 43.4602 8.89062 43.4602C8.71562 43.4302 8.59563 43.2602 8.62563 43.0852C8.77063 42.2552 8.95563 41.4252 9.18563 40.6152C9.23563 40.4452 9.41063 40.3402 9.58563 40.3902C9.76063 40.4402 9.86063 40.6202 9.81063 40.7902C9.59063 41.5752 9.40562 42.3852 9.26562 43.1952C9.24063 43.3552 9.10063 43.4652 8.94563 43.4652V43.4602ZM56.4406 40.6902C56.3006 40.6902 56.1706 40.6002 56.1306 40.4552C55.9006 39.6752 55.6206 38.8902 55.3106 38.1302C55.2406 37.9652 55.3206 37.7752 55.4856 37.7052C55.6506 37.6352 55.8406 37.7152 55.9106 37.8802C56.2306 38.6602 56.5156 39.4652 56.7556 40.2652C56.8056 40.4352 56.7106 40.6202 56.5356 40.6702C56.5056 40.6802 56.4756 40.6852 56.4456 40.6852L56.4406 40.6902ZM10.1156 39.1252C10.0756 39.1252 10.0406 39.1202 10.0006 39.1052C9.83063 39.0402 9.74563 38.8552 9.81063 38.6852C10.1056 37.9002 10.4406 37.1152 10.8156 36.3602C10.8956 36.2002 11.0906 36.1352 11.2506 36.2152C11.4106 36.2952 11.4756 36.4902 11.3956 36.6502C11.0306 37.3852 10.7006 38.1502 10.4156 38.9152C10.3656 39.0452 10.2406 39.1252 10.1106 39.1252H10.1156ZM54.7656 36.5202C54.6456 36.5202 54.5356 36.4552 54.4756 36.3452C54.1006 35.6202 53.6856 34.9002 53.2406 34.2102C53.1406 34.0602 53.1856 33.8602 53.3356 33.7602C53.4856 33.6602 53.6906 33.7052 53.7856 33.8552C54.2456 34.5602 54.6706 35.3002 55.0556 36.0452C55.1356 36.2052 55.0756 36.4002 54.9156 36.4852C54.8706 36.5102 54.8156 36.5202 54.7656 36.5202ZM12.0656 35.0752C12.0106 35.0752 11.9506 35.0602 11.9006 35.0302C11.7456 34.9352 11.6956 34.7402 11.7906 34.5852C12.2256 33.8652 12.7006 33.1602 13.2056 32.4852C13.3106 32.3402 13.5156 32.3102 13.6606 32.4202C13.8056 32.5302 13.8356 32.7302 13.7256 32.8752C13.2356 33.5302 12.7706 34.2202 12.3506 34.9202C12.2906 35.0202 12.1806 35.0752 12.0706 35.0752H12.0656ZM52.3556 32.7302C52.2606 32.7302 52.1606 32.6852 52.1006 32.6052C51.6006 31.9602 51.0606 31.3302 50.4956 30.7352C50.3706 30.6052 50.3756 30.4002 50.5056 30.2752C50.6356 30.1502 50.8406 30.1552 50.9656 30.2852C51.5456 30.8952 52.1006 31.5452 52.6106 32.2052C52.7206 32.3452 52.6956 32.5502 52.5506 32.6602C52.4906 32.7052 52.4206 32.7302 52.3506 32.7302H52.3556ZM14.7256 31.4552C14.6506 31.4552 14.5706 31.4302 14.5106 31.3752C14.3756 31.2552 14.3656 31.0502 14.4856 30.9152C15.0456 30.2852 15.6406 29.6802 16.2606 29.1102C16.3956 28.9902 16.6006 28.9952 16.7206 29.1302C16.8406 29.2602 16.8356 29.4702 16.7006 29.5902C16.0956 30.1452 15.5156 30.7352 14.9706 31.3502C14.9056 31.4252 14.8156 31.4602 14.7256 31.4602V31.4552ZM49.2956 29.4402C49.2206 29.4402 49.1406 29.4152 49.0806 29.3552C48.4706 28.8052 47.8206 28.2852 47.1606 27.8052C47.0156 27.7002 46.9806 27.4952 47.0906 27.3502C47.1956 27.2052 47.4006 27.1702 47.5456 27.2802C48.2256 27.7702 48.8856 28.3052 49.5156 28.8702C49.6506 28.9902 49.6606 29.1952 49.5406 29.3302C49.4756 29.4002 49.3856 29.4402 49.3006 29.4402H49.2956ZM18.0006 28.3802C17.9056 28.3802 17.8106 28.3352 17.7456 28.2552C17.6356 28.1152 17.6606 27.9102 17.8006 27.8002C18.4656 27.2852 19.1606 26.7952 19.8756 26.3452C20.0306 26.2502 20.2306 26.2952 20.3256 26.4502C20.4206 26.6002 20.3756 26.8052 20.2206 26.9002C19.5256 27.3352 18.8456 27.8102 18.2006 28.3152C18.1406 28.3602 18.0706 28.3852 18.0006 28.3852V28.3802ZM45.6856 26.7652C45.6306 26.7652 45.5706 26.7502 45.5156 26.7202C44.8156 26.2952 44.0856 25.9002 43.3456 25.5502C43.1856 25.4752 43.1156 25.2802 43.1906 25.1152C43.2706 24.9552 43.4656 24.8852 43.6256 24.9602C44.3856 25.3202 45.1356 25.7252 45.8556 26.1652C46.0106 26.2602 46.0556 26.4602 45.9656 26.6102C45.9056 26.7102 45.7956 26.7652 45.6856 26.7652ZM21.7806 25.9552C21.6606 25.9552 21.5506 25.8902 21.4906 25.7802C21.4056 25.6202 21.4706 25.4252 21.6306 25.3402C22.3756 24.9552 23.1506 24.6002 23.9356 24.2902C24.1006 24.2252 24.2906 24.3052 24.3556 24.4752C24.4206 24.6452 24.3406 24.8302 24.1706 24.8952C23.4106 25.1952 22.6556 25.5402 21.9256 25.9152C21.8756 25.9402 21.8256 25.9502 21.7756 25.9502L21.7806 25.9552ZM41.6456 24.7952C41.6056 24.7952 41.5706 24.7902 41.5306 24.7752C40.7656 24.4852 39.9756 24.2352 39.1806 24.0252C39.0056 23.9802 38.9056 23.8002 38.9506 23.6302C38.9956 23.4552 39.1756 23.3552 39.3456 23.4002C40.1606 23.6152 40.9706 23.8752 41.7556 24.1702C41.9256 24.2352 42.0106 24.4202 41.9456 24.5902C41.8956 24.7202 41.7706 24.8002 41.6406 24.8002L41.6456 24.7952ZM25.9406 24.2652C25.8006 24.2652 25.6706 24.1752 25.6306 24.0352C25.5806 23.8652 25.6756 23.6802 25.8456 23.6302C26.6506 23.3852 27.4756 23.1852 28.3056 23.0252C28.4806 22.9902 28.6506 23.1052 28.6856 23.2852C28.7206 23.4602 28.6056 23.6302 28.4256 23.6652C27.6206 23.8202 26.8156 24.0202 26.0306 24.2552C26.0006 24.2652 25.9656 24.2702 25.9356 24.2702L25.9406 24.2652ZM37.3156 23.6052C37.3156 23.6052 37.2756 23.6052 37.2606 23.6052C36.4556 23.4602 35.6306 23.3602 34.8156 23.3002C34.6356 23.2852 34.5006 23.1302 34.5156 22.9502C34.5306 22.7702 34.6856 22.6402 34.8656 22.6502C35.7056 22.7102 36.5506 22.8202 37.3756 22.9652C37.5506 22.9952 37.6706 23.1652 37.6406 23.3402C37.6106 23.4952 37.4756 23.6102 37.3206 23.6102L37.3156 23.6052ZM30.3456 23.3702C30.1806 23.3702 30.0406 23.2452 30.0206 23.0802C30.0006 22.9002 30.1306 22.7402 30.3106 22.7202C31.1456 22.6302 31.9956 22.5802 32.8356 22.5752C33.0156 22.5752 33.1606 22.7202 33.1606 22.9002C33.1606 23.0802 33.0156 23.2252 32.8356 23.2252C32.0156 23.2302 31.1856 23.2802 30.3756 23.3652C30.3656 23.3652 30.3506 23.3652 30.3406 23.3652L30.3456 23.3702Z" fill="#A1A3A7"/> +<path d="M28.9552 58.755C35.1932 58.755 40.2502 53.6981 40.2502 47.46C40.2502 41.222 35.1932 36.165 28.9552 36.165C22.7171 36.165 17.6602 41.222 17.6602 47.46C17.6602 53.6981 22.7171 58.755 28.9552 58.755Z" fill="#F4F4F4"/> +<path d="M28.955 59.0798C22.545 59.0798 17.335 53.8648 17.335 47.4598C17.335 41.0548 22.55 35.8398 28.955 35.8398C35.36 35.8398 40.575 41.0548 40.575 47.4598C40.575 53.8648 35.36 59.0798 28.955 59.0798ZM28.955 36.4898C22.905 36.4898 17.985 41.4098 17.985 47.4598C17.985 53.5098 22.905 58.4298 28.955 58.4298C35.005 58.4298 39.925 53.5098 39.925 47.4598C39.925 41.4098 35.005 36.4898 28.955 36.4898Z" fill="#A1A3A7"/> +<path d="M28.9548 54.8247C33.0224 54.8247 36.3198 51.5273 36.3198 47.4597C36.3198 43.3921 33.0224 40.0947 28.9548 40.0947C24.8873 40.0947 21.5898 43.3921 21.5898 47.4597C21.5898 51.5273 24.8873 54.8247 28.9548 54.8247Z" fill="#F4F4F4"/> +<path d="M28.9556 55.1549C24.7156 55.1549 21.2656 51.7049 21.2656 47.4649C21.2656 43.2249 24.7156 39.7749 28.9556 39.7749C33.1956 39.7749 36.6456 43.2249 36.6456 47.4649C36.6456 51.7049 33.1956 55.1549 28.9556 55.1549ZM28.9556 40.4199C25.0706 40.4199 21.9156 43.5799 21.9156 47.4599C21.9156 51.3399 25.0756 54.4999 28.9556 54.4999C32.8356 54.4999 35.9956 51.3399 35.9956 47.4599C35.9956 43.5799 32.8356 40.4199 28.9556 40.4199Z" fill="#A1A3A7"/> +<path d="M36.9552 58.755C43.1932 58.755 48.2502 53.6981 48.2502 47.46C48.2502 41.222 43.1932 36.165 36.9552 36.165C30.7171 36.165 25.6602 41.222 25.6602 47.46C25.6602 53.6981 30.7171 58.755 36.9552 58.755Z" fill="#F4F4F4"/> +<path d="M36.955 59.0798C30.545 59.0798 25.335 53.8648 25.335 47.4598C25.335 41.0548 30.55 35.8398 36.955 35.8398C43.36 35.8398 48.575 41.0548 48.575 47.4598C48.575 53.8648 43.36 59.0798 36.955 59.0798ZM36.955 36.4898C30.905 36.4898 25.985 41.4098 25.985 47.4598C25.985 53.5098 30.905 58.4298 36.955 58.4298C43.005 58.4298 47.925 53.5098 47.925 47.4598C47.925 41.4098 43.005 36.4898 36.955 36.4898Z" fill="#A1A3A7"/> +<path d="M36.9548 54.8247C41.0224 54.8247 44.3198 51.5273 44.3198 47.4597C44.3198 43.3921 41.0224 40.0947 36.9548 40.0947C32.8873 40.0947 29.5898 43.3921 29.5898 47.4597C29.5898 51.5273 32.8873 54.8247 36.9548 54.8247Z" fill="#F4F4F4"/> +<path d="M36.9556 55.1549C32.7156 55.1549 29.2656 51.7049 29.2656 47.4649C29.2656 43.2249 32.7156 39.7749 36.9556 39.7749C41.1956 39.7749 44.6456 43.2249 44.6456 47.4649C44.6456 51.7049 41.1956 55.1549 36.9556 55.1549ZM36.9556 40.4199C33.0706 40.4199 29.9156 43.5799 29.9156 47.4599C29.9156 51.3399 33.0756 54.4999 36.9556 54.4999C40.8356 54.4999 43.9956 51.3399 43.9956 47.4599C43.9956 43.5799 40.8356 40.4199 36.9556 40.4199Z" fill="#A1A3A7"/> +</svg> diff --git a/apps/web/public/images/common/no-txs.svg b/apps/web/public/images/common/no-txs.svg new file mode 100644 index 0000000000..ae459d7e85 --- /dev/null +++ b/apps/web/public/images/common/no-txs.svg @@ -0,0 +1,22 @@ +<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M31.1006 6.47168C44.771 6.47168 55.8535 17.5542 55.8535 31.2246C55.8535 44.8951 44.7711 55.9775 31.1006 55.9775C17.4302 55.9775 6.34766 44.8951 6.34766 31.2246C6.34768 17.5542 17.4302 6.4717 31.1006 6.47168Z" stroke="#A1A3A7" stroke-width="0.665"/> +<path d="M38.7754 69.5302C52.0304 69.5302 62.7754 58.7852 62.7754 45.5303C62.7754 32.2754 52.0304 21.5303 38.7754 21.5303C25.5206 21.5303 14.7754 32.2754 14.7754 45.5303C14.7754 58.7852 25.5206 69.5302 38.7754 69.5302Z" fill="white"/> +<path d="M38.7756 20.645C25.0306 20.645 13.8906 31.785 13.8906 45.53C13.8906 59.275 25.0306 70.415 38.7756 70.415C52.5206 70.415 63.6606 59.275 63.6606 45.53C63.6606 31.785 52.5206 20.645 38.7756 20.645ZM38.7756 69.53C25.5456 69.53 14.7756 58.765 14.7756 45.53C14.7756 32.295 25.5406 21.53 38.7756 21.53C52.0106 21.53 62.7756 32.295 62.7756 45.53C62.7756 58.765 52.0106 69.53 38.7756 69.53Z" fill="#A1A3A7"/> +<path d="M38.7748 69.9002C25.9298 69.9002 15.6098 59.3702 14.0298 45.5303C13.9348 46.4903 13.8848 47.4653 13.8848 48.4553C13.8848 63.5302 25.0998 74.0002 38.8398 74.0002C52.5798 74.0002 62.9348 63.6752 63.6548 48.4553C63.6998 47.4703 63.6048 46.4903 63.5098 45.5303C62.1798 59.2302 51.6098 69.9002 38.7648 69.9002H38.7748Z" fill="#A1A3A7"/> +<path d="M39.1648 27.0802C38.9848 27.0802 38.8398 26.9352 38.8398 26.7552V21.1102C38.8398 20.9302 38.9848 20.7852 39.1648 20.7852C39.3448 20.7852 39.4898 20.9302 39.4898 21.1102V26.7552C39.4898 26.9352 39.3448 27.0802 39.1648 27.0802Z" fill="#A1A3A7"/> +<path d="M39.1648 69.9649C38.9848 69.9649 38.8398 69.8199 38.8398 69.6399V63.9949C38.8398 63.8149 38.9848 63.6699 39.1648 63.6699C39.3448 63.6699 39.4898 63.8149 39.4898 63.9949V69.6399C39.4898 69.8199 39.3448 69.9649 39.1648 69.9649Z" fill="#A1A3A7"/> +<path d="M39.1648 45.6102C38.9848 45.6102 38.8398 45.4652 38.8398 45.2852V31.6102C38.8398 31.4302 38.9848 31.2852 39.1648 31.2852C39.3448 31.2852 39.4898 31.4302 39.4898 31.6102V45.2852C39.4898 45.4652 39.3448 45.6102 39.1648 45.6102Z" fill="#A1A3A7"/> +<path d="M62.8645 45.6353H57.2195C57.0395 45.6353 56.8945 45.4904 56.8945 45.3104C56.8945 45.1304 57.0395 44.9854 57.2195 44.9854H62.8645C63.0445 44.9854 63.1895 45.1304 63.1895 45.3104C63.1895 45.4904 63.0445 45.6353 62.8645 45.6353Z" fill="#A1A3A7"/> +<path d="M19.9798 45.6353H14.3348C14.1548 45.6353 14.0098 45.4904 14.0098 45.3104C14.0098 45.1304 14.1548 44.9854 14.3348 44.9854H19.9798C20.1598 44.9854 20.3048 45.1304 20.3048 45.3104C20.3048 45.4904 20.1598 45.6353 19.9798 45.6353Z" fill="#A1A3A7"/> +<path d="M51.77 31.9698C51.685 31.9698 51.605 31.9398 51.54 31.8748C51.415 31.7498 51.415 31.5398 51.54 31.4148L55.53 27.4248C55.655 27.2998 55.865 27.2998 55.99 27.4248C56.115 27.5498 56.115 27.7598 55.99 27.8848L52 31.8748C51.935 31.9398 51.855 31.9698 51.77 31.9698Z" fill="#A1A3A7"/> +<path d="M21.4455 63.8049C21.3805 63.8049 21.3105 63.7849 21.2555 63.7449C21.1105 63.6399 21.0805 63.4349 21.1855 63.2899L25.1755 57.7899C25.2805 57.6449 25.4855 57.6099 25.6305 57.7199C25.7755 57.8249 25.8105 58.0299 25.7005 58.1749L21.7105 63.6749C21.6455 63.7649 21.5455 63.8099 21.4455 63.8099V63.8049Z" fill="#A1A3A7"/> +<path d="M25.3802 33.4801C25.2952 33.4801 25.2152 33.4501 25.1502 33.3851L21.1602 29.3951C21.0352 29.2701 21.0352 29.0601 21.1602 28.9351C21.2852 28.8101 21.4952 28.8101 21.6202 28.9351L25.6102 32.9251C25.7352 33.0501 25.7352 33.2601 25.6102 33.3851C25.5452 33.4501 25.4652 33.4801 25.3802 33.4801Z" fill="#A1A3A7"/> +<path d="M55.7044 63.8048C55.6194 63.8048 55.5394 63.7748 55.4744 63.7098L51.4844 59.7198C51.3594 59.5948 51.3594 59.3848 51.4844 59.2598C51.6094 59.1348 51.8194 59.1348 51.9444 59.2598L55.9344 63.2498C56.0594 63.3748 56.0594 63.5848 55.9344 63.7098C55.8694 63.7748 55.7894 63.8048 55.7044 63.8048Z" fill="#A1A3A7"/> +<path d="M58.1505 40.1549C66.7355 40.1549 73.6955 33.1952 73.6955 24.6099C73.6955 16.0247 66.7355 9.06494 58.1505 9.06494C49.5652 9.06494 42.6055 16.0247 42.6055 24.6099C42.6055 33.1952 49.5652 40.1549 58.1505 40.1549Z" fill="white"/> +<path d="M58.1494 40.4854C57.9544 40.4854 57.7594 40.4853 57.5694 40.4753C57.3894 40.4703 57.2494 40.3203 57.2544 40.1403C57.2594 39.9603 57.4194 39.8204 57.5894 39.8254C58.3994 39.8554 59.2294 39.8204 60.0294 39.7204C60.2094 39.7004 60.3694 39.8254 60.3944 40.0054C60.4144 40.1854 60.2894 40.3454 60.1094 40.3654C59.4644 40.4454 58.7994 40.4854 58.1444 40.4854H58.1494ZM55.5944 40.2754C55.5944 40.2754 55.5594 40.2754 55.5394 40.2754C54.7044 40.1354 53.8694 39.9303 53.0644 39.6553C52.8944 39.6003 52.8044 39.4153 52.8594 39.2453C52.9194 39.0753 53.0994 38.9854 53.2694 39.0404C54.0394 39.3004 54.8394 39.5003 55.6394 39.6353C55.8144 39.6653 55.9344 39.8303 55.9094 40.0103C55.8844 40.1703 55.7444 40.2803 55.5894 40.2803L55.5944 40.2754ZM62.0394 39.9953C61.8944 39.9953 61.7644 39.9003 61.7244 39.7503C61.6794 39.5753 61.7844 39.4003 61.9594 39.3553C62.7494 39.1503 63.5244 38.8854 64.2694 38.5554C64.4344 38.4854 64.6244 38.5554 64.6994 38.7204C64.7694 38.8854 64.6994 39.0754 64.5344 39.1504C63.7594 39.4904 62.9494 39.7704 62.1244 39.9854C62.0994 39.9904 62.0694 39.9953 62.0444 39.9953H62.0394ZM51.3244 38.9103C51.2744 38.9103 51.2294 38.9003 51.1794 38.8753C50.4194 38.5003 49.6793 38.0654 48.9843 37.5754C48.8393 37.4704 48.8043 37.2703 48.9093 37.1203C49.0143 36.9753 49.2143 36.9404 49.3643 37.0404C50.0294 37.5104 50.7394 37.9304 51.4694 38.2904C51.6294 38.3704 51.6994 38.5653 51.6194 38.7253C51.5644 38.8403 51.4494 38.9053 51.3294 38.9053L51.3244 38.9103ZM66.1744 38.2603C66.0644 38.2603 65.9544 38.2053 65.8944 38.1053C65.7994 37.9503 65.8494 37.7503 66.0044 37.6603C66.6994 37.2403 67.3694 36.7604 67.9894 36.2354C68.1244 36.1204 68.3294 36.1354 68.4494 36.2754C68.5644 36.4104 68.5494 36.6154 68.4094 36.7354C67.7644 37.2854 67.0644 37.7854 66.3394 38.2204C66.2844 38.2504 66.2294 38.2653 66.1744 38.2653V38.2603ZM47.6243 36.3804C47.5443 36.3804 47.4643 36.3503 47.4043 36.2953C46.7793 35.7203 46.1993 35.0903 45.6743 34.4253C45.5643 34.2853 45.5893 34.0804 45.7293 33.9704C45.8693 33.8604 46.0743 33.8854 46.1843 34.0254C46.6843 34.6654 47.2443 35.2703 47.8443 35.8203C47.9743 35.9403 47.9843 36.1453 47.8643 36.2803C47.7993 36.3503 47.7143 36.3853 47.6243 36.3853V36.3804ZM69.6394 35.4203C69.5594 35.4203 69.4844 35.3903 69.4194 35.3353C69.2894 35.2153 69.2794 35.0103 69.3994 34.8753C69.9494 34.2753 70.4544 33.6253 70.8994 32.9453C70.9994 32.7953 71.1994 32.7553 71.3494 32.8503C71.4994 32.9503 71.5394 33.1503 71.4444 33.3003C70.9794 34.0103 70.4544 34.6853 69.8844 35.3153C69.8194 35.3853 69.7344 35.4203 69.6444 35.4203H69.6394ZM44.7993 32.9053C44.6893 32.9053 44.5793 32.8503 44.5193 32.7453C44.0843 32.0153 43.7043 31.2453 43.3943 30.4603C43.3293 30.2953 43.4093 30.1054 43.5793 30.0404C43.7443 29.9754 43.9343 30.0554 43.9993 30.2254C44.2993 30.9804 44.6643 31.7154 45.0793 32.4154C45.1693 32.5704 45.1193 32.7704 44.9693 32.8604C44.9193 32.8904 44.8593 32.9053 44.8043 32.9053H44.7993ZM72.1594 31.7103C72.1094 31.7103 72.0644 31.7003 72.0194 31.6753C71.8594 31.5953 71.7894 31.4003 71.8694 31.2403C72.2244 30.5103 72.5194 29.7403 72.7544 28.9603C72.8044 28.7903 72.9844 28.6903 73.1594 28.7403C73.3294 28.7903 73.4294 28.9704 73.3794 29.1454C73.1394 29.9604 72.8294 30.7603 72.4594 31.5253C72.4044 31.6403 72.2894 31.7103 72.1694 31.7103H72.1594ZM43.0843 28.7653C42.9393 28.7653 42.8043 28.6654 42.7693 28.5204C42.5593 27.7004 42.4193 26.8553 42.3443 26.0053C42.3293 25.8253 42.4593 25.6704 42.6393 25.6554C42.8143 25.6354 42.9743 25.7704 42.9893 25.9504C43.0593 26.7604 43.1943 27.5704 43.3943 28.3604C43.4393 28.5354 43.3343 28.7103 43.1593 28.7553C43.1343 28.7603 43.1043 28.7653 43.0793 28.7653H43.0843ZM73.5094 27.4304C73.5094 27.4304 73.4744 27.4304 73.4594 27.4304C73.2844 27.4004 73.1594 27.2353 73.1894 27.0603C73.3194 26.2603 73.3844 25.4403 73.3844 24.6253C73.3844 24.4453 73.5294 24.3003 73.7094 24.3003C73.8894 24.3003 74.0344 24.4453 74.0344 24.6253C74.0344 25.4753 73.9644 26.3304 73.8294 27.1654C73.8044 27.3254 73.6644 27.4404 73.5094 27.4404V27.4304ZM42.6243 24.3054C42.6243 24.3054 42.6143 24.3054 42.6093 24.3054C42.4293 24.3004 42.2893 24.1453 42.2993 23.9653C42.3343 23.1153 42.4343 22.2653 42.6043 21.4353C42.6393 21.2603 42.8093 21.1454 42.9893 21.1804C43.1643 21.2154 43.2793 21.3854 43.2443 21.5654C43.0843 22.3604 42.9843 23.1753 42.9493 23.9903C42.9443 24.1653 42.7993 24.3003 42.6243 24.3003V24.3054ZM73.6894 24.2203C73.5194 24.2203 73.3744 24.0853 73.3644 23.9103C73.3294 23.0953 73.2244 22.2804 73.0594 21.4854C73.0244 21.3104 73.1344 21.1354 73.3094 21.1004C73.4844 21.0654 73.6594 21.1754 73.6944 21.3504C73.8694 22.1804 73.9744 23.0303 74.0144 23.8803C74.0244 24.0603 73.8844 24.2103 73.7044 24.2203C73.7044 24.2203 73.6944 24.2203 73.6894 24.2203ZM43.4493 19.8954C43.4143 19.8954 43.3793 19.8903 43.3443 19.8753C43.1743 19.8153 43.0843 19.6303 43.1443 19.4603C43.4193 18.6603 43.7643 17.8703 44.1643 17.1253C44.2493 16.9653 44.4443 16.9053 44.6043 16.9903C44.7643 17.0753 44.8243 17.2704 44.7393 17.4304C44.3543 18.1504 44.0243 18.9003 43.7593 19.6703C43.7143 19.8053 43.5893 19.8903 43.4493 19.8903V19.8954ZM72.8444 19.8203C72.7094 19.8203 72.5844 19.7354 72.5394 19.6004C72.2694 18.8304 71.9394 18.0803 71.5494 17.3653C71.4644 17.2053 71.5244 17.0103 71.6794 16.9253C71.8394 16.8403 72.0344 16.9004 72.1194 17.0554C72.5244 17.8004 72.8694 18.5854 73.1494 19.3854C73.2094 19.5554 73.1194 19.7403 72.9494 19.8003C72.9144 19.8103 72.8794 19.8203 72.8444 19.8203ZM45.5043 15.9103C45.4393 15.9103 45.3743 15.8904 45.3143 15.8504C45.1693 15.7454 45.1343 15.5454 45.2393 15.3954C45.7343 14.7054 46.2843 14.0503 46.8843 13.4453C47.0093 13.3153 47.2193 13.3153 47.3443 13.4453C47.4743 13.5703 47.4743 13.7753 47.3443 13.9053C46.7693 14.4853 46.2393 15.1103 45.7693 15.7753C45.7043 15.8653 45.6043 15.9103 45.5043 15.9103ZM70.7694 15.8454C70.6694 15.8454 70.5694 15.8003 70.5044 15.7103C70.0294 15.0503 69.4944 14.4254 68.9194 13.8504C68.7944 13.7254 68.7944 13.5154 68.9194 13.3904C69.0444 13.2654 69.2544 13.2654 69.3794 13.3904C69.9794 13.9904 70.5394 14.6453 71.0344 15.3303C71.1394 15.4753 71.1044 15.6803 70.9594 15.7853C70.9044 15.8253 70.8344 15.8454 70.7694 15.8454ZM48.6093 12.6753C48.5143 12.6753 48.4193 12.6303 48.3543 12.5503C48.2443 12.4103 48.2693 12.2053 48.4093 12.0953C49.0793 11.5703 49.7943 11.1003 50.5394 10.6953C50.6994 10.6103 50.8944 10.6653 50.9794 10.8253C51.0644 10.9803 51.0094 11.1803 50.8494 11.2653C50.1394 11.6553 49.4493 12.1053 48.8093 12.6103C48.7493 12.6553 48.6793 12.6803 48.6093 12.6803V12.6753ZM67.6494 12.6253C67.5794 12.6253 67.5094 12.6053 67.4494 12.5553C66.8044 12.0603 66.1144 11.6103 65.3994 11.2203C65.2394 11.1353 65.1844 10.9353 65.2694 10.7803C65.3544 10.6253 65.5494 10.5653 65.7094 10.6503C66.4544 11.0553 67.1744 11.5253 67.8444 12.0403C67.9844 12.1503 68.0144 12.3553 67.9044 12.4953C67.8394 12.5803 67.7444 12.6203 67.6494 12.6203V12.6253ZM52.5044 10.4553C52.3744 10.4553 52.2544 10.3753 52.1994 10.2503C52.1344 10.0853 52.2144 9.89533 52.3844 9.83033C53.1744 9.52033 53.9944 9.27533 54.8244 9.09533C54.9994 9.06033 55.1744 9.17033 55.2094 9.34533C55.2444 9.52033 55.1344 9.69533 54.9594 9.73033C54.1644 9.90033 53.3744 10.1354 52.6194 10.4354C52.5794 10.4504 52.5394 10.4553 52.4994 10.4553H52.5044ZM63.7444 10.4253C63.7044 10.4253 63.6644 10.4203 63.6294 10.4053C62.8694 10.1153 62.0794 9.88033 61.2844 9.71533C61.1094 9.68033 60.9944 9.50533 61.0344 9.33033C61.0744 9.15533 61.2444 9.04033 61.4194 9.08033C62.2494 9.25533 63.0744 9.49533 63.8644 9.80033C64.0294 9.86533 64.1144 10.0503 64.0494 10.2203C63.9994 10.3503 63.8744 10.4303 63.7444 10.4303V10.4253ZM56.8744 9.44533C56.7094 9.44533 56.5644 9.31538 56.5494 9.14538C56.5344 8.96538 56.6694 8.81033 56.8444 8.79533C57.6844 8.72533 58.5494 8.72533 59.3944 8.79033C59.5744 8.80533 59.7094 8.96038 59.6944 9.14038C59.6794 9.32038 59.5244 9.46033 59.3444 9.44033C58.5344 9.38033 57.7044 9.38033 56.8994 9.44533C56.8894 9.44533 56.8794 9.44533 56.8744 9.44533Z" fill="#A1A3A7"/> +<path d="M45.805 52.1602C45.72 52.1602 45.64 52.1302 45.575 52.0652L38.96 45.4502C38.835 45.3252 38.835 45.1152 38.96 44.9902C39.085 44.8652 39.295 44.8652 39.42 44.9902L46.035 51.6052C46.16 51.7302 46.16 51.9402 46.035 52.0652C45.97 52.1302 45.89 52.1602 45.805 52.1602Z" fill="#A1A3A7"/> +<path d="M55.0301 30.0849C54.8501 30.0849 54.7051 29.9399 54.7051 29.7599V18.9549C54.7051 18.7749 54.8501 18.6299 55.0301 18.6299C55.2101 18.6299 55.3551 18.7749 55.3551 18.9549V29.7599C55.3551 29.9399 55.2101 30.0849 55.0301 30.0849Z" fill="#A1A3A7"/> +<path d="M52.1602 27.4351L52.6702 26.9551L55.0302 29.4901L57.3902 26.9551L57.9002 27.4351L55.0302 30.5251L52.1602 27.4351Z" fill="#A1A3A7"/> +<path d="M61.5145 30.3549C61.3345 30.3549 61.1895 30.21 61.1895 30.03V19.225C61.1895 19.045 61.3345 18.8999 61.5145 18.8999C61.6945 18.8999 61.8395 19.045 61.8395 19.225V30.03C61.8395 30.21 61.6945 30.3549 61.5145 30.3549Z" fill="#A1A3A7"/> +<path d="M64.3856 21.5548L63.8706 22.0348L61.5156 19.4948L59.1556 22.0348L58.6406 21.5548L61.5156 18.4648L64.3856 21.5548Z" fill="#A1A3A7"/> +</svg> diff --git a/apps/web/public/images/common/safe-bridge-dark.svg b/apps/web/public/images/common/safe-bridge-dark.svg new file mode 100644 index 0000000000..cd340da05a --- /dev/null +++ b/apps/web/public/images/common/safe-bridge-dark.svg @@ -0,0 +1,19 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_4138_11022)"> +<rect x="0.5" y="0.5" width="31" height="31" rx="5.5" stroke="#DCDEE0"/> +<g clip-path="url(#clip1_4138_11022)"> +<path d="M13.9004 9C14.6734 9.00021 15.2998 9.62732 15.2998 10.4004V13.9004C15.2996 14.6733 14.6733 15.2996 13.9004 15.2998H10.4004C9.62732 15.2998 9.00021 14.6734 9 13.9004V10.4004C9 9.62719 9.62719 9 10.4004 9H13.9004ZM10.4004 13.9004H13.9004V10.4004H10.4004V13.9004Z" fill="white"/> +<path d="M12.6001 17.0051C12.2695 17.0023 12 17.2695 12 17.6001V18.7661C12 19.4476 12.5328 20 13.19 20H14.3781C14.717 20 14.9924 19.7268 14.9951 19.388V19.388C14.9978 19.0454 14.7208 18.7661 14.3781 18.7661H13.7975H13.4938H13.4875L13.19 18.4814V18.1966V17.6001C13.19 17.2735 12.9267 17.0079 12.6001 17.0051V17.0051Z" fill="white"/> +<path d="M19.3999 14.9949C19.7305 14.9977 20 14.7305 20 14.3999V13.2339C20 12.5524 19.4672 12 18.81 12H17.6219C17.283 12 17.0076 12.2732 17.0049 12.612V12.612C17.0022 12.9546 17.2792 13.2339 17.6219 13.2339H18.2025H18.5062H18.5125L18.81 13.5186V13.8034V14.3999C18.81 14.7265 19.0733 14.9921 19.3999 14.9949V14.9949Z" fill="white"/> +<path d="M21.6006 16.7002C22.3736 16.7004 23 17.3275 23 18.1006V21.6006C22.9998 22.3735 22.3735 22.9998 21.6006 23H18.1006C17.3275 23 16.7004 22.3736 16.7002 21.6006V18.1006C16.7002 17.3274 17.3274 16.7002 18.1006 16.7002H21.6006ZM18.1006 21.6006H21.6006V18.1006H18.1006V21.6006Z" fill="white"/> +</g> +</g> +<defs> +<clipPath id="clip0_4138_11022"> +<rect width="32" height="32" fill="white"/> +</clipPath> +<clipPath id="clip1_4138_11022"> +<rect width="16" height="16" fill="white" transform="translate(8 8)"/> +</clipPath> +</defs> +</svg> diff --git a/apps/web/public/images/common/safe-bridge.svg b/apps/web/public/images/common/safe-bridge.svg new file mode 100644 index 0000000000..55de3869fc --- /dev/null +++ b/apps/web/public/images/common/safe-bridge.svg @@ -0,0 +1,19 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_4138_11018)"> +<rect x="0.5" y="0.5" width="31" height="31" rx="5.5" stroke="#DCDEE0"/> +<g clip-path="url(#clip1_4138_11018)"> +<path d="M13.9004 9C14.6734 9.00021 15.2998 9.62732 15.2998 10.4004V13.9004C15.2996 14.6733 14.6733 15.2996 13.9004 15.2998H10.4004C9.62732 15.2998 9.00021 14.6734 9 13.9004V10.4004C9 9.62719 9.62719 9 10.4004 9H13.9004ZM10.4004 13.9004H13.9004V10.4004H10.4004V13.9004Z" fill="#121312"/> +<path d="M12.6001 17.0051C12.2695 17.0023 12 17.2695 12 17.6001V18.7661C12 19.4476 12.5328 20 13.19 20H14.3781C14.717 20 14.9924 19.7268 14.9951 19.388V19.388C14.9978 19.0454 14.7208 18.7661 14.3781 18.7661H13.7975H13.4938H13.4875L13.19 18.4814V18.1966V17.6001C13.19 17.2735 12.9267 17.0079 12.6001 17.0051V17.0051Z" fill="#121312"/> +<path d="M19.3999 14.9949C19.7305 14.9977 20 14.7305 20 14.3999V13.2339C20 12.5524 19.4672 12 18.81 12H17.6219C17.283 12 17.0076 12.2732 17.0049 12.612V12.612C17.0022 12.9546 17.2792 13.2339 17.6219 13.2339H18.2025H18.5062H18.5125L18.81 13.5186V13.8034V14.3999C18.81 14.7265 19.0733 14.9921 19.3999 14.9949V14.9949Z" fill="#121312"/> +<path d="M21.6006 16.7002C22.3736 16.7004 23 17.3275 23 18.1006V21.6006C22.9998 22.3735 22.3735 22.9998 21.6006 23H18.1006C17.3275 23 16.7004 22.3736 16.7002 21.6006V18.1006C16.7002 17.3274 17.3274 16.7002 18.1006 16.7002H21.6006ZM18.1006 21.6006H21.6006V18.1006H18.1006V21.6006Z" fill="#121312"/> +</g> +</g> +<defs> +<clipPath id="clip0_4138_11018"> +<rect width="32" height="32" fill="white"/> +</clipPath> +<clipPath id="clip1_4138_11018"> +<rect width="16" height="16" fill="white" transform="translate(8 8)"/> +</clipPath> +</defs> +</svg> diff --git a/apps/web/public/images/common/spaces-illustration.png b/apps/web/public/images/common/spaces-illustration.png new file mode 100644 index 0000000000..beccc52bf2 Binary files /dev/null and b/apps/web/public/images/common/spaces-illustration.png differ diff --git a/apps/web/public/images/common/stake-light.svg b/apps/web/public/images/common/stake-light.svg new file mode 100644 index 0000000000..7e24576398 --- /dev/null +++ b/apps/web/public/images/common/stake-light.svg @@ -0,0 +1,4 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M15.3165 2.41478C15.3744 2.08835 15.3743 2.08834 15.3742 2.08832L15.6311 2.12657L15.6728 2.36196C15.6728 2.36184 15.6728 2.36173 15.3165 2.41478Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M1.09638 2.3622L1.45267 2.41502C1.0967 2.36202 1.0964 2.36208 1.09638 2.3622L1.13813 2.12681L1.39505 2.08856C1.39492 2.08858 1.39482 2.08887 1.45267 2.41502L1.39505 2.08856L1.39631 2.08837L1.3992 2.08795L1.40906 2.08655L1.44465 2.08174C1.47526 2.07774 1.51942 2.07225 1.57576 2.06599C1.6884 2.05349 1.84997 2.03791 2.04938 2.02506C2.44756 1.99939 3.00004 1.98436 3.6172 2.0274C4.83698 2.11246 6.37906 2.4291 7.44866 3.40909C7.85842 3.78452 8.16125 4.22302 8.38423 4.68218C8.60723 4.22299 8.91009 3.78445 9.31988 3.409C10.3896 2.4289 11.9318 2.11223 13.1518 2.02716C13.769 1.98411 14.3215 1.99914 14.7198 2.02481C14.9192 2.03767 15.0808 2.05325 15.1934 2.06575C15.2498 2.07201 15.294 2.0775 15.3246 2.0815L15.3602 2.08631L15.37 2.08771L15.3729 2.08813L15.3742 2.08832L15.3165 2.41478L15.6728 2.36196L15.673 2.36312L15.6735 2.36577L15.675 2.3748L15.6803 2.40741C15.6846 2.43546 15.6906 2.47593 15.6975 2.52755C15.7111 2.63077 15.7281 2.77882 15.7422 2.96154C15.7702 3.3264 15.7866 3.83266 15.7396 4.39817C15.6467 5.51589 15.3011 6.92893 14.2314 7.90902C13.4157 8.65635 12.3268 9.01629 11.3246 9.18328C10.5184 9.31762 9.74554 9.3309 9.16986 9.31017V13.2494C9.16986 13.6639 8.80306 14 8.35058 14C7.89811 14 7.53131 13.6639 7.53131 13.2494V9.31187C6.94958 9.32958 6.182 9.31107 5.38555 9.1728C4.40028 9.00173 3.33759 8.64154 2.53767 7.90864C1.46807 6.92865 1.12247 5.51577 1.02963 4.39818C0.982662 3.83273 0.99906 3.32653 1.02707 2.96172C1.04111 2.77901 1.05811 2.63098 1.07176 2.52777C1.07858 2.47615 1.08457 2.43569 1.08894 2.40765L1.09419 2.37504L1.09572 2.36601L1.09618 2.36336L1.09638 2.3622ZM13.2761 3.52409C13.5829 3.5027 13.8727 3.49834 14.1289 3.5029C14.1339 3.73763 14.1291 4.0032 14.1058 4.28424C14.023 5.28119 13.7252 6.24968 13.0728 6.84746C12.4203 7.44525 11.3633 7.71804 10.2752 7.79392C9.96842 7.81532 9.67856 7.81968 9.42237 7.81512C9.41739 7.58039 9.42216 7.31482 9.4455 7.03378C9.52832 6.03682 9.82606 5.06834 10.4785 4.47055C11.131 3.87277 12.188 3.59997 13.2761 3.52409ZM2.66345 4.28425C2.64012 4.0033 2.63535 3.73781 2.64032 3.50315C2.89644 3.49859 3.18621 3.50295 3.49285 3.52434C4.58082 3.60021 5.6377 3.87297 6.29003 4.47064C6.94236 5.06832 7.24006 6.03665 7.32287 7.03348C7.34621 7.31443 7.35097 7.57992 7.346 7.81458C7.08988 7.81914 6.80011 7.81477 6.49347 7.79339C5.4055 7.71752 4.34862 7.44476 3.69629 6.84708C3.04396 6.2494 2.74626 5.28107 2.66345 4.28425Z" fill="white"/> +</svg> diff --git a/apps/web/public/images/common/star.svg b/apps/web/public/images/common/star.svg new file mode 100644 index 0000000000..f891511c27 --- /dev/null +++ b/apps/web/public/images/common/star.svg @@ -0,0 +1,3 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M12.0004 2C11.6314 2 11.2624 2.195 11.0804 2.586L8.80544 7.851C8.66144 8.157 8.37444 8.372 8.03944 8.424L2.86144 9.219C2.04744 9.344 1.71344 10.337 2.28744 10.929L6.10444 14.455C6.32744 14.685 6.42844 15.007 6.37744 15.323L5.07344 20.823C4.96744 21.478 5.48844 22.003 6.07544 22.003C6.23844 22.003 6.40644 21.962 6.56744 21.873L11.5064 19.358C11.6604 19.273 11.8304 19.23 12.0004 19.23C12.1704 19.23 12.3404 19.273 12.4934 19.358L17.3444 21.873C17.5064 21.962 17.6744 22.003 17.8364 22.003C18.4244 22.003 18.9444 21.478 18.8384 20.823L17.6234 15.323C17.5714 15.007 17.6724 14.685 17.8964 14.455L21.7124 10.929C22.2864 10.337 21.9534 9.344 21.1394 9.219L15.9604 8.424C15.6254 8.372 15.3384 8.157 15.1954 7.851L12.9204 2.586C12.7374 2.195 12.3694 2 12.0004 2ZM12.0004 5.499L13.3594 8.644C13.3664 8.661 13.3754 8.679 13.3834 8.696C13.8104 9.61 14.6594 10.248 15.6564 10.4L18.8124 10.885L16.5384 12.986C16.5124 13.011 16.4864 13.036 16.4614 13.062C15.8004 13.742 15.4964 14.708 15.6484 15.645C15.6544 15.682 15.6624 15.719 15.6704 15.755L16.4184 19.14L13.4334 17.593C12.9934 17.355 12.4994 17.23 12.0004 17.23C11.5044 17.23 11.0124 17.354 10.5754 17.588L7.52744 19.14L8.32344 15.785C8.33444 15.738 8.34444 15.691 8.35244 15.644C8.50344 14.707 8.19944 13.742 7.53844 13.062C7.51444 13.036 7.48744 13.011 7.46144 12.986L5.18844 10.885L8.34344 10.4C9.34044 10.248 10.1894 9.611 10.6164 8.697C10.6254 8.68 10.6334 8.662 10.6414 8.644L12.0004 5.499Z" fill="currentColor"/> +</svg> diff --git a/apps/web/public/images/common/unknown.svg b/apps/web/public/images/common/unknown.svg new file mode 100644 index 0000000000..19351160f7 --- /dev/null +++ b/apps/web/public/images/common/unknown.svg @@ -0,0 +1,4 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="7" y="10" width="2" height="2" rx="1" fill="#A1A3A7"/> +<path d="M7.38526 5.93278C7.38526 5.63364 7.64362 5.38526 7.96968 5.38526C8.29476 5.38526 8.55296 5.63377 8.55296 5.93278C8.55635 6.15507 8.52747 6.19852 8.18716 6.44691L8.16039 6.46644C7.38594 7.03156 7.11124 7.50234 7.15311 8.32328L7.15205 8.42076C7.15205 8.87398 7.51811 9.24004 7.96968 9.24004C8.42124 9.24004 8.78731 8.87398 8.78731 8.42241V8.28164C8.77652 8.05395 8.79194 8.02993 9.12429 7.78742L9.15119 7.76779C9.91824 7.20793 10.2009 6.73722 10.1881 5.92004C10.1882 4.72356 9.19123 3.75 7.96968 3.75C6.74572 3.75 5.75 4.72482 5.75 5.93278C5.75 6.38435 6.11607 6.75041 6.56763 6.75041C7.0192 6.75041 7.38526 6.38435 7.38526 5.93278Z" fill="#A1A3A7"/> +</svg> diff --git a/apps/web/public/images/common/whatsnew.svg b/apps/web/public/images/common/whatsnew.svg new file mode 100644 index 0000000000..6b36d1a465 --- /dev/null +++ b/apps/web/public/images/common/whatsnew.svg @@ -0,0 +1,3 @@ +<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path id="Union" fill-rule="evenodd" clip-rule="evenodd" d="M8.01153 3.90476C7.61862 3.90476 7.2418 4.05527 6.96396 4.32318C6.68613 4.59109 6.53005 4.95445 6.53005 5.33333C6.53005 5.71221 6.68613 6.07558 6.96396 6.34349C7.2418 6.6114 7.61862 6.7619 8.01153 6.7619H11.1266C11.006 6.45773 10.8534 6.12325 10.6646 5.7923C10.0434 4.70318 9.19069 3.90476 8.01153 3.90476ZM12.3325 4.76599C11.5818 3.49577 10.2258 2 8.01153 2C7.09473 2 6.21548 2.35119 5.56721 2.97631C4.91894 3.60143 4.55474 4.44928 4.55474 5.33333C4.55474 6.15536 4.86963 6.94609 5.43499 7.55556H3.24161C2.73953 7.55556 2.33252 7.91088 2.33252 8.34921V12.3175C2.33252 12.7558 2.73953 13.1111 3.24161 13.1111H3.44363V21.1667C3.44363 21.6269 3.88582 22 4.43128 22L12.3325 22L20.2338 22C20.7792 22 21.2214 21.6269 21.2214 21.1667V13.1111H21.4234C21.9255 13.1111 22.3325 12.7558 22.3325 12.3175V8.34921C22.3325 7.91088 21.9255 7.55556 21.4234 7.55556H19.2301C19.7954 6.94609 20.1103 6.15536 20.1103 5.33333C20.1103 4.44928 19.7461 3.60143 19.0978 2.97631C18.4496 2.35119 17.5703 2 16.6535 2C14.4392 2 13.0833 3.49577 12.3325 4.76599ZM13.4436 20.3333H19.2461V13.1111H13.4436V20.3333ZM11.2214 13.1111V20.3333H5.41894V13.1111H11.2214ZM13.4436 11.5238H20.5143V9.14286H13.4436V11.5238ZM11.2214 9.14286V11.5238H4.1507V9.14286H11.2214ZM16.6535 6.7619H13.5384C13.659 6.45773 13.8116 6.12325 14.0004 5.7923C14.6216 4.70318 15.4744 3.90476 16.6535 3.90476C17.0464 3.90476 17.4232 4.05527 17.7011 4.32318C17.9789 4.59109 18.135 4.95445 18.135 5.33333C18.135 5.71221 17.9789 6.07558 17.7011 6.34349C17.4232 6.6114 17.0464 6.7619 16.6535 6.7619Z" fill="currentColor"/> +</svg> diff --git a/apps/web/public/images/logo-round.svg b/apps/web/public/images/logo-round.svg index 915af51b85..20e992a849 100644 --- a/apps/web/public/images/logo-round.svg +++ b/apps/web/public/images/logo-round.svg @@ -1,4 +1,4 @@ -<svg viewBox="0 0 1202 1202" fill="none" xmlns="http://www.w3.org/2000/svg"> +<svg viewBox="0 0 1202 1202" width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M682 712.3H375.9V265H682C787.1 265 872.2 350.2 872.2 455.2V522C872.2 627.1 787.1 712.3 682 712.3Z" fill="#ACD905"></path> diff --git a/apps/web/public/images/sidebar/members.svg b/apps/web/public/images/sidebar/members.svg new file mode 100644 index 0000000000..350ff08df1 --- /dev/null +++ b/apps/web/public/images/sidebar/members.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M10.6666 5.9995C11.4019 5.9995 11.9999 6.5975 11.9999 7.33283C11.9999 8.06816 11.4019 8.66616 10.6666 8.66616C9.93123 8.66616 9.33323 8.06816 9.33323 7.33283C9.33323 6.5975 9.93123 5.9995 10.6666 5.9995ZM5.3319 2.66683C6.06723 2.66683 6.66523 3.26483 6.66523 4.00016C6.66523 4.7355 6.06723 5.3335 5.3319 5.3335C4.59723 5.3335 3.99857 4.7355 3.99857 4.00016C3.99857 3.26483 4.59723 2.66683 5.3319 2.66683ZM15.3046 13.8128C14.8459 12.2382 14.0292 10.2422 12.1106 9.56883C12.8446 9.0935 13.3332 8.27083 13.3332 7.33283C13.3332 5.86216 12.1372 4.66616 10.6666 4.66616C9.3179 4.66616 8.21123 5.67616 8.0359 6.9775C7.6839 6.67216 7.2639 6.4155 6.76723 6.24016C7.5059 5.7655 7.99857 4.9415 7.99857 4.00016C7.99857 2.5295 6.80257 1.3335 5.3319 1.3335C3.86123 1.3335 2.66523 2.5295 2.66523 4.00016C2.66523 4.93816 3.15457 5.7615 3.88923 6.23616C1.96923 6.9095 1.15257 8.90616 0.693232 10.4815C0.590566 10.8348 0.792566 11.2055 1.14657 11.3082C1.20857 11.3262 1.27123 11.3348 1.33323 11.3348C1.62257 11.3348 1.88857 11.1462 1.97323 10.8548C2.69323 8.3875 3.6979 7.33483 5.33323 7.33483C6.77523 7.33483 7.70123 8.1375 8.39523 9.97416C7.0519 10.8555 6.4119 12.4848 6.02457 13.8128C5.9219 14.1662 6.12523 14.5368 6.4779 14.6395C6.54057 14.6575 6.60323 14.6662 6.66457 14.6662C6.9539 14.6662 7.2199 14.4775 7.30457 14.1862C8.0239 11.7188 9.02857 10.6662 10.6646 10.6662C12.2999 10.6662 13.3046 11.7188 14.0246 14.1862C14.1272 14.5388 14.4926 14.7422 14.8512 14.6395C15.2052 14.5368 15.4072 14.1662 15.3046 13.8128Z" fill="currentColor"/> +</svg> diff --git a/apps/web/public/images/sidebar/nested-safes-icon.svg b/apps/web/public/images/sidebar/nested-safes-icon.svg new file mode 100644 index 0000000000..d4d0ffaead --- /dev/null +++ b/apps/web/public/images/sidebar/nested-safes-icon.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M9 6.05281C9.95372 5.65955 10.625 4.72067 10.625 3.625C10.625 2.17525 9.44975 1 8 1C6.55025 1 5.375 2.17525 5.375 3.625C5.375 4.72067 6.04628 5.65955 7 6.05281V7.125H4.85C3.6902 7.125 2.75 8.0652 2.75 9.225V9.89937C1.73046 10.2597 1 11.2321 1 12.375C1 13.8247 2.17525 15 3.625 15C5.07475 15 6.25 13.8247 6.25 12.375C6.25 11.2321 5.51954 10.2597 4.5 9.89937V9.575C4.5 9.1884 4.8134 8.875 5.2 8.875H10.8C11.1866 8.875 11.5 9.1884 11.5 9.575V9.89937C10.4805 10.2597 9.75 11.2321 9.75 12.375C9.75 13.8247 10.9253 15 12.375 15C13.8247 15 15 13.8247 15 12.375C15 11.2321 14.2695 10.2597 13.25 9.89937V9.225C13.25 8.0652 12.3098 7.125 11.15 7.125H9V6.05281ZM8 4.5C8.48325 4.5 8.875 4.10825 8.875 3.625C8.875 3.14175 8.48325 2.75 8 2.75C7.51675 2.75 7.125 3.14175 7.125 3.625C7.125 4.10825 7.51675 4.5 8 4.5ZM11.5 12.375C11.5 11.8918 11.8918 11.5 12.375 11.5C12.8582 11.5 13.25 11.8918 13.25 12.375C13.25 12.8582 12.8582 13.25 12.375 13.25C11.8918 13.25 11.5 12.8582 11.5 12.375ZM2.75 12.375C2.75 11.8918 3.14175 11.5 3.625 11.5C4.10825 11.5 4.5 11.8918 4.5 12.375C4.5 12.8582 4.10825 13.25 3.625 13.25C3.14175 13.25 2.75 12.8582 2.75 12.375Z" fill="currentColor"/> +</svg> \ No newline at end of file diff --git a/apps/web/public/images/sidebar/nested-safes.svg b/apps/web/public/images/sidebar/nested-safes.svg new file mode 100644 index 0000000000..90203adb65 --- /dev/null +++ b/apps/web/public/images/sidebar/nested-safes.svg @@ -0,0 +1,10 @@ +<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M50 4C75.405 4 96 24.595 96 50C96 75.404 75.405 96 50 96C24.596 96 4 75.404 4 50C4 24.595 24.596 4 50 4Z" class="illustration-background-fill"/> +<path d="M50 46L50 33.1429" stroke="#A1A3A7" class="illustration-light-stroke" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M50 46L40.3265 46C36.2119 46 34.1545 46 32.5829 46.8297C31.2005 47.5596 30.0766 48.7242 29.3722 50.1566C28.5714 51.7851 28.5714 54.5936 28.5714 58.8571" stroke="#A1A3A7" class="illustration-light-stroke" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M37.1429 67.4289C37.1429 72.1627 33.3053 76.0003 28.5714 76.0003C23.8376 76.0003 20 72.1627 20 67.4289C20 62.695 23.8376 58.8574 28.5714 58.8574C33.3053 58.8574 37.1429 62.695 37.1429 67.4289Z" stroke="#A1A3A7" class="illustration-light-stroke" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M50 46L59.6735 46C63.7881 46 65.8455 46 67.4171 46.8297C68.7995 47.5596 69.9234 48.7242 70.6278 50.1566C71.4286 51.7851 71.4286 54.5936 71.4286 58.8571" stroke="#A1A3A7" class="illustration-light-stroke" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M62.8571 67.4289C62.8571 72.1627 66.6947 76.0003 71.4286 76.0003C76.1624 76.0003 80 72.1627 80 67.4289C80 62.695 76.1624 58.8574 71.4286 58.8574C66.6947 58.8574 62.8571 62.695 62.8571 67.4289Z" stroke="#A1A3A7" class="illustration-light-stroke" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M58.5714 24.5714C58.5714 29.3053 54.7339 33.1429 50 33.1429C45.2661 33.1429 41.4286 29.3053 41.4286 24.5714C41.4286 19.8376 45.2661 16 50 16C54.7339 16 58.5714 19.8376 58.5714 24.5714Z" stroke="#A1A3A7" class="illustration-light-stroke" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M77.204 9C78.2174 9 79.1098 9.6699 79.3913 10.647L79.4704 10.9201L79.7 11.6825L79.8863 12.256L79.9234 12.3643L80.072 12.7772L80.147 12.972L80.2227 13.1592L80.3773 13.5121C81.0512 14.9651 81.8869 15.6706 83.5658 16.3116L83.8241 16.4073L84.2521 16.5555L84.714 16.7046L85.2115 16.8564L85.7467 17.0129C86.7333 17.2888 87.407 18.1887 87.407 19.204C87.407 20.1629 86.8078 21.01 85.9203 21.3377L85.7607 21.3901L85.2238 21.5471L84.7247 21.6994L84.1509 21.886L83.6294 22.072C81.9265 22.7131 81.078 23.41 80.3998 24.8471L80.2993 25.0683L80.1468 25.4357L79.9973 25.8331L79.9231 26.0437L79.7745 26.4898L79.6241 26.9707L79.3908 27.7617C79.1098 28.7371 78.2174 29.407 77.204 29.407C76.1925 29.407 75.3075 28.7409 75.0168 27.7603L74.9376 27.4871L74.7078 26.7245L74.5585 26.2613L74.4101 25.8321C73.6977 23.8532 72.9482 22.9752 71.2052 22.2418L70.9721 22.1468L70.5746 21.9972L70.1451 21.8486L69.6816 21.6991L69.1823 21.5468L68.6453 21.3898C67.6717 21.1093 67 20.2193 67 19.204C67 18.1887 67.6737 17.2888 68.6464 17.0169L68.9197 16.9377L69.4375 16.7833L69.9185 16.6327L70.471 16.4467L70.5752 16.4096L70.9727 16.2599C72.6784 15.5863 73.4863 14.8223 74.1703 13.1919L74.2629 12.9641L74.4129 12.5654L74.5617 12.1346L74.6364 11.9066L74.7874 11.424L75.0216 10.6303C75.3075 9.66611 76.1925 9 77.204 9Z" class="illustration-main-fill"/> +</svg> \ No newline at end of file diff --git a/apps/web/public/images/sidebar/wallet.svg b/apps/web/public/images/sidebar/wallet.svg new file mode 100644 index 0000000000..0339007b76 --- /dev/null +++ b/apps/web/public/images/sidebar/wallet.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M14 10.0002V11.3335C14 12.0699 13.403 12.6668 12.6667 12.6668H2.66667C1.93029 12.6668 1.33333 12.0699 1.33333 11.3335V4.66683C1.33333 3.93045 1.93029 3.3335 2.66667 3.3335H12.6667C13.403 3.3335 14 3.93045 14 4.66683V6.00016C14.3682 6.00016 14.6667 6.29864 14.6667 6.66683V9.3335C14.6667 9.70169 14.3682 10.0002 14 10.0002ZM12.6667 6.00016V4.66683H2.66667V11.3335H12.6667V10.0002H10.6667C10.2985 10.0002 10 9.70169 10 9.3335V6.66683C10 6.29864 10.2985 6.00016 10.6667 6.00016H12.6667ZM11.3333 8.66683V7.3335H13.3333V8.66683H11.3333Z" fill="currentColor"/> +</svg> diff --git a/apps/web/public/images/spaces/admin.svg b/apps/web/public/images/spaces/admin.svg new file mode 100644 index 0000000000..e910bec2f6 --- /dev/null +++ b/apps/web/public/images/spaces/admin.svg @@ -0,0 +1,5 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M8.00217 2.66634C9.10484 2.66634 10.0022 3.56301 10.0022 4.66634C10.0022 5.76901 9.10484 6.66634 8.00217 6.66634C6.89951 6.66634 6.00217 5.76901 6.00217 4.66634C6.00217 3.56301 6.89951 2.66634 8.00217 2.66634ZM10.8762 8.04567C10.4895 7.83034 10.0748 7.66901 9.63951 7.55167C10.6468 6.97767 11.3355 5.90634 11.3355 4.66634C11.3355 2.82834 9.84017 1.33301 8.00217 1.33301C6.16417 1.33301 4.66884 2.82834 4.66884 4.66634C4.66884 5.90167 5.35217 6.96967 6.35351 7.54567C3.36484 8.32701 2.11351 11.2317 1.36017 13.8163C1.25751 14.1697 1.46017 14.5403 1.81351 14.643C1.87617 14.6617 1.93884 14.6697 2.00017 14.6697C2.28951 14.6697 2.55551 14.481 2.64017 14.1903C3.76817 10.321 5.37151 8.66967 8.00017 8.66967C8.84817 8.66967 9.57551 8.84634 10.2248 9.20967C10.5455 9.38834 10.9522 9.27367 11.1322 8.95301C11.3122 8.63167 11.1968 8.22501 10.8762 8.04567Z" fill="currentColor"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M8.93237 13.5902L8.93771 14.4849C8.93771 14.5855 9.01971 14.6669 9.12037 14.6669H10.009C10.0577 14.6669 10.1044 14.6475 10.139 14.6129L12.8244 11.9275C12.9297 11.8222 12.9297 11.6515 12.8244 11.5462L12.0364 10.7575C11.9404 10.6615 11.7844 10.6615 11.6884 10.7575L8.98637 13.4602C8.95171 13.4942 8.93237 13.5415 8.93237 13.5902Z" fill="currentColor"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M13.6276 11.1242L14.6136 10.1382C14.6876 10.0648 14.6876 9.94416 14.6136 9.87016L13.7283 8.98616C13.6549 8.91216 13.5343 8.91216 13.4603 8.98616L12.4749 9.9715C12.4003 10.0455 12.4003 10.1662 12.4749 10.2402L13.3589 11.1242C13.4329 11.1982 13.5536 11.1982 13.6276 11.1242Z" fill="currentColor"/> +</svg> diff --git a/apps/web/public/images/spaces/create_space_info.png b/apps/web/public/images/spaces/create_space_info.png new file mode 100644 index 0000000000..f05a68f229 Binary files /dev/null and b/apps/web/public/images/spaces/create_space_info.png differ diff --git a/apps/web/public/images/spaces/empty_dashboard.png b/apps/web/public/images/spaces/empty_dashboard.png new file mode 100644 index 0000000000..c86602582d Binary files /dev/null and b/apps/web/public/images/spaces/empty_dashboard.png differ diff --git a/apps/web/public/images/spaces/empty_dashboard_dark.png b/apps/web/public/images/spaces/empty_dashboard_dark.png new file mode 100644 index 0000000000..3f837e39cd Binary files /dev/null and b/apps/web/public/images/spaces/empty_dashboard_dark.png differ diff --git a/apps/web/public/images/spaces/member.svg b/apps/web/public/images/spaces/member.svg new file mode 100644 index 0000000000..6393e22b5e --- /dev/null +++ b/apps/web/public/images/spaces/member.svg @@ -0,0 +1,4 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M12.005 4.00061C13.659 4.00061 15.005 5.34561 15.005 7.00061C15.005 8.65461 13.659 10.0006 12.005 10.0006C10.351 10.0006 9.005 8.65461 9.005 7.00061C9.005 5.34561 10.351 4.00061 12.005 4.00061ZM16.316 12.0694C15.736 11.7464 15.114 11.5044 14.461 11.3284C15.972 10.4674 17.005 8.86037 17.005 7.00037C17.005 4.24337 14.762 2.00037 12.005 2.00037C9.24797 2.00037 7.00497 4.24337 7.00497 7.00037C7.00497 8.85337 8.02997 10.4554 9.53197 11.3194C5.04897 12.4914 3.17197 16.8484 2.04197 20.7254C1.88797 21.2554 2.19197 21.8114 2.72197 21.9654C2.81597 21.9934 2.90997 22.0054 3.00197 22.0054C3.43597 22.0054 3.83497 21.7224 3.96197 21.2864C5.65397 15.4824 8.05897 13.0054 12.002 13.0054C13.274 13.0054 14.365 13.2704 15.339 13.8154C15.82 14.0834 16.43 13.9114 16.7 13.4304C16.97 12.9484 16.797 12.3384 16.316 12.0694Z" fill="currentColor"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M12.0017 4.00061C10.3477 4.00061 9.00167 5.34561 9.00167 7.00061C9.00167 8.65461 10.3477 10.0006 12.0017 10.0006C13.6557 10.0006 15.0017 8.65461 15.0017 7.00061C15.0017 5.34561 13.6557 4.00061 12.0017 4.00061ZM7.69071 12.0694C8.27071 11.7464 8.89271 11.5044 9.54571 11.3284C8.03471 10.4674 7.00171 8.86037 7.00171 7.00037C7.00171 4.24337 9.24471 2.00037 12.0017 2.00037C14.7587 2.00037 17.0017 4.24337 17.0017 7.00037C17.0017 8.85337 15.9767 10.4554 14.4747 11.3194C18.9577 12.4914 20.8347 16.8484 21.9647 20.7254C22.1187 21.2554 21.8147 21.8114 21.2847 21.9654C21.1907 21.9934 21.0967 22.0054 21.0047 22.0054C20.5707 22.0054 20.1717 21.7224 20.0447 21.2864C18.3527 15.4824 15.9477 13.0054 12.0047 13.0054C10.7327 13.0054 9.64171 13.2704 8.66771 13.8154C8.18671 14.0834 7.57671 13.9114 7.30671 13.4304C7.03671 12.9484 7.20971 12.3384 7.69071 12.0694Z" fill="currentColor"/> +</svg> diff --git a/apps/web/public/images/spaces/safe-accounts.svg b/apps/web/public/images/spaces/safe-accounts.svg new file mode 100644 index 0000000000..fd97d51b2b --- /dev/null +++ b/apps/web/public/images/spaces/safe-accounts.svg @@ -0,0 +1,18 @@ +<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M66.29 146.58C92.9598 146.58 114.58 124.96 114.58 98.29C114.58 71.6202 92.9598 50 66.29 50C39.6202 50 18 71.6202 18 98.29C18 124.96 39.6202 146.58 66.29 146.58Z" fill="#B0FFC9"/> +<path d="M99.35 125.7C125.501 125.7 146.7 104.501 146.7 78.35C146.7 52.1993 125.501 31 99.35 31C73.1993 31 52 52.1993 52 78.35C52 104.501 73.1993 125.7 99.35 125.7Z" fill="white"/> +<path d="M99.35 125.7C125.501 125.7 146.7 104.501 146.7 78.35C146.7 52.1993 125.501 31 99.35 31C73.1993 31 52 52.1993 52 78.35C52 104.501 73.1993 125.7 99.35 125.7Z" fill="white" stroke="#121312" stroke-width="1.33" stroke-miterlimit="10" stroke-linecap="round" stroke-dasharray="6 6"/> +<path d="M43.57 74.4899C60.0944 74.4899 73.49 61.0943 73.49 44.5699C73.49 28.0455 60.0944 14.6499 43.57 14.6499C27.0457 14.6499 13.65 28.0455 13.65 44.5699C13.65 61.0943 27.0457 74.4899 43.57 74.4899Z" fill="white"/> +<path d="M46.1344 47.5343L45.5344 47.5343L45.5344 48.1343L45.5344 57.5376C45.5344 58.9374 44.3997 60.0721 43 60.0721C41.6003 60.0721 40.4656 58.9374 40.4656 57.5376L40.4656 48.1343L40.4656 47.5343L39.8656 47.5343L30.4622 47.5343C29.0625 47.5343 27.9278 46.3996 27.9278 44.9999C27.9278 43.6001 29.0625 42.4654 30.4622 42.4654L39.8656 42.4654L40.4656 42.4654L40.4656 41.8654L40.4656 32.4621C40.4656 31.0623 41.6003 29.9276 43 29.9276C44.3997 29.9276 45.5344 31.0623 45.5344 32.4621L45.5344 41.8654L45.5344 42.4654L46.1344 42.4654L55.5378 42.4654C56.9375 42.4654 58.0722 43.6001 58.0722 44.9999C58.0722 46.3996 56.9375 47.5343 55.5378 47.5343L46.1344 47.5343Z" stroke="#121312" stroke-width="1.2"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M43.57 15.3C27.4046 15.3 14.3 28.4046 14.3 44.57C14.3 60.7354 27.4046 73.84 43.57 73.84C59.7354 73.84 72.84 60.7354 72.84 44.57C72.84 28.4046 59.7354 15.3 43.57 15.3ZM13 44.57C13 27.6867 26.6867 14 43.57 14C60.4533 14 74.14 27.6867 74.14 44.57C74.14 61.4533 60.4533 75.14 43.57 75.14C26.6867 75.14 13 61.4533 13 44.57Z" fill="#121312"/> +<g clip-path="url(#clip0_3756_17645)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M79.25 67C79.25 64.9289 80.9289 63.25 83 63.25H117C119.071 63.25 120.75 64.9289 120.75 67V89C120.75 91.0711 119.071 92.75 117 92.75H83C80.9289 92.75 79.25 91.0711 79.25 89V67ZM83 64.75C81.7574 64.75 80.75 65.7574 80.75 67V89C80.75 90.2426 81.7574 91.25 83 91.25H117C118.243 91.25 119.25 90.2426 119.25 89V67C119.25 65.7574 118.243 64.75 117 64.75H83Z" fill="black"/> +<path d="M109 74C109 73.4477 109.448 73 110 73H121C121.552 73 122 73.4477 122 74V82C122 82.5523 121.552 83 121 83H110C109.448 83 109 82.5523 109 82V74Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M108.25 74C108.25 73.0335 109.034 72.25 110 72.25H121C121.966 72.25 122.75 73.0335 122.75 74V82C122.75 82.9665 121.966 83.75 121 83.75H110C109.034 83.75 108.25 82.9665 108.25 82V74ZM110 73.75C109.862 73.75 109.75 73.8619 109.75 74V82C109.75 82.1381 109.862 82.25 110 82.25H121C121.138 82.25 121.25 82.1381 121.25 82V74C121.25 73.8619 121.138 73.75 121 73.75H110Z" fill="black"/> +</g> +<defs> +<clipPath id="clip0_3756_17645"> +<rect width="43.5" height="29.5" fill="white" transform="translate(79.25 63.25)"/> +</clipPath> +</defs> +</svg> diff --git a/apps/web/public/images/spaces/skeleton_bg.png b/apps/web/public/images/spaces/skeleton_bg.png new file mode 100644 index 0000000000..5fd9f69159 Binary files /dev/null and b/apps/web/public/images/spaces/skeleton_bg.png differ diff --git a/apps/web/public/images/spaces/space.svg b/apps/web/public/images/spaces/space.svg new file mode 100644 index 0000000000..59be659252 --- /dev/null +++ b/apps/web/public/images/spaces/space.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M3.33333 5.99997V11.3333M6.33333 5.99997V11.3333M9.66667 5.99997V11.3333M12.6667 5.99997V11.3333M2 12.4L2 12.9333C2 13.3067 2 13.4934 2.07266 13.636C2.13658 13.7614 2.23856 13.8634 2.36401 13.9273C2.50661 14 2.6933 14 3.06667 14H12.9333C13.3067 14 13.4934 14 13.636 13.9273C13.7614 13.8634 13.8634 13.7614 13.9273 13.636C14 13.4934 14 13.3067 14 12.9333V12.4C14 12.0266 14 11.8399 13.9273 11.6973C13.8634 11.5719 13.7614 11.4699 13.636 11.406C13.4934 11.3333 13.3067 11.3333 12.9333 11.3333H3.06667C2.6933 11.3333 2.50661 11.3333 2.36401 11.406C2.23857 11.4699 2.13658 11.5719 2.07266 11.6973C2 11.8399 2 12.0266 2 12.4ZM7.76861 2.05139L2.83527 3.14769C2.53723 3.21392 2.38821 3.24704 2.27698 3.32717C2.17886 3.39786 2.10182 3.4939 2.0541 3.60502C2 3.73099 2 3.88364 2 4.18896L2 4.93331C2 5.30667 2 5.49336 2.07266 5.63597C2.13658 5.76141 2.23856 5.86339 2.36401 5.92731C2.50661 5.99997 2.6933 5.99997 3.06667 5.99997H12.9333C13.3067 5.99997 13.4934 5.99997 13.636 5.92731C13.7614 5.86339 13.8634 5.76141 13.9273 5.63597C14 5.49336 14 5.30667 14 4.93331V4.18896C14 3.88365 14 3.73099 13.9459 3.60502C13.8982 3.4939 13.8211 3.39786 13.723 3.32718C13.6118 3.24704 13.4628 3.21392 13.1647 3.14769L8.23139 2.05139C8.14504 2.0322 8.10187 2.02261 8.05824 2.01878C8.01949 2.01539 7.98051 2.01539 7.94176 2.01878C7.89813 2.02261 7.85496 2.0322 7.76861 2.05139Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> +</svg> diff --git a/apps/web/public/images/spaces/spaces.svg b/apps/web/public/images/spaces/spaces.svg new file mode 100644 index 0000000000..ea9c4c3344 --- /dev/null +++ b/apps/web/public/images/spaces/spaces.svg @@ -0,0 +1,14 @@ +<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M66.29 146.58C92.9598 146.58 114.58 124.96 114.58 98.29C114.58 71.6202 92.9598 50 66.29 50C39.6202 50 18 71.6202 18 98.29C18 124.96 39.6202 146.58 66.29 146.58Z" fill="#B0FFC9"/> +<path d="M99.35 125.7C125.501 125.7 146.7 104.501 146.7 78.35C146.7 52.1993 125.501 31 99.35 31C73.1993 31 52 52.1993 52 78.35C52 104.501 73.1993 125.7 99.35 125.7Z" fill="white"/> +<path d="M99.35 125.7C125.501 125.7 146.7 104.501 146.7 78.35C146.7 52.1993 125.501 31 99.35 31C73.1993 31 52 52.1993 52 78.35C52 104.501 73.1993 125.7 99.35 125.7Z" fill="white" stroke="#121312" stroke-width="1.33" stroke-miterlimit="10" stroke-linecap="round" stroke-dasharray="6 6"/> +<path d="M43.5699 74.4904C60.0943 74.4904 73.4899 61.0947 73.4899 44.5704C73.4899 28.046 60.0943 14.6504 43.5699 14.6504C27.0455 14.6504 13.6499 28.046 13.6499 44.5704C13.6499 61.0947 27.0455 74.4904 43.5699 74.4904Z" fill="white"/> +<path d="M46.1344 47.5343L45.5344 47.5343L45.5344 48.1343L45.5344 57.5376C45.5344 58.9374 44.3997 60.0721 43 60.0721C41.6003 60.0721 40.4656 58.9374 40.4656 57.5376L40.4656 48.1343L40.4656 47.5343L39.8656 47.5343L30.4622 47.5343C29.0625 47.5343 27.9278 46.3996 27.9278 44.9999C27.9278 43.6001 29.0625 42.4654 30.4622 42.4654L39.8656 42.4654L40.4656 42.4654L40.4656 41.8654L40.4656 32.4621C40.4656 31.0623 41.6003 29.9276 43 29.9276C44.3997 29.9276 45.5344 31.0623 45.5344 32.4621L45.5344 41.8654L45.5344 42.4654L46.1344 42.4654L55.5378 42.4654C56.9375 42.4654 58.0722 43.6001 58.0722 44.9999C58.0722 46.3996 56.9375 47.5343 55.5378 47.5343L46.1344 47.5343Z" stroke="#121312" stroke-width="1.2"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M43.57 15.3C27.4046 15.3 14.3 28.4046 14.3 44.57C14.3 60.7354 27.4046 73.84 43.57 73.84C59.7354 73.84 72.84 60.7354 72.84 44.57C72.84 28.4046 59.7354 15.3 43.57 15.3ZM13 44.57C13 27.6867 26.6867 14 43.57 14C60.4533 14 74.14 27.6867 74.14 44.57C74.14 61.4533 60.4533 75.14 43.57 75.14C26.6867 75.14 13 61.4533 13 44.57Z" fill="#121312"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M81.7364 80.156C79.7214 82.8744 78.6744 86.1542 78.3343 88.3308C78.2789 88.6855 77.9464 88.9281 77.5917 88.8727C77.2371 88.8172 76.9945 88.4848 77.0499 88.1301C77.4149 85.7939 78.5256 82.3045 80.692 79.3818C82.8703 76.4432 86.1492 74.042 90.8075 74.042C95.4795 74.042 98.6959 76.6532 100.713 79.1915C101.722 80.4624 102.445 81.73 102.915 82.6786C103.15 83.1537 103.323 83.551 103.438 83.8316C103.496 83.972 103.539 84.0833 103.568 84.1608C103.583 84.1996 103.594 84.2299 103.601 84.2511L103.61 84.2761L103.613 84.2834L103.614 84.2857L103.614 84.2865C103.614 84.2869 103.614 84.2871 103 84.4996L103.614 84.2871C103.731 84.6264 103.552 84.9966 103.212 85.1139C102.873 85.2312 102.503 85.0515 102.386 84.7125L102.386 84.7122L102.386 84.7121L102.384 84.7089L102.378 84.6918C102.373 84.676 102.364 84.6512 102.351 84.6181C102.326 84.5519 102.288 84.4526 102.235 84.3248C102.131 84.069 101.97 83.7001 101.75 83.2557C101.309 82.3655 100.633 81.1811 99.6947 80.0002C97.8152 77.6346 94.9354 75.342 90.8075 75.342C86.6658 75.342 83.7395 77.4536 81.7364 80.156Z" fill="#121312"/> +<path d="M100.115 65.8077C100.115 70.9482 95.9482 75.1154 90.8077 75.1154C85.6672 75.1154 81.5 70.9482 81.5 65.8077C81.5 60.6672 85.6672 56.5 90.8077 56.5C95.9482 56.5 100.115 60.6672 100.115 65.8077Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M90.8077 73.7854C95.2137 73.7854 98.7854 70.2137 98.7854 65.8077C98.7854 61.4017 95.2137 57.83 90.8077 57.83C86.4017 57.83 82.83 61.4017 82.83 65.8077C82.83 70.2137 86.4017 73.7854 90.8077 73.7854ZM90.8077 75.1154C95.9482 75.1154 100.115 70.9482 100.115 65.8077C100.115 60.6672 95.9482 56.5 90.8077 56.5C85.6672 56.5 81.5 60.6672 81.5 65.8077C81.5 70.9482 85.6672 75.1154 90.8077 75.1154Z" fill="#121312"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M101.544 87.656C99.5295 90.3744 98.4825 93.6542 98.1424 95.8308C98.087 96.1855 97.7545 96.4281 97.3998 96.3727C97.0452 96.3172 96.8026 95.9848 96.858 95.6301C97.223 93.2939 98.3337 89.8045 100.5 86.8818C102.678 83.9432 105.957 81.542 110.616 81.542C115.271 81.542 118.617 83.9399 120.882 86.8715C123.136 89.7897 124.355 93.2755 124.793 95.6107C124.859 95.9635 124.627 96.3032 124.274 96.3693C123.921 96.4355 123.581 96.2031 123.515 95.8502C123.107 93.6726 121.957 90.3892 119.853 87.6663C117.76 84.9569 114.76 82.842 110.616 82.842C106.474 82.842 103.548 84.9536 101.544 87.656Z" fill="#121312"/> +<path d="M119.923 73.3077C119.923 78.4482 115.756 82.6154 110.616 82.6154C105.475 82.6154 101.308 78.4482 101.308 73.3077C101.308 68.1672 105.475 64 110.616 64C115.756 64 119.923 68.1672 119.923 73.3077Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M110.616 81.2854C115.022 81.2854 118.593 77.7137 118.593 73.3077C118.593 68.9017 115.022 65.33 110.616 65.33C106.21 65.33 102.638 68.9017 102.638 73.3077C102.638 77.7137 106.21 81.2854 110.616 81.2854ZM110.616 82.6154C115.756 82.6154 119.923 78.4482 119.923 73.3077C119.923 68.1672 115.756 64 110.616 64C105.475 64 101.308 68.1672 101.308 73.3077C101.308 78.4482 105.475 82.6154 110.616 82.6154Z" fill="#121312"/> +</svg> diff --git a/apps/web/public/images/transactions/tenderly-small.svg b/apps/web/public/images/transactions/tenderly-small.svg new file mode 100644 index 0000000000..5a94e316eb --- /dev/null +++ b/apps/web/public/images/transactions/tenderly-small.svg @@ -0,0 +1,9 @@ +<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<rect y="0.393555" width="17" height="15.2128" fill="url(#pattern0_6059_15659)"/> +<defs> +<pattern id="pattern0_6059_15659" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image0_6059_15659" transform="matrix(0.0248575 0 0 0.0277778 -0.0271173 0)"/> +</pattern> +<image id="image0_6059_15659" width="156" height="36" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJwAAAAkCAYAAAB8IVNVAAAMPmlDQ1BJQ0MgUHJvZmlsZQAASImVVwdYU8kWnluSkEBoAQSkhN4EkRpASggt9N5shCRAKDEGgoodXVRw7SICNnRVRMFKsyN2FsXeFwsqyrpYsCtvUkDXfeV7831z57//nPnPmXNn7r0DgNpxjkiUi6oDkCcsEMcG+9OTU1LppKeAAHBAASZAmcPNFzGjo8MBLEPt38u76wCRtlfspVr/7P+vRYPHz+cCgERDnM7L5+ZBfAAAvJorEhcAQJTyZlMLRFIMK9ASwwAhXiTFmXJcLcXpcrxHZhMfy4K4HQAlFQ5HnAmA6iXI0wu5mVBDtR9iRyFPIARAjQ6xT17eZB7EaRBbQxsRxFJ9RvoPOpl/00wf1uRwMoexfC6yohQgyBflcqb/n+n43yUvVzLkwxJWlSxxSKx0zjBvN3Mmh0mxCsR9wvTIKIg1If4g4MnsIUYpWZKQBLk9asDNZ8GcAR2IHXmcgDCIDSAOEuZGhiv49AxBEBtiuELQaYICdjzEuhAv4ucHxilsNoknxyp8oY0ZYhZTwZ/liGV+pb7uS3ISmAr911l8tkIfUy3Kik+CmAKxeaEgMRJiVYgd8nPiwhQ2Y4uyWJFDNmJJrDR+c4hj+cJgf7k+VpghDopV2Jfm5Q/NF9uUJWBHKvC+gqz4EHl+sHYuRxY/nAt2iS9kJgzp8POTw4fmwuMHBMrnjj3jCxPiFDofRAX+sfKxOEWUG62wx035ucFS3hRil/zCOMVYPLEALki5Pp4hKoiOl8eJF2VzQqPl8eDLQThggQBABxJY08FkkA0EnX1NffBO3hMEOEAMMgEf2CuYoRFJsh4hvMaBIvAnRHyQPzzOX9bLB4WQ/zrMyq/2IEPWWygbkQOeQJwHwkAuvJfIRgmHvSWCx5AR/MM7B1YujDcXVmn/v+eH2O8MEzLhCkYy5JGuNmRJDCQGEEOIQUQbXB/3wb3wcHj1g9UJZ+AeQ/P4bk94QugiPCRcI3QTbk0SFIt/ijICdEP9IEUu0n/MBW4JNV1xf9wbqkNlXAfXB/a4C/TDxH2hZ1fIshRxS7NC/0n7bzP44Wko7MiOZJQ8guxHtv55pKqtquuwijTXP+ZHHmv6cL5Zwz0/+2f9kH0ebMN+tsQWYfuxM9gJ7Bx2GGsCdOwY1ox1YEekeHh1PZatriFvsbJ4cqCO4B/+hp6sNJP5jnWOvY5f5H0F/GnSdzRgTRZNFwsyswroTPhF4NPZQq7DKLqTo5MzANLvi/z19SZG9t1AdDq+c/P/AMD72ODg4KHvXOgxAPa6w+3f8p2zZsBPhzIAZ1u4EnGhnMOlFwJ8S6jBnaYHjIAZsIbzcQJuwAv4gUAQCqJAPEgBE2H0WXCdi8FUMBPMAyWgDCwHa0Al2Ai2gB1gN9gHmsBhcAKcBhfAJXAN3IGrpwe8AP3gHfiMIAgJoSI0RA8xRiwQO8QJYSA+SCASjsQiKUgakokIEQkyE5mPlCErkUpkM1KL7EVakBPIOaQLuYU8QHqR18gnFENVUC3UELVER6MMlImGofHoBDQTnYIWoQvQpWgFWoPuQhvRE+gF9Brajb5ABzCAKWM6mAlmjzEwFhaFpWIZmBibjZVi5VgNVo+1wud8BevG+rCPOBGn4XTcHq7gEDwB5+JT8Nn4ErwS34E34u34FfwB3o9/I1AJBgQ7gieBTUgmZBKmEkoI5YRthIOEU3Av9RDeEYlEHaIV0R3uxRRiNnEGcQlxPbGBeJzYRXxEHCCRSHokO5I3KYrEIRWQSkjrSLtIx0iXST2kD0rKSsZKTkpBSqlKQqVipXKlnUpHlS4rPVX6TFYnW5A9yVFkHnk6eRl5K7mVfJHcQ/5M0aBYUbwp8ZRsyjxKBaWecopyl/JGWVnZVNlDOUZZoDxXuUJ5j/JZ5QfKH1U0VWxVWCrjVSQqS1W2qxxXuaXyhkqlWlL9qKnUAupSai31JPU+9YMqTdVBla3KU52jWqXaqHpZ9aUaWc1Cjak2Ua1IrVxtv9pFtT51srqlOkudoz5bvUq9Rf2G+oAGTWOMRpRGnsYSjZ0a5zSeaZI0LTUDNXmaCzS3aJ7UfETDaGY0Fo1Lm0/bSjtF69EiallpsbWytcq0dmt1avVra2q7aCdqT9Ou0j6i3a2D6VjqsHVydZbp7NO5rvNphOEI5gj+iMUj6kdcHvFed6Suny5ft1S3Qfea7ic9ul6gXo7eCr0mvXv6uL6tfoz+VP0N+qf0+0ZqjfQayR1ZOnLfyNsGqIGtQazBDIMtBh0GA4ZGhsGGIsN1hicN+4x0jPyMso1WGx016jWmGfsYC4xXGx8zfk7XpjPpufQKeju938TAJMREYrLZpNPks6mVaYJpsWmD6T0zihnDLMNstVmbWb+5sXmE+UzzOvPbFmQLhkWWxVqLMxbvLa0skywXWjZZPrPStWJbFVnVWd21plr7Wk+xrrG+akO0Ydjk2Ky3uWSL2rraZtlW2V60Q+3c7AR26+26RhFGeYwSjqoZdcNexZ5pX2hfZ//AQcch3KHYocnh5Wjz0amjV4w+M/qbo6tjruNWxztjNMeEjike0zrmtZOtE9epyumqM9U5yHmOc7PzKxc7F77LBpebrjTXCNeFrm2uX93c3cRu9W697ubuae7V7jcYWoxoxhLGWQ+Ch7/HHI/DHh893TwLPPd5/uVl75XjtdPr2VirsfyxW8c+8jb15nhv9u72ofuk+Wzy6fY18eX41vg+9DPz4/lt83vKtGFmM3cxX/o7+ov9D/q/Z3myZrGOB2ABwQGlAZ2BmoEJgZWB94NMgzKD6oL6g12DZwQfDyGEhIWsCLnBNmRz2bXs/lD30Fmh7WEqYXFhlWEPw23DxeGtEWhEaMSqiLuRFpHCyKYoEMWOWhV1L9oqekr0oRhiTHRMVcyT2DGxM2PPxNHiJsXtjHsX7x+/LP5OgnWCJKEtUS1xfGJt4vukgKSVSd3Jo5NnJV9I0U8RpDSnklITU7elDowLHLdmXM941/El469PsJowbcK5ifoTcycemaQ2iTNpfxohLSltZ9oXThSnhjOQzk6vTu/nsrhruS94frzVvF6+N38l/2mGd8bKjGeZ3pmrMnuzfLPKs/oELEGl4FV2SPbG7Pc5UTnbcwZzk3Ib8pTy0vJahJrCHGH7ZKPJ0yZ3iexEJaLuKZ5T1kzpF4eJt+Uj+RPymwu04I98h8Ra8ovkQaFPYVXhh6mJU/dP05gmnNYx3Xb64ulPi4KKfpuBz+DOaJtpMnPezAezmLM2z0Zmp89um2M2Z8GcnrnBc3fMo8zLmfd7sWPxyuK385Pmty4wXDB3waNfgn+pK1EtEZfcWOi1cOMifJFgUedi58XrFn8r5ZWeL3MsKy/7soS75PyvY36t+HVwacbSzmVuyzYsJy4XLr++wnfFjpUaK4tWPloVsapxNX116eq3ayatOVfuUr5xLWWtZG13RXhF8zrzdcvXfanMqrxW5V/VUG1Qvbj6/Xre+ssb/DbUbzTcWLbx0ybBppubgzc31ljWlG8hbinc8mRr4tYzvzF+q92mv61s29ftwu3dO2J3tNe619buNNi5rA6tk9T17hq/69LugN3N9fb1mxt0Gsr2gD2SPc/3pu29vi9sX9t+xv76AxYHqg/SDpY2Io3TG/ubspq6m1Oau1pCW9pavVoPHnI4tP2wyeGqI9pHlh2lHF1wdPBY0bGB46LjfScyTzxqm9R252TyyavtMe2dp8JOnT0ddPrkGeaZY2e9zx4+53mu5TzjfNMFtwuNHa4dB393/f1gp1tn40X3i82XPC61do3tOnrZ9/KJKwFXTl9lX71wLfJa1/WE6zdvjL/RfZN389mt3Fuvbhfe/nxn7l3C3dJ76vfK7xvcr/nD5o+GbrfuIw8CHnQ8jHt45xH30YvH+Y+/9Cx4Qn1S/tT4ae0zp2eHe4N6Lz0f97znhejF576SPzX+rH5p/fLAX35/dfQn9/e8Er8afL3kjd6b7W9d3rYNRA/cf5f37vP70g96H3Z8ZHw88ynp09PPU7+QvlR8tfna+i3s293BvMFBEUfMkf0KYLCiGRkAvN4OADUFABo8n1HGyc9/soLIz6wyBP4Tlp8RZcUNgHr4/x7TB/9ubgCwZys8fkF9tfEARFMBiPcAqLPzcB06q8nOldJChOeATTFf0/PSwb8p8jPnD3H/3AKpqgv4uf0XTIh8SnYVtqgAAAA4ZVhJZk1NACoAAAAIAAGHaQAEAAAAAQAAABoAAAAAAAKgAgAEAAAAAQAAAJygAwAEAAAAAQAAACQAAAAAbKjuNwAAC55JREFUeAHtW01sG8cVniEpwfKfWBj+SwqI7s8haF0xSN2iP6npoGmOYoH+oIdCq0MBu05gKkXPoq51Y1EHF24vXqE9FD3U1LkHUY6BBm4AU02KtEbqUCkSG/5pSEeJLJHc6fdWO+vZ4e6Sktc0VOwCq3l/896bN2/fzOxSnIVc1xZEOplszgjOsgnGJ48+P1AJEY9ZcQS6RoAHSbz1enPKEqzAOUu7MkKY7fbA5LMneN2lxUAcgU1EoCPhlq60xpllFTnnGT89QrA646I4+vzgrB8/psURCIuAm3CoaDnB2BSEc2EdFF4lmUhNfulbvKrQYjCOQGgE+Dt/FZlmsznFODdCJYOY8TIbFJmY7hMBvnRlvSAsPp3gbK8PvycSLbPoX2q1UrNb3d/NFER6MPHpGZYU86fP7o6rZk+R335C9pJ69tTaV775EvvVnn2JlyIYQllwXh5MJhef+QavhenbqK7t0Q9uWC8sv9Mcb7fYsMUTJ1759VAlrN//O+/QoUMGxjiijHPu1q1bNQXvCwg/iqoh+ODBVV6vsLuHM7Ii/VRm5U8v/mRgaN9TyW/3qiBMjiofTrlUreowZFctIUQGy3eG2laTNZYut/atrojPSj1bTbjDhw+XoHNU6kFLk2Qq+LYBMdEVHNqOS4cxrhMYS0XifWoTiGlbtdVqtfbcuXNnRaVtFk7JDmaV1w0mfvTH1xqVzx9NVY//cJAP7eLqBErRnlvnlUqOOuBAkrc7gthuW9f/9Wa7fu+myNq0CP5gUrLaJC1GoDZWEXEE3IQjvXbSZUXu3281ajfebg9/7svJ6gs/HmwPDvHnorBrWeyD//xTvPf+9VYkFTQKn2Id/Y2AJ+HINCXdya9+nGdJawFJl73x9io79t3BK8+9mDqSSLKnt+IeltbGvQ/FtevXms9inxZpsmH5sZceVLdhzbcRyWu32w0sBfaSrsqkce3YsWMMtAzuHHRULcuqoZ332zM58m7VV/XC1rijh6HaYivhrwMy9gV52laMQS7tkGoPHjyYr+Ny8K7NZv0nhTImUjnGaa8Eqv/g0XbkfSmjtnoMiCd1qHIE67ZWV1f/znUhiZ/8WsNknFEQ7YuC+L2f7vz9F7PsO4z1utSKpfv3+J//8UbrB62mdVTqCms3u4fDPgOrdfgF3xcRlJyUoqANDQ1NAS9Imk9bQoCm1QRAACkpF6Qs6UXSFVKp1EXQspIuW/AnYNeUuGyhZwp6ihJX2jr6TAI3wD8u6aB17OFCdNjd0MdEAk+q/hNDjxfG+BnEgsbk+u/Yu+y3h8MrtBTkP7KNOH8Qg+/fvn27rNLgXwZjeE+loe/TCZXggRODRRVH5/Rf/rD6ym9+sXppIJU6wgSfEIxNQ4aeEPcGbZZ4JPP6pfVS9fLaZK/Jptp7nLAT4LBkI/MFRy7QFcRkOJlMXoKAO1mqMPgzFHiVBrwIelGlKXAavIu43Sqq8FwQOhZCdNhy4BvkPz1cbkcfADIXQfb130ecOQk8r/IQg5yKEwz7eZWGJF6+e/fuhx1LqhS68MZQDVVuTq1yNo+z4uyZ+/zC1eGilPVrz7+6YsDqRe7HfII0PLUlmNcDvISA0DKYBk+d7CzJ37x5sxDgMh1UAlg2mfQZuIu4aYmhp36KYPWC7QboNdCkbernezn+5zRmoP9IqCKSJMh/UpPXdHVFUdFMJNmYFIT/BHtsgJZXYwO4RPLBFQ5Mi/EyCXVcnE2dPHbf6KA7hPO/XMmi0s0E8aOkIxk43RggVVn3Aj4teXI5dZ72M64QAEcuSzKQzwKfUPmAx7tUCZrsI44PJwA3tP45iSPonkkhOtmD7TTZpuUNpCUpr7eOH+MqHf3nqK/0n5Y3lQ/4TBf/yYcG7mnnnoOOuqbDg9LySX0kEePK7N+/PytxsgfacYlTC3k7l0IT7ndX99pCakcJC27N/OzYimtE0u3WspeFtIfWEyIaCR4+2J7UBAjhaTc01hImqqjSgJvA1Umng0VelVFhBDKPPjWioa2gKRDsd0HWEy/gc449W5yWK9Ln15dojh9pyYfsMvobEqfW2UvNqzSfcbts0oG9XgZ6is5t+B2w3A4OgIQyVRr2sYbEfeLlHsICl1TZGam5iKXRk63Eg8F0kreoip1wZQGcf/WTIhpPYFV+EIyKOJdsJ4unXhuqBck8Kh3BpSfPVQP8IyxzUy7BAYiuyoGc0WUIh1wDk1TTeDrusqFTj6PpMh2A9MGnRR9Zksg4YnYDmSD/h8FzRWncLqIBOJUXKNE1clcUL4FNJJm6Wrhjg+28qgD2yxLvnnCMVyDsKpMdN1qeo6X1wt/2moTT91DBPjnDsfHr+aKETrLCy/35fppT/UJgCPfQVL6EIRc0YVUpE3Fbg76AmHss0R4y66H4IGEy+unSp7sviaog9pO0Eow6Alnao6Ja1oGPyU7OQ2lKPHRJtYWsREUK+7VYWt0sH0isGiGT4+kuGFumVyCnz+3O9fFjfc3jRO9IpnfRSCQjtRdW4R7RW1Ptj7nP+yynZVWmhwqninfC9PTgRXHuwpt7KpwJN/k6JSUFm038kvjlc7tNSeljW1NtYSJoD2WotMcMqxWBTGUC7I0E0HXyPA4MeZ3YLxyHHBP7wxnF3jhgGqN74QBSchEA3SucKh0Ai6SVp5MpVtJMgMgGWbD59fauDKqaGSq3BSYSv6p1y2g4fshseWTQZ4yWAV0OS8Ul0BfkrZ7AdNnN4Ehwj330pQnyXAcOHKDXCRkP0UEweWWN7us//L4ofXfanNYvEtTZ+80rymiJV5fTZf0A0rXCUeU6+fWGotMHFOK4aCdqnGOh9L3wnolx4+fndukB85XeChGTWcdg3a6AxxFscqiGm05zJu1XQFsGb8QRTAO+dPDgweLa2triwMBABhvhKfBo0m0R6PX9LOb032xTQYdx2Qk2cpQQsFECTP7nwStIvt767JtIZAH+F3T/ZV/yH/uqqsSjbvEQeN7JQX9a2sB4ShKWbdeEk4JhLRSjuglPKZXy+I661LR25SZLj/0fb0zYpGRxL/hlEIKgL6IxCaZTmfN1gFC6solEooylYQPT/kJHUSNtGaWkR/WkhBqVSqA/R7fEu7U4HRp4KK5JOfTN4A70H3KlrZxCpf5urfMQ04vrYV0WcS/rtEiWVFLaWudf0JXTK5WmtbMfyUbvwGqwP9vhg0agACEQExo5CJ3FHqnjKQ0S7oVOCUNVJ0SWHlzfh5f6UJUj/7vosNVDhvaoRRt5jH+QbKaPevfdm8qLpMKRwtWPrd0D+zaWIcIF3qvhYGAQ3K8LyVHAElWHPTotj0i7gInmXlRpsC+rolIUQRxzGQ6AiaKKSC9CKyqP3sCjOhLPvqC3KmHZdpOhhIHtHPQU0N9dXp0EKmP5o2+4ReCuz6RT6qdW+k86gNLy76kuQf5TX4dHYLfLgmxFFYLvnh9kSp7POzmyU5Z8tX2YISpVg7GHQ/6EXyPPJJf27tv46LyxjPansgV5RZ9XaE9GExwkI+lI0hzBNLG9yMt+UbR0IEHipPXk3oxuqeNJ+E9+kn11mUey0QvxtN8YIqtwD5XjgJBkxuQ5b1V5yO8P5OxbuiYbefMok/2oo4kiwaPQ8SjjcCqtqsK3upFA9AkneOn02V09TbTqYQxvzwhgdaBDi7s1oFGg0paCRhPZoWHDgGisWzsDjQU5EdO3dQQM1Xsspx3v3lR+tBUO1a0Prz9U/2P4CUcA1S2LJHMPUnDHDHMp0oRLWIlQY2GOxLztGYHNflrrbUnlg/hJuSeLO6LTbvF3T5Ue30+LOgzGhG0ZgZ4Sjn5ufuFqOsfaCfpFq+9LybVPN/7ReVtGIXa6bxHoKeGkN/Rd9bdX01lm8Qm8QlyWdGpXV6x3VTyG4wj4RWBTCScV0A8uH6wNZ/E5gX4Hb3+mWfkvuyX5cRtHICgCW0o4Ukb/ME3/ubW2PpxB4s0FGYjpcQTUCPwPioM0XXAoKPAAAAAASUVORK5CYII="/> +</defs> +</svg> diff --git a/apps/web/scripts/github/download_bundle_analyser_artifact.sh b/apps/web/scripts/github/download_bundle_analyser_artifact.sh deleted file mode 100644 index e5c0fa122f..0000000000 --- a/apps/web/scripts/github/download_bundle_analyser_artifact.sh +++ /dev/null @@ -1,31 +0,0 @@ -# We use this instead of action-download-artifact. See discussion on -# https://github.com/dawidd6/action-download-artifact/issues/240 -set -xe -ORG="safe-global" -REPO="safe-wallet-web" -WORKFLOW="nextjs-bundle-analysis.yml" -ARTIFACT_NAME="bundle" -DESTINATION=".next/analyze/base" -BASE_BRANCH="dev" - -ARTIFACTS_URL=$( - gh api \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/repos/${ORG}/${REPO}/actions/workflows/${WORKFLOW}/runs?event=push&branch=${BASE_BRANCH}&status=success&per_page=1" \ - --jq ".workflow_runs[0].artifacts_url" -) - -DOWNLOAD_URL=$( - gh api \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "${ARTIFACTS_URL}" \ - --jq '.artifacts[] | select(.name == "'"${ARTIFACT_NAME}"'") | .archive_download_url' -) - -set +x -curl -H "Accept: application/vnd.github+json" -H "Authorization: token $GH_TOKEN" -L -o "${DESTINATION}.zip" "$DOWNLOAD_URL" -set -x -unzip "${DESTINATION}.zip" -d "${DESTINATION}" && mkdir -p "${DESTINATION}/bundle" && mv "${DESTINATION}/__bundle_analysis.json" "${DESTINATION}/bundle/" -rm "${DESTINATION}.zip" diff --git a/apps/web/scripts/github/s3_upload.sh b/apps/web/scripts/github/s3_upload.sh index effa8204ff..00dd6da89c 100755 --- a/apps/web/scripts/github/s3_upload.sh +++ b/apps/web/scripts/github/s3_upload.sh @@ -11,11 +11,29 @@ cd out # Upload the build to S3 aws s3 sync . $BUCKET --delete +function parallel_limit { + local max="$1" + while (( $(jobs -rp | wc -l) >= max )); do + sleep 0.1 + done +} + +export BUCKET + +MAX_JOBS=10 + # Upload all HTML files again but w/o an extention so that URLs like /welcome open the right page -for file in $(find . -name '*.html' | sed 's|^\./||'); do - aws s3 cp ${file%} $BUCKET/${file%.*} --content-type 'text/html' & +find . -name '*.html' -print0 | while IFS= read -r -d '' file; do + filepath="${file#./}" + noext="${filepath%.html}" + + # Throttle jobs when max limit is hit + parallel_limit "$MAX_JOBS" + + # Upload files to S3 using parallel threads + aws s3 cp "$filepath" "$BUCKET/$noext" --content-type 'text/html' & done wait -cd - +cd - \ No newline at end of file diff --git a/apps/web/scripts/integrity-hashes.cjs b/apps/web/scripts/integrity-hashes.cjs new file mode 100644 index 0000000000..bcc87dc4ee --- /dev/null +++ b/apps/web/scripts/integrity-hashes.cjs @@ -0,0 +1,171 @@ +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const cheerio = require('cheerio') + +const OUT_DIR = 'out' +const CHUNKS_DIR = path.join(OUT_DIR, '_next', 'static', 'chunks') +const MANIFEST_JS_FILENAME = 'chunks-sri-manifest.js' + +/** + * Recursively find all JS files in `out/_next/static/chunks` + */ +function getAllChunkFiles(dir = CHUNKS_DIR) { + let results = [] + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + const entryPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + results = results.concat(getAllChunkFiles(entryPath)) + } else if (entry.isFile() && entry.name.endsWith('.js')) { + results.push(entryPath) + } + } + return results +} + +/** + * Compute the SHA-384 SRI hash for a given file + */ +function computeSriHash(filePath) { + const content = fs.readFileSync(filePath) + const hash = crypto.createHash('sha384').update(content).digest('base64') + return `sha384-${hash}` +} + +/** + * Build a mapping from each chunk's public path to its integrity hash + * e.g.: { "/_next/static/chunks/foo.js": "sha384-abc..." } + */ +function buildSriManifest() { + const allJsFiles = getAllChunkFiles(CHUNKS_DIR) + const manifest = {} + + for (const filePath of allJsFiles) { + // filePath is absolute, e.g. /path/to/out/_next/static/chunks/foo.js + // We want to create a key like "/_next/static/chunks/foo.js" + const relPath = path.relative(OUT_DIR, filePath).replace(/\\/g, '/') + // On Windows, ensure forward slashes + const publicPath = `/${relPath}` + manifest[publicPath] = computeSriHash(filePath) + } + + return manifest +} + +/** + * Write the manifest file in `out/_next/static/`. + * The script sets the global window.__CHUNK_SRI_MANIFEST + */ +function writeExternalManifest(manifestObj) { + const manifestJson = JSON.stringify(manifestObj, null, 2) + + const fileContents = ` +/** + * Auto-generated chunk SRI manifest. + * DO NOT EDIT. + */ +window.__CHUNK_SRI_MANIFEST = ${manifestJson}; +` + const manifestJsPath = path.join(OUT_DIR, '_next', 'static', MANIFEST_JS_FILENAME) + fs.writeFileSync(manifestJsPath, fileContents, 'utf8') + + return `/_next/static/${MANIFEST_JS_FILENAME}` +} + +/** + * Insert a single <script src="..."> reference into each .html file + */ +function insertManifestScriptIntoHtml(manifestScriptPath) { + function processDir(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name) + + if (entry.isDirectory()) { + processDir(entryPath) + } else if (entry.isFile() && entry.name.endsWith('.html')) { + const html = fs.readFileSync(entryPath, 'utf8') + const $ = cheerio.load(html) + + // Ideally, put it in <head> so it loads early + // so the manifest is available by the time Next tries dynamic chunks + const container = $('head').length ? $('head') : $('body') + container.append(`\n<script src="${manifestScriptPath}"></script>\n`) + + fs.writeFileSync(entryPath, $.html(), 'utf8') + } + } + } + + processDir(OUT_DIR) +} + +/** + * Process a single .html file to add SRI attributes to local script tags. + */ +function processHtmlFile(htmlFilePath) { + const html = fs.readFileSync(htmlFilePath, 'utf8') + const $ = cheerio.load(html) + + $('script[src]').each((_, scriptEl) => { + const scriptSrc = $(scriptEl).attr('src') + /** + * Skip external or protocol-based (http/https) scripts. Currently, no external scripts + * are loaded but if that is the case we should fetch those scripts here e.g. via curl + */ + if (!scriptSrc || scriptSrc.startsWith('http')) { + console.log('Skipping external script', scriptSrc) + return + } + + // Build an absolute path to the script + const scriptFilePath = path.join(path.dirname(htmlFilePath), scriptSrc) + + // Ensure the file actually exists before hashing + if (fs.existsSync(scriptFilePath) && fs.lstatSync(scriptFilePath).isFile()) { + const integrityVal = computeSriHash(scriptFilePath) + $(scriptEl).attr('integrity', integrityVal) + + console.log('Added integrity hash', integrityVal, scriptSrc) + } + }) + + // Write the updated HTML back to disk + fs.writeFileSync(htmlFilePath, $.html(), 'utf8') +} + +/** + * Recursively traverse a directory, processing .html files. + */ +function addSRIToAllHtmlFiles(dirPath) { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }) + + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name) + + if (entry.isDirectory()) { + addSRIToAllHtmlFiles(entryPath) + } else if (entry.isFile() && entry.name.endsWith('.html')) { + processHtmlFile(entryPath) + } + } +} + +/** + * Main + */ +function main() { + const sriManifest = buildSriManifest() + // 1) Write the external JS file + const manifestScriptPublicPath = writeExternalManifest(sriManifest) + // 2) Insert <script src="..."> references in each .html + insertManifestScriptIntoHtml(manifestScriptPublicPath) + // 3) Insert integrity hashes for all static html files + addSRIToAllHtmlFiles(OUT_DIR) + + console.log(`Added SRI manifest script to all .html files.`) +} + +main() diff --git a/apps/web/src/components/address-book/AddressBookTable/index.tsx b/apps/web/src/components/address-book/AddressBookTable/index.tsx index f1ee73aeec..9718f44531 100644 --- a/apps/web/src/components/address-book/AddressBookTable/index.tsx +++ b/apps/web/src/components/address-book/AddressBookTable/index.tsx @@ -124,7 +124,7 @@ function AddressBookTable({ chain, setTxFlow }: AddressBookTableProps) { variant="contained" color="primary" size="small" - onClick={() => setTxFlow(<TokenTransferFlow recipient={address} />)} + onClick={() => setTxFlow(<TokenTransferFlow recipients={[{ recipient: address }]} />)} disabled={!isOk} > Send diff --git a/apps/web/src/components/address-book/EntryDialog/index.tsx b/apps/web/src/components/address-book/EntryDialog/index.tsx index 3c809ec4c0..90fbbf078d 100644 --- a/apps/web/src/components/address-book/EntryDialog/index.tsx +++ b/apps/web/src/components/address-book/EntryDialog/index.tsx @@ -7,8 +7,8 @@ import ModalDialog from '@/components/common/ModalDialog' import NameInput from '@/components/common/NameInput' import useChainId from '@/hooks/useChainId' import { useAppDispatch } from '@/store' -import madProps from '@/utils/mad-props' import { upsertAddressBookEntries } from '@/store/addressBookSlice' +import { useChain } from '@/hooks/useChains' export type AddressEntry = { name: string @@ -29,8 +29,11 @@ function EntryDialog({ defaultValues?: AddressEntry disableAddressInput?: boolean chainIds?: string[] - currentChainId: string + currentChainId?: string }): ReactElement { + const chainId = useChainId() + const actualChainId = currentChainId ?? chainId + const currentChain = useChain(actualChainId) const dispatch = useAppDispatch() const methods = useForm<AddressEntry>({ @@ -41,7 +44,7 @@ function EntryDialog({ const { handleSubmit, formState } = methods const submitCallback = handleSubmit((data: AddressEntry) => { - dispatch(upsertAddressBookEntries({ ...data, chainIds: chainIds ?? [currentChainId] })) + dispatch(upsertAddressBookEntries({ ...data, chainIds: chainIds ?? [actualChainId] })) handleClose() }) @@ -69,11 +72,13 @@ function EntryDialog({ <Box> <AddressInput name="address" - label="Contact" + label="Address" variant="outlined" fullWidth required disabled={disableAddressInput} + chain={currentChain} + showPrefix={!!currentChainId} /> </Box> </DialogContent> @@ -98,6 +103,4 @@ function EntryDialog({ ) } -export default madProps(EntryDialog, { - currentChainId: useChainId, -}) +export default EntryDialog diff --git a/apps/web/src/components/address-book/ExportDialog/index.tsx b/apps/web/src/components/address-book/ExportDialog/index.tsx index 15256feaae..af479bbba4 100644 --- a/apps/web/src/components/address-book/ExportDialog/index.tsx +++ b/apps/web/src/components/address-book/ExportDialog/index.tsx @@ -11,8 +11,8 @@ 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' import madProps from '@/utils/mad-props' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' const COL_1 = 'address' const COL_2 = 'name' diff --git a/apps/web/src/components/address-book/ImportDialog/index.tsx b/apps/web/src/components/address-book/ImportDialog/index.tsx index 8f7e47b390..eedb59ad94 100644 --- a/apps/web/src/components/address-book/ImportDialog/index.tsx +++ b/apps/web/src/components/address-book/ImportDialog/index.tsx @@ -17,7 +17,8 @@ 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 { BRAND_NAME, HelpCenterArticle } from '@/config/constants' +import { BRAND_NAME } from '@/config/constants' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' type AddressBookCSVRow = ['address', 'name', 'chainId'] diff --git a/apps/web/src/components/address-book/ImportDialog/validation.ts b/apps/web/src/components/address-book/ImportDialog/validation.ts index 772f35e484..303c579516 100644 --- a/apps/web/src/components/address-book/ImportDialog/validation.ts +++ b/apps/web/src/components/address-book/ImportDialog/validation.ts @@ -1,6 +1,6 @@ import type { ParseResult } from 'papaparse' -import { validateAddress } from '@/utils/validation' +import { validateAddress } from '@safe-global/utils/utils/validation' export const abCsvReaderValidator = ({ size }: File): string[] | undefined => { if (size > 1_000_000) { diff --git a/apps/web/src/components/balances/AssetsTable/FiatBalance.tsx b/apps/web/src/components/balances/AssetsTable/FiatBalance.tsx new file mode 100644 index 0000000000..1dc99cc1d1 --- /dev/null +++ b/apps/web/src/components/balances/AssetsTable/FiatBalance.tsx @@ -0,0 +1,24 @@ +import FiatValue from '@/components/common/FiatValue' +import { Stack, SvgIcon, Tooltip } from '@mui/material' +import InfoIcon from '@/public/images/notifications/info.svg' +import type { Balance } from '@safe-global/store/gateway/AUTO_GENERATED/balances' + +export const FiatBalance = ({ balanceItem }: { balanceItem: Balance }) => { + const isMissingFiatConversion = balanceItem.fiatConversion === '0' && balanceItem.fiatBalance === '0' + + return ( + <Stack direction="row" spacing={0.5} alignItems="center" justifyContent="flex-end"> + <FiatValue value={isMissingFiatConversion ? null : balanceItem.fiatBalance} /> + + {isMissingFiatConversion && ( + <Tooltip + title="Provided values are indicative and we are unable to accommodate pricing requests for individual assets" + placement="top" + arrow + > + <SvgIcon component={InfoIcon} inheritViewBox color="error" fontSize="small" /> + </Tooltip> + )} + </Stack> + ) +} diff --git a/apps/web/src/components/balances/AssetsTable/FiatChange.test.tsx b/apps/web/src/components/balances/AssetsTable/FiatChange.test.tsx new file mode 100644 index 0000000000..b562c52c39 --- /dev/null +++ b/apps/web/src/components/balances/AssetsTable/FiatChange.test.tsx @@ -0,0 +1,74 @@ +import { render, screen } from '@testing-library/react' +import { FiatChange } from './FiatChange' +import { type Balance } from '@safe-global/store/gateway/AUTO_GENERATED/balances' + +describe('FiatChange', () => { + it('renders "n/a" when fiatBalance24hChange is not present', () => { + const mockBalance: Balance = { + fiatBalance24hChange: undefined, + } as Balance + + render(<FiatChange balanceItem={mockBalance} />) + expect(screen.getByText('n/a')).toBeInTheDocument() + }) + + it('renders positive change with green chip and up arrow', () => { + const mockBalance: Balance = { + fiatBalance24hChange: '5.00', // 5% increase + } as Balance + + render(<FiatChange balanceItem={mockBalance} />) + + const chip = screen.getByText('5.00%') + expect(chip).toBeInTheDocument() + expect(chip).toHaveStyle({ backgroundColor: 'success.background', color: 'success.main' }) + }) + + it('renders negative change with red chip and down arrow', () => { + const mockBalance: Balance = { + fiatBalance24hChange: '-3.00', // 3% decrease + } as Balance + + render(<FiatChange balanceItem={mockBalance} />) + + const chip = screen.getByText('3.00%') + expect(chip).toBeInTheDocument() + expect(chip).toHaveStyle({ backgroundColor: 'error.background', color: 'error.main' }) + }) + + it('renders zero change with default styling', () => { + const mockBalance: Balance = { + fiatBalance24hChange: '0', + } as Balance + + render(<FiatChange balanceItem={mockBalance} />) + + const chip = screen.getByText('0.00%') + expect(chip).toBeInTheDocument() + expect(chip).toHaveStyle({ backgroundColor: 'default', color: 'default' }) + }) + + it('renders up to 2 decimal places', () => { + const mockBalance: Balance = { + fiatBalance24hChange: '5.12345', // 5% increase + } as Balance + + render(<FiatChange balanceItem={mockBalance} />) + + const chip = screen.getByText('5.12%') + expect(chip).toBeInTheDocument() + expect(chip).toHaveStyle({ backgroundColor: 'success.background', color: 'success.main' }) + }) + + it('rounds correctly', () => { + const mockBalance: Balance = { + fiatBalance24hChange: '4.269', // 5% increase + } as Balance + + render(<FiatChange balanceItem={mockBalance} />) + + const chip = screen.getByText('4.27%') + expect(chip).toBeInTheDocument() + expect(chip).toHaveStyle({ backgroundColor: 'success.background', color: 'success.main' }) + }) +}) diff --git a/apps/web/src/components/balances/AssetsTable/FiatChange.tsx b/apps/web/src/components/balances/AssetsTable/FiatChange.tsx new file mode 100644 index 0000000000..3de8864579 --- /dev/null +++ b/apps/web/src/components/balances/AssetsTable/FiatChange.tsx @@ -0,0 +1,48 @@ +import { Chip, SvgIcon, Tooltip, Typography } from '@mui/material' +import { type Balance } from '@safe-global/store/gateway/AUTO_GENERATED/balances' +import { formatPercentage } from '@safe-global/utils/utils/formatters' +import ArrowDown from '@/public/images/balances/change-down.svg' +import ArrowUp from '@/public/images/balances/change-up.svg' + +export const FiatChange = ({ balanceItem, inline = false }: { balanceItem: Balance; inline?: boolean }) => { + if (!balanceItem.fiatBalance24hChange) { + return ( + <Typography variant="caption" color="text.secondary" paddingLeft={3} display="block"> + n/a + </Typography> + ) + } + + const changeAsNumber = Number(balanceItem.fiatBalance24hChange) / 100 + const changeLabel = formatPercentage(changeAsNumber) + const direction = changeAsNumber < 0 ? 'down' : changeAsNumber > 0 ? 'up' : 'none' + + const backgroundColor = + direction === 'down' ? 'error.background' : direction === 'up' ? 'success.background' : 'default' + const color = direction === 'down' ? 'error.main' : direction === 'up' ? 'success.main' : 'default' + + return ( + <Tooltip title="24h change"> + <Chip + size="small" + sx={{ + backgroundColor: inline ? 'transparent' : backgroundColor, + color, + padding: inline ? '0' : '2px 8px', + height: inline ? '20px' : 'inherit', + '& .MuiChip-label': { pr: inline ? 0 : 1 }, + }} + label={changeLabel} + icon={ + direction === 'down' ? ( + <SvgIcon color="error" inheritViewBox component={ArrowDown} sx={{ width: '9px', height: '6px' }} /> + ) : direction === 'up' ? ( + <SvgIcon color="success" inheritViewBox component={ArrowUp} sx={{ width: '9px', height: '6px' }} /> + ) : ( + <>-</> + ) + } + /> + </Tooltip> + ) +} diff --git a/apps/web/src/components/balances/AssetsTable/SendButton.tsx b/apps/web/src/components/balances/AssetsTable/SendButton.tsx index f6a1a41b71..34dcdcbb5c 100644 --- a/apps/web/src/components/balances/AssetsTable/SendButton.tsx +++ b/apps/web/src/components/balances/AssetsTable/SendButton.tsx @@ -9,12 +9,12 @@ import { ASSETS_EVENTS } from '@/services/analytics/events/assets' import { TokenTransferFlow } from '@/components/tx-flow/flows' import { TxModalContext } from '@/components/tx-flow' -const SendButton = ({ tokenInfo, isOutlined }: { tokenInfo: TokenInfo; isOutlined?: boolean }) => { +const SendButton = ({ tokenInfo, light }: { tokenInfo: TokenInfo; light?: boolean }) => { const spendingLimit = useSpendingLimit(tokenInfo) const { setTxFlow } = useContext(TxModalContext) const onSendClick = () => { - setTxFlow(<TokenTransferFlow tokenAddress={tokenInfo.address} />) + setTxFlow(<TokenTransferFlow recipients={[{ tokenAddress: tokenInfo.address }]} />) } return ( @@ -22,13 +22,14 @@ const SendButton = ({ tokenInfo, isOutlined }: { tokenInfo: TokenInfo; isOutline {(isOk) => ( <Track {...ASSETS_EVENTS.SEND}> <Button - variant={isOutlined ? 'outlined' : 'contained'} - color="primary" - size="small" + data-testid="send-button" + variant="contained" + color={light ? 'background.paper' : 'primary'} + size="compact" startIcon={<ArrowIconNW />} onClick={onSendClick} disabled={!isOk} - sx={{ height: '37.5px' }} + sx={{ height: 32, px: 2 }} > Send </Button> diff --git a/apps/web/src/components/balances/AssetsTable/index.test.tsx b/apps/web/src/components/balances/AssetsTable/index.test.tsx index ce7b4de018..0068565f6d 100644 --- a/apps/web/src/components/balances/AssetsTable/index.test.tsx +++ b/apps/web/src/components/balances/AssetsTable/index.test.tsx @@ -2,12 +2,13 @@ import * as useChainId from '@/hooks/useChainId' import useHiddenTokens from '@/hooks/useHiddenTokens' import { TOKEN_LISTS } from '@/store/settingsSlice' import { act, fireEvent, getByRole, getByTestId, render, waitFor } from '@/tests/test-utils' -import { safeParseUnits } from '@/utils/formatters' +import { safeParseUnits } from '@safe-global/utils/utils/formatters' import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' import { toBeHex } from 'ethers' import { useState } from 'react' import AssetsTable from '.' import { COLLAPSE_TIMEOUT_MS } from './useHideAssets' +import { type Balances } from '@safe-global/store/gateway/AUTO_GENERATED/balances' const getParentRow = (element: HTMLElement | null) => { while (element !== null) { @@ -47,45 +48,44 @@ describe('AssetsTable', () => { const mockHiddenAssets = { '5': [toBeHex('0x2', 20), toBeHex('0x3', 20)], } - const mockBalances = { - data: { - fiatTotal: '300', - items: [ - { - balance: safeParseUnits('100', 18)!.toString(), - fiatBalance: '100', - fiatConversion: '1', - tokenInfo: { - address: toBeHex('0x2', 20), - decimals: 18, - logoUri: '', - name: 'DAI', - symbol: 'DAI', - type: TokenType.ERC20, - }, + const mockBalances: Balances = { + fiatTotal: '300', + items: [ + { + balance: safeParseUnits('100', 18)!.toString(), + fiatBalance: '100', + fiatConversion: '1', + tokenInfo: { + address: toBeHex('0x2', 20), + decimals: 18, + logoUri: '', + name: 'DAI', + symbol: 'DAI', + type: TokenType.ERC20, }, - { - balance: safeParseUnits('200', 18)!.toString(), - fiatBalance: '200', - fiatConversion: '1', - tokenInfo: { - address: toBeHex('0x3', 20), - decimals: 18, - logoUri: '', - name: 'SPAM', - symbol: 'SPM', - type: TokenType.ERC20, - }, + }, + { + balance: safeParseUnits('200', 18)!.toString(), + fiatBalance: '200', + fiatConversion: '1', + tokenInfo: { + address: toBeHex('0x3', 20), + decimals: 18, + logoUri: '', + name: 'SPAM', + symbol: 'SPM', + type: TokenType.ERC20, }, - ], - }, - loading: false, - error: undefined, + }, + ], } const result = render(<TestComponent />, { initialReduxState: { - balances: mockBalances, + balances: { + data: mockBalances, + loading: false, + }, settings: { currency: 'usd', hiddenTokens: mockHiddenAssets, @@ -153,45 +153,44 @@ describe('AssetsTable', () => { const mockHiddenAssets = { '5': [toBeHex('0x2', 20), toBeHex('0x3', 20), toBeHex('0xdead', 20)], } - const mockBalances = { - data: { - fiatTotal: '300', - items: [ - { - balance: safeParseUnits('100', 18)!.toString(), - fiatBalance: '100', - fiatConversion: '1', - tokenInfo: { - address: toBeHex('0x2', 20), - decimals: 18, - logoUri: '', - name: 'DAI', - symbol: 'DAI', - type: TokenType.ERC20, - }, + const mockBalances: Balances = { + fiatTotal: '300', + items: [ + { + balance: safeParseUnits('100', 18)!.toString(), + fiatBalance: '100', + fiatConversion: '1', + tokenInfo: { + address: toBeHex('0x2', 20), + decimals: 18, + logoUri: '', + name: 'DAI', + symbol: 'DAI', + type: TokenType.ERC20, }, - { - balance: safeParseUnits('200', 18)!.toString(), - fiatBalance: '200', - fiatConversion: '1', - tokenInfo: { - address: toBeHex('0x3', 20), - decimals: 18, - logoUri: '', - name: 'SPAM', - symbol: 'SPM', - type: TokenType.ERC20, - }, + }, + { + balance: safeParseUnits('200', 18)!.toString(), + fiatBalance: '200', + fiatConversion: '1', + tokenInfo: { + address: toBeHex('0x3', 20), + decimals: 18, + logoUri: '', + name: 'SPAM', + symbol: 'SPM', + type: TokenType.ERC20, }, - ], - }, - loading: false, - error: undefined, + }, + ], } const result = render(<TestComponent />, { initialReduxState: { - balances: mockBalances, + balances: { + data: mockBalances, + loading: false, + }, settings: { currency: 'usd', hiddenTokens: mockHiddenAssets, @@ -255,45 +254,44 @@ describe('AssetsTable', () => { const mockHiddenAssets = { '5': [], } - const mockBalances = { - data: { - fiatTotal: '300', - items: [ - { - balance: safeParseUnits('100', 18)!.toString(), - fiatBalance: '100', - fiatConversion: '1', - tokenInfo: { - address: toBeHex('0x2', 20), - decimals: 18, - logoUri: '', - name: 'DAI', - symbol: 'DAI', - type: TokenType.ERC20, - }, + const mockBalances: Balances = { + fiatTotal: '300', + items: [ + { + balance: safeParseUnits('100', 18)!.toString(), + fiatBalance: '100', + fiatConversion: '1', + tokenInfo: { + address: toBeHex('0x2', 20), + decimals: 18, + logoUri: '', + name: 'DAI', + symbol: 'DAI', + type: TokenType.ERC20, }, - { - balance: safeParseUnits('200', 18)!.toString(), - fiatBalance: '200', - fiatConversion: '1', - tokenInfo: { - address: toBeHex('0x3', 20), - decimals: 18, - logoUri: '', - name: 'SPAM', - symbol: 'SPM', - type: TokenType.ERC20, - }, + }, + { + balance: safeParseUnits('200', 18)!.toString(), + fiatBalance: '200', + fiatConversion: '1', + tokenInfo: { + address: toBeHex('0x3', 20), + decimals: 18, + logoUri: '', + name: 'SPAM', + symbol: 'SPM', + type: TokenType.ERC20, }, - ], - }, - loading: false, - error: undefined, + }, + ], } const result = render(<TestComponent />, { initialReduxState: { - balances: mockBalances, + balances: { + data: mockBalances, + loading: false, + }, settings: { currency: 'usd', hiddenTokens: mockHiddenAssets, @@ -354,45 +352,44 @@ describe('AssetsTable', () => { const mockHiddenAssets = { '5': [], } - const mockBalances = { - data: { - fiatTotal: '300', - items: [ - { - balance: safeParseUnits('100', 18)!.toString(), - fiatBalance: '100', - fiatConversion: '1', - tokenInfo: { - address: toBeHex('0x2', 20), - decimals: 18, - logoUri: '', - name: 'DAI', - symbol: 'DAI', - type: TokenType.ERC20, - }, + const mockBalances: Balances = { + fiatTotal: '300', + items: [ + { + balance: safeParseUnits('100', 18)!.toString(), + fiatBalance: '100', + fiatConversion: '1', + tokenInfo: { + address: toBeHex('0x2', 20), + decimals: 18, + logoUri: '', + name: 'DAI', + symbol: 'DAI', + type: TokenType.ERC20, }, - { - balance: safeParseUnits('200', 18)!.toString(), - fiatBalance: '200', - fiatConversion: '1', - tokenInfo: { - address: toBeHex('0x3', 20), - decimals: 18, - logoUri: '', - name: 'SPAM', - symbol: 'SPM', - type: TokenType.ERC20, - }, + }, + { + balance: safeParseUnits('200', 18)!.toString(), + fiatBalance: '200', + fiatConversion: '1', + tokenInfo: { + address: toBeHex('0x3', 20), + decimals: 18, + logoUri: '', + name: 'SPAM', + symbol: 'SPM', + type: TokenType.ERC20, }, - ], - }, - loading: false, - error: undefined, + }, + ], } const result = render(<TestComponent />, { initialReduxState: { - balances: mockBalances, + balances: { + data: mockBalances, + loading: false, + }, settings: { currency: 'usd', hiddenTokens: mockHiddenAssets, diff --git a/apps/web/src/components/balances/AssetsTable/index.tsx b/apps/web/src/components/balances/AssetsTable/index.tsx index 50d75aecd0..6cc0aeada2 100644 --- a/apps/web/src/components/balances/AssetsTable/index.tsx +++ b/apps/web/src/components/balances/AssetsTable/index.tsx @@ -1,17 +1,13 @@ import CheckBalance from '@/features/counterfactual/CheckBalance' import { type ReactElement } from 'react' -import { Box, IconButton, Checkbox, Skeleton, SvgIcon, Tooltip, Typography } from '@mui/material' -import type { TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { Box, Checkbox, IconButton, Skeleton, Tooltip, Typography } from '@mui/material' import css from './styles.module.css' -import FiatValue from '@/components/common/FiatValue' import TokenAmount from '@/components/common/TokenAmount' import TokenIcon from '@/components/common/TokenIcon' import EnhancedTable, { type EnhancedTableProps } from '@/components/common/EnhancedTable' import TokenExplorerLink from '@/components/common/TokenExplorerLink' import Track from '@/components/common/Track' import { ASSETS_EVENTS } from '@/services/analytics/events/assets' -import InfoIcon from '@/public/images/notifications/info.svg' import { VisibilityOutlined } from '@mui/icons-material' import TokenMenu from '../TokenMenu' import useBalances from '@/hooks/useBalances' @@ -21,9 +17,18 @@ import SwapButton from '@/features/swap/components/SwapButton' import { SWAP_LABELS } from '@/services/analytics/events/swaps' import SendButton from './SendButton' import useIsSwapFeatureEnabled from '@/features/swap/hooks/useIsSwapFeatureEnabled' -import useIsStakingFeatureEnabled from '@/features/stake/hooks/useIsSwapFeatureEnabled' +import useIsStakingFeatureEnabled from '@/features/stake/hooks/useIsStakingFeatureEnabled' import { STAKE_LABELS } from '@/services/analytics/events/stake' import StakeButton from '@/features/stake/components/StakeButton' +import { FiatBalance } from './FiatBalance' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { type Balance } from '@safe-global/store/gateway/AUTO_GENERATED/balances' +import { FiatChange } from './FiatChange' +import useIsEarnFeatureEnabled from '@/features/earn/hooks/useIsEarnFeatureEnabled' +import EarnButton from '@/features/earn/components/EarnButton' +import { EARN_LABELS } from '@/services/analytics/events/earn' +import { isEligibleEarnToken } from '@/features/earn/utils' +import useChainId from '@/hooks/useChainId' const skeletonCells: EnhancedTableProps['rows'][0]['cells'] = { asset: { @@ -53,6 +58,14 @@ const skeletonCells: EnhancedTableProps['rows'][0]['cells'] = { </Typography> ), }, + change: { + rawValue: '0', + content: ( + <Typography> + <Skeleton width="32px" /> + </Typography> + ), + }, actions: { rawValue: '', sticky: true, @@ -62,7 +75,7 @@ const skeletonCells: EnhancedTableProps['rows'][0]['cells'] = { const skeletonRows: EnhancedTableProps['rows'] = Array(3).fill({ cells: skeletonCells }) -const isNativeToken = (tokenInfo: TokenInfo) => { +const isNativeToken = (tokenInfo: Balance['tokenInfo']) => { return tokenInfo.type === TokenType.NATIVE_TOKEN } @@ -70,23 +83,29 @@ const headCells = [ { id: 'asset', label: 'Asset', - width: '60%', + width: '44%', }, { id: 'balance', label: 'Balance', - width: '20%', + width: '14%', }, { id: 'value', label: 'Value', - width: '20%', + width: '14%', align: 'right', }, + { + id: 'change', + label: '24h change', + width: '14%', + align: 'left', + }, { id: 'actions', label: '', - width: '20%', + width: '14%', sticky: true, }, ] @@ -99,8 +118,10 @@ const AssetsTable = ({ setShowHiddenAssets: (hidden: boolean) => void }): ReactElement => { const { balances, loading } = useBalances() + const chainId = useChainId() const isSwapFeatureEnabled = useIsSwapFeatureEnabled() const isStakingFeatureEnabled = useIsStakingFeatureEnabled() + const isEarnFeatureEnabled = useIsEarnFeatureEnabled() const { isAssetSelected, toggleAsset, hidingAsset, hideAsset, cancel, deselectAll, saveChanges } = useHideAssets(() => setShowHiddenAssets(false), @@ -138,12 +159,16 @@ const AssetsTable = ({ <StakeButton tokenInfo={item.tokenInfo} trackingLabel={STAKE_LABELS.asset} /> )} + {isEarnFeatureEnabled && isEligibleEarnToken(chainId, item.tokenInfo.address) && ( + <EarnButton tokenInfo={item.tokenInfo} trackingLabel={EARN_LABELS.asset} /> + )} + {!isNative && <TokenExplorerLink address={item.tokenInfo.address} />} </div> ), }, balance: { - rawValue: Number(item.balance) / 10 ** item.tokenInfo.decimals, + rawValue: Number(item.balance) / 10 ** (item.tokenInfo.decimals ?? 0), collapsed: item.tokenInfo.address === hidingAsset, content: ( <TokenAmount @@ -156,32 +181,15 @@ const AssetsTable = ({ value: { rawValue: rawFiatValue, collapsed: item.tokenInfo.address === hidingAsset, - content: ( - <Typography textAlign="right"> - <FiatValue value={item.fiatBalance} /> - - {rawFiatValue === 0 && ( - <Tooltip - title="Provided values are indicative and we are unable to accommodate pricing requests for individual assets" - placement="top" - arrow - > - <span> - <SvgIcon - component={InfoIcon} - inheritViewBox - color="error" - fontSize="small" - sx={{ verticalAlign: 'middle', ml: 0.5, mr: [0, '-20px'], mt: '-2px' }} - /> - </span> - </Tooltip> - )} - </Typography> - ), + content: <FiatBalance balanceItem={item} />, + }, + change: { + rawValue: item.fiatBalance24hChange ? Number(item.fiatBalance24hChange) : null, + collapsed: item.tokenInfo.address === hidingAsset, + content: <FiatChange balanceItem={item} />, }, actions: { - rawValue: '', + rawValue: Number(item.fiatBalance24hChange), sticky: true, collapsed: item.tokenInfo.address === hidingAsset, content: ( diff --git a/apps/web/src/components/balances/HiddenTokenButton/index.test.tsx b/apps/web/src/components/balances/HiddenTokenButton/index.test.tsx index facd302a27..cb8f5d63d0 100644 --- a/apps/web/src/components/balances/HiddenTokenButton/index.test.tsx +++ b/apps/web/src/components/balances/HiddenTokenButton/index.test.tsx @@ -2,10 +2,12 @@ import * as useChainId from '@/hooks/useChainId' import { fireEvent, render } from '@/tests/test-utils' import { toBeHex } from 'ethers' import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' -import { safeParseUnits } from '@/utils/formatters' +import { safeParseUnits } from '@safe-global/utils/utils/formatters' import HiddenTokenButton from '.' import { useState } from 'react' import { TOKEN_LISTS } from '@/store/settingsSlice' +import { type Balances } from '@safe-global/store/gateway/AUTO_GENERATED/balances' +import { type Loadable } from '@/store/common' const TestComponent = () => { const [showHidden, setShowHidden] = useState(false) @@ -25,7 +27,7 @@ describe('HiddenTokenToggle', () => { const mockHiddenAssets = { '5': [toBeHex('0x3', 20)], } - const mockBalances = { + const mockBalances: Loadable<Balances> = { data: { fiatTotal: '300', items: [ diff --git a/apps/web/src/components/balances/HiddenTokenButton/index.tsx b/apps/web/src/components/balances/HiddenTokenButton/index.tsx index df9593c361..43f26e0f85 100644 --- a/apps/web/src/components/balances/HiddenTokenButton/index.tsx +++ b/apps/web/src/components/balances/HiddenTokenButton/index.tsx @@ -7,7 +7,7 @@ import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined' import Track from '@/components/common/Track' import css from './styles.module.css' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' const HiddenTokenButton = ({ toggleShowHiddenAssets, diff --git a/apps/web/src/components/balances/TokenListSelect/index.tsx b/apps/web/src/components/balances/TokenListSelect/index.tsx index e300bc4e16..d31d6de068 100644 --- a/apps/web/src/components/balances/TokenListSelect/index.tsx +++ b/apps/web/src/components/balances/TokenListSelect/index.tsx @@ -1,6 +1,5 @@ import { useAppDispatch, useAppSelector } from '@/store' import { selectSettings, setTokenList, TOKEN_LISTS } from '@/store/settingsSlice' -import { FEATURES } from '@/utils/chains' import type { SelectChangeEvent } from '@mui/material' import { Box, SvgIcon, Tooltip, Typography, FormControl, InputLabel, Select, MenuItem } from '@mui/material' import InfoIcon from '@/public/images/notifications/info.svg' @@ -8,8 +7,9 @@ import ExternalLink from '@/components/common/ExternalLink' import { OnboardingTooltip } from '@/components/common/OnboardingTooltip' import Track from '@/components/common/Track' import { ASSETS_EVENTS, trackEvent } from '@/services/analytics' -import { HelpCenterArticle } from '@/config/constants' import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' const LS_TOKENLIST_ONBOARDING = 'tokenlist_onboarding' diff --git a/apps/web/src/components/batch/BatchSidebar/BatchTxItem.tsx b/apps/web/src/components/batch/BatchSidebar/BatchTxItem.tsx index e1d3d08566..0b6b4a9b91 100644 --- a/apps/web/src/components/batch/BatchSidebar/BatchTxItem.tsx +++ b/apps/web/src/components/batch/BatchSidebar/BatchTxItem.tsx @@ -9,7 +9,7 @@ import DeleteIcon from '@/public/images/common/delete.svg' import TxData from '@/components/transactions/TxDetails/TxData' import { MethodDetails } from '@/components/transactions/TxDetails/TxData/DecodedData/MethodDetails' import { TxDataRow } from '@/components/transactions/TxDetails/Summary/TxDataRow' -import { dateString } from '@/utils/formatters' +import { dateString } from '@safe-global/utils/utils/formatters' import { BATCH_EVENTS, trackEvent } from '@/services/analytics' type BatchTxItemProps = DraftBatchItem & { diff --git a/apps/web/src/components/common/AddressBookInput/index.test.tsx b/apps/web/src/components/common/AddressBookInput/index.test.tsx index ee910e6dcc..7e0baf567a 100644 --- a/apps/web/src/components/common/AddressBookInput/index.test.tsx +++ b/apps/web/src/components/common/AddressBookInput/index.test.tsx @@ -7,7 +7,7 @@ import { useCurrentChain } from '@/hooks/useChains' import { faker } from '@faker-js/faker' import { chainBuilder } from '@/tests/builders/chains' import { FEATURES } from '@safe-global/safe-gateway-typescript-sdk' -import { checksumAddress } from '@/utils/addresses' +import { checksumAddress } from '@safe-global/utils/utils/addresses' import type { AddressBook } from '@/store/addressBookSlice' // We use Rinkeby and chainId 4 here as this is our default url chain (see jest.setup.js) diff --git a/apps/web/src/components/common/AddressBookInput/index.tsx b/apps/web/src/components/common/AddressBookInput/index.tsx index 9cc9c28099..59a73ab5af 100644 --- a/apps/web/src/components/common/AddressBookInput/index.tsx +++ b/apps/web/src/components/common/AddressBookInput/index.tsx @@ -9,8 +9,8 @@ import InfoIcon from '@/public/images/notifications/info.svg' import EntryDialog from '@/components/address-book/EntryDialog' import css from './styles.module.css' import inputCss from '@/styles/inputs.module.css' -import { isValidAddress } from '@/utils/validation' -import { sameAddress } from '@/utils/addresses' +import { isValidAddress } from '@safe-global/utils/utils/validation' +import { sameAddress } from '@safe-global/utils/utils/addresses' const abFilterOptions = createFilterOptions({ stringify: (option: { label: string; name: string }) => option.name + ' ' + option.label, diff --git a/apps/web/src/components/common/AddressInput/index.test.tsx b/apps/web/src/components/common/AddressInput/index.test.tsx index ee5305e679..3553072eb4 100644 --- a/apps/web/src/components/common/AddressInput/index.test.tsx +++ b/apps/web/src/components/common/AddressInput/index.test.tsx @@ -100,6 +100,7 @@ describe('AddressInput tests', () => { expect(input.value).toBe(`eth:${TEST_ADDRESS_A}`) }) + // TODO: This test is flaky and regularly times out after 5000ms it('should validate the address on input', async () => { const { input, utils } = setup('') @@ -149,6 +150,7 @@ describe('AddressInput tests', () => { await waitFor(() => expect(utils.getByLabelText(`${TEST_ADDRESS_B} is wrong`, { exact: false })).toBeDefined()) }) + // TODO: This test is flaky and regularly times out after 5000ms it('should show a spinner when validation is in progress', async () => { const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) @@ -181,9 +183,10 @@ describe('AddressInput tests', () => { fireEvent.change(input, { target: { value: 'zero.eth' } }) }) - await waitFor(() => expect(input.value).toBe('0x0000000000000000000000000000000000000000')) - - expect(useNameResolver).toHaveBeenCalledWith('zero.eth') + await waitFor(() => { + expect(input.value).toBe('0x0000000000000000000000000000000000000000') + expect(useNameResolver).toHaveBeenCalledWith('zero.eth') + }) }) it('should show an error if ENS resolution has failed', async () => { @@ -226,16 +229,14 @@ describe('AddressInput tests', () => { expect(input.previousElementSibling?.textContent).toBe(`${mockChain.shortName}:`) }) - it('should not show the adornment prefix when the value contains correct prefix', async () => { + // TODO: Fix this test + it.skip('should not show the adornment prefix when the value contains correct prefix', async () => { const mockChain = chainBuilder().with({ features: [] }).build() ;(useCurrentChain as jest.Mock).mockImplementation(() => mockChain) const { input } = setup(`${mockChain.shortName}:${TEST_ADDRESS_A}`) - await act(() => { - fireEvent.change(input, { target: { value: `${mockChain.shortName}:${TEST_ADDRESS_B}` } }) - return Promise.resolve() - }) + fireEvent.change(input, { target: { value: `${mockChain.shortName}:${TEST_ADDRESS_B}` } }) await waitFor(() => expect(input.previousElementSibling?.textContent).toBe('')) }) diff --git a/apps/web/src/components/common/AddressInput/index.tsx b/apps/web/src/components/common/AddressInput/index.tsx index 4bfa83da24..9700e53c21 100644 --- a/apps/web/src/components/common/AddressInput/index.tsx +++ b/apps/web/src/components/common/AddressInput/index.tsx @@ -1,5 +1,6 @@ import AddressInputReadOnly from '@/components/common/AddressInputReadOnly' import useAddressBook from '@/hooks/useAddressBook' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import type { ReactElement } from 'react' import { useEffect, useCallback, useRef, useMemo } from 'react' import { @@ -10,13 +11,13 @@ import { IconButton, SvgIcon, Skeleton, + Box, } from '@mui/material' import { useFormContext, useWatch, type Validate, get } from 'react-hook-form' -import { validatePrefixedAddress } from '@/utils/validation' +import { validatePrefixedAddress } from '@safe-global/utils/utils/validation' import { useCurrentChain } from '@/hooks/useChains' import useNameResolver from './useNameResolver' -import { FEATURES, hasFeature } from '@/utils/chains' -import { cleanInputValue, parsePrefixedAddress } from '@/utils/addresses' +import { cleanInputValue, parsePrefixedAddress } from '@safe-global/utils/utils/addresses' import useDebounce from '@/hooks/useDebounce' import CaretDownIcon from '@/public/images/common/caret-down.svg' import SaveAddressIcon from '@/public/images/common/save-address.svg' @@ -24,6 +25,7 @@ import classnames from 'classnames' import css from './styles.module.css' import inputCss from '@/styles/inputs.module.css' import Identicon from '../Identicon' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' export type AddressInputProps = TextFieldProps & { name: string @@ -33,6 +35,9 @@ export type AddressInputProps = TextFieldProps & { validate?: Validate<string> deps?: string | string[] onAddressBookClick?: () => void + chain?: ChainInfo + showPrefix?: boolean + onReset?: () => void } const AddressInput = ({ @@ -43,6 +48,9 @@ const AddressInput = ({ isAutocompleteOpen, onAddressBookClick, deps, + chain, + showPrefix = true, + onReset, ...props }: AddressInputProps): ReactElement => { const { @@ -56,7 +64,7 @@ const AddressInput = ({ const currentChain = useCurrentChain() const rawValueRef = useRef<string>('') const watchedValue = useWatch({ name, control }) - const currentShortName = currentChain?.shortName || '' + const currentShortName = chain?.shortName || currentChain?.shortName || '' const addressBook = useAddressBook() @@ -75,6 +83,23 @@ const AddressInput = ({ // Validation function based on the current chain prefix const validatePrefixed = useMemo(() => validatePrefixedAddress(currentShortName), [currentShortName]) + const transformAddressValue = useCallback( + (value: string): string => { + // Clean the input value + const cleanValue = cleanInputValue(value) + rawValueRef.current = cleanValue + // This also checksums the address + if (validatePrefixed(cleanValue) === undefined) { + // if the prefix is correct we remove it from the value + return parsePrefixedAddress(cleanValue).address + } else { + // we keep invalid prefixes such that the validation error is persistent + return cleanValue + } + }, + [validatePrefixed], + ) + // Update the input value const setAddressValue = useCallback( (value: string) => setValue(name, value, { shouldValidate: true }), @@ -88,6 +113,16 @@ const AddressInput = ({ } }, [address, currentShortName, setAddressValue]) + // Retransform the value when chain changes + useEffect(() => { + if (address) return + + if (watchedValue) { + const transformedValue = transformAddressValue(watchedValue) + setAddressValue(transformedValue) + } + }, [address, currentShortName, setAddressValue, transformAddressValue, watchedValue]) + const endAdornment = ( <InputAdornment position="end"> {resolving || isValidating ? ( @@ -117,6 +152,7 @@ const AddressInput = ({ const resetName = () => { if (!props.disabled && addressBook[watchedValue]) { setValue(name, '') + onReset?.() } } @@ -137,17 +173,19 @@ const AddressInput = ({ className: addressBook[watchedValue] ? css.readOnly : undefined, startAdornment: addressBook[watchedValue] ? ( - <AddressInputReadOnly address={watchedValue} /> + <AddressInputReadOnly address={watchedValue} showPrefix={showPrefix} chainId={chain?.chainId} /> ) : ( // Display the current short name in the adornment, unless the value contains the same prefix - <InputAdornment position="end" sx={{ ml: 0, gap: 1 }}> - {watchedValue && !fieldError ? ( - <Identicon address={watchedValue} size={32} /> - ) : ( - <Skeleton variant="circular" width={32} height={32} animation={false} /> - )} - - {!rawValueRef.current.startsWith(`${currentShortName}:`) && <>{currentShortName}:</>} + <InputAdornment position="end" sx={{ ml: 0 }}> + <Box mr={1}> + {watchedValue && !fieldError ? ( + <Identicon address={watchedValue} size={32} /> + ) : ( + <Skeleton variant="circular" width={32} height={32} animation={false} /> + )} + </Box> + + {showPrefix && !rawValueRef.current.startsWith(`${currentShortName}:`) && <Box>{currentShortName}:</Box>} </InputAdornment> ), @@ -162,19 +200,7 @@ const AddressInput = ({ required, - setValueAs: (value: string): string => { - // Clean the input value - const cleanValue = cleanInputValue(value) - rawValueRef.current = cleanValue - // This also checksums the address - if (validatePrefixed(cleanValue) === undefined) { - // if the prefix is correct we remove it from the value - return parsePrefixedAddress(cleanValue).address - } else { - // we keep invalid prefixes such that the validation error is persistet - return cleanValue - } - }, + setValueAs: transformAddressValue, validate: async () => { const value = rawValueRef.current diff --git a/apps/web/src/components/common/AddressInput/useNameResolver.ts b/apps/web/src/components/common/AddressInput/useNameResolver.ts index ddd4f8a6fb..e69ddca611 100644 --- a/apps/web/src/components/common/AddressInput/useNameResolver.ts +++ b/apps/web/src/components/common/AddressInput/useNameResolver.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { isDomain, resolveName } from '@/services/ens' import useDebounce from '@/hooks/useDebounce' diff --git a/apps/web/src/components/common/AddressInputReadOnly/index.tsx b/apps/web/src/components/common/AddressInputReadOnly/index.tsx index 65f5fa192d..88d1cc24d4 100644 --- a/apps/web/src/components/common/AddressInputReadOnly/index.tsx +++ b/apps/web/src/components/common/AddressInputReadOnly/index.tsx @@ -3,12 +3,27 @@ import { InputAdornment, Typography } from '@mui/material' import EthHashInfo from '@/components/common/EthHashInfo' import css from './styles.module.css' -const AddressInputReadOnly = ({ address }: { address: string }): ReactElement => { +const AddressInputReadOnly = ({ + address, + showPrefix, + chainId, +}: { + address: string + showPrefix?: boolean + chainId?: string +}): ReactElement => { return ( <div className={css.input} data-testid="address-book-recipient"> <InputAdornment position="start"> <Typography variant="body2" component="div" width={1}> - <EthHashInfo address={address} shortAddress={false} copyAddress={false} /> + <EthHashInfo + address={address} + shortAddress={false} + copyAddress={false} + chainId={chainId} + showPrefix={showPrefix} + avatarSize={32} + /> </Typography> </InputAdornment> </div> diff --git a/apps/web/src/components/common/BlockedAddress/index.tsx b/apps/web/src/components/common/BlockedAddress/index.tsx index 33e9c90d40..ee8df7b5b4 100644 --- a/apps/web/src/components/common/BlockedAddress/index.tsx +++ b/apps/web/src/components/common/BlockedAddress/index.tsx @@ -1,6 +1,6 @@ import type { ReactElement } from 'react' import { useMediaQuery, useTheme } from '@mui/material' -import { shortenAddress } from '@/utils/formatters' +import { shortenAddress } from '@safe-global/utils/utils/formatters' import { useRouter } from 'next/router' import Disclaimer from '@/components/common/Disclaimer' import { AppRoutes } from '@/config/routes' diff --git a/apps/web/src/components/common/Breadcrumbs/BreadcrumbItem.tsx b/apps/web/src/components/common/Breadcrumbs/BreadcrumbItem.tsx new file mode 100644 index 0000000000..0365864ce6 --- /dev/null +++ b/apps/web/src/components/common/Breadcrumbs/BreadcrumbItem.tsx @@ -0,0 +1,31 @@ +import Link from 'next/link' +import type { UrlObject } from 'url' +import { Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material' +import useAddressBook from '@/hooks/useAddressBook' +import { shortenAddress } from '@safe-global/utils/utils/formatters' +import css from './styles.module.css' +import Identicon from '@/components/common/Identicon' + +export const BreadcrumbItem = ({ title, address, href }: { title: string; address: string; href?: UrlObject }) => { + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + const addressBook = useAddressBook() + const name = addressBook[address] ?? (isMobile ? shortenAddress(address) : address) + + return ( + <Tooltip title={title}> + <div className={css.breadcrumb}> + <Identicon address={address} size={20} /> + {href ? ( + <Link href={href}> + <Typography variant="body2" color="text.secondary"> + {name} + </Typography> + </Link> + ) : ( + <Typography variant="body2">{name}</Typography> + )} + </div> + </Tooltip> + ) +} diff --git a/apps/web/src/components/common/Breadcrumbs/index.tsx b/apps/web/src/components/common/Breadcrumbs/index.tsx new file mode 100644 index 0000000000..bf64f65fce --- /dev/null +++ b/apps/web/src/components/common/Breadcrumbs/index.tsx @@ -0,0 +1,14 @@ +import css from './styles.module.css' +import SpaceBreadcrumbs from '@/features/spaces/components/SpaceBreadcrumbs' +import { NestedSafeBreadcrumbs } from '@/components/common/NestedSafeBreadcrumbs' + +const Breadcrumbs = () => { + return ( + <div className={css.container} data-testid="safe-breadcrumb-container"> + <SpaceBreadcrumbs /> + <NestedSafeBreadcrumbs /> + </div> + ) +} + +export default Breadcrumbs diff --git a/apps/web/src/components/common/Breadcrumbs/styles.module.css b/apps/web/src/components/common/Breadcrumbs/styles.module.css new file mode 100644 index 0000000000..f53b53fd4c --- /dev/null +++ b/apps/web/src/components/common/Breadcrumbs/styles.module.css @@ -0,0 +1,19 @@ +.container { + height: 36px; + display: flex; + align-items: center; + background-color: var(--color-background-paper); + border-bottom: 1px solid var(--color-border-light); + padding: var(--space-1) var(--space-3); + gap: var(--space-1); +} + +.container:empty { + display: none; +} + +.breadcrumb { + display: flex; + align-items: center; + gap: calc(var(--space-1) / 2); +} diff --git a/apps/web/src/components/common/BuyCryptoButton/index.tsx b/apps/web/src/components/common/BuyCryptoButton/index.tsx index 2694e78a14..e06fdba307 100644 --- a/apps/web/src/components/common/BuyCryptoButton/index.tsx +++ b/apps/web/src/components/common/BuyCryptoButton/index.tsx @@ -1,7 +1,6 @@ -import { useTheme } from '@mui/material/styles' import { usePathname, useSearchParams } from 'next/navigation' import Link, { type LinkProps } from 'next/link' -import { Alert, Box, Button, ButtonBase, Typography, useMediaQuery } from '@mui/material' +import { Alert, Box, Button, ButtonBase, Typography } from '@mui/material' import AddIcon from '@mui/icons-material/Add' import { SafeAppsTag } from '@/config/constants' import { AppRoutes } from '@/config/routes' @@ -15,7 +14,7 @@ import css from './styles.module.css' import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded' const useOnrampAppUrl = (): string | undefined => { - const [onrampApps] = useRemoteSafeApps(SafeAppsTag.ONRAMP) + const [onrampApps] = useRemoteSafeApps({ tag: SafeAppsTag.ONRAMP }) return onrampApps?.[0]?.url } @@ -31,7 +30,7 @@ const useBuyCryptoHref = (): LinkProps['href'] | undefined => { } const buttonStyles = { - minHeight: '37.5px', + height: '42px', } const BuyCryptoOption = ({ name, children }: { name: string; children: ReactNode }) => { @@ -69,9 +68,6 @@ const _BuyCryptoOptions = ({ rampLink }: { rampLink?: LinkProps['href'] }) => { } const InternalBuyCryptoButton = ({ href, pagePath }: { href?: LinkProps['href']; pagePath: string }) => { - const theme = useTheme() - const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')) - if (!href) return null return ( @@ -80,11 +76,13 @@ const InternalBuyCryptoButton = ({ href, pagePath }: { href?: LinkProps['href']; <Link href={href} passHref> <Button variant="contained" - size={isSmallScreen ? 'medium' : 'small'} + size="compact" + color="background" sx={buttonStyles} - startIcon={<AddIcon />} + startIcon={<AddIcon fontSize="small" />} className={css.buyCryptoButton} fullWidth + disableElevation > Buy crypto </Button> diff --git a/apps/web/src/components/common/ChainIndicator/index.tsx b/apps/web/src/components/common/ChainIndicator/index.tsx index 57c9fa0a94..a39ad3cc2f 100644 --- a/apps/web/src/components/common/ChainIndicator/index.tsx +++ b/apps/web/src/components/common/ChainIndicator/index.tsx @@ -5,9 +5,10 @@ import { useAppSelector } from '@/store' import { selectChainById, selectChains } from '@/store/chainsSlice' import css from './styles.module.css' import useChainId from '@/hooks/useChainId' -import { Skeleton, Stack, Typography } from '@mui/material' +import { Skeleton, Stack, SvgIcon, Typography } from '@mui/material' import isEmpty from 'lodash/isEmpty' import FiatValue from '../FiatValue' +import UnknownChainIcon from '@/public/images/common/unknown.svg' type ChainIndicatorProps = { chainId?: string @@ -18,10 +19,11 @@ type ChainIndicatorProps = { onlyLogo?: boolean responsive?: boolean fiatValue?: string + imageSize?: number } const fallbackChainConfig = { - chainName: 'Unknown chain', + chainName: 'Unknown network', chainId: '-1', theme: { backgroundColor: '#ddd', @@ -39,6 +41,7 @@ const ChainIndicator = ({ showLogo = true, responsive = false, onlyLogo = false, + imageSize = 24, }: ChainIndicatorProps): ReactElement | null => { const currentChainId = useChainId() const id = chainId || currentChainId @@ -57,6 +60,27 @@ const ChainIndicator = ({ } }, [chainConfig]) + const logoComponent = chainConfig?.chainLogoUri ? ( + <img + src={chainConfig.chainLogoUri ?? undefined} + alt={`${chainConfig.chainName} Logo`} + width={imageSize} + height={imageSize} + loading="lazy" + /> + ) : ( + <SvgIcon + component={UnknownChainIcon} + inheritViewBox + sx={{ + height: imageSize, + width: imageSize, + backgroundColor: (theme) => theme.palette.background.main, + borderRadius: '100%', + }} + /> + ) + return noChains ? ( <Skeleton width="100%" height="22px" variant="rectangular" sx={{ flexShrink: 0 }} /> ) : chainConfig ? ( @@ -71,15 +95,7 @@ const ChainIndicator = ({ [css.onlyLogo]: onlyLogo, })} > - {showLogo && ( - <img - src={chainConfig.chainLogoUri ?? undefined} - alt={`${chainConfig.chainName} Logo`} - width={24} - height={24} - loading="lazy" - /> - )} + {showLogo && logoComponent} {!onlyLogo && ( <Stack> <span className={css.name}>{chainConfig.chainName}</span> diff --git a/apps/web/src/components/common/CheckWalletWithPermission/index.test.tsx b/apps/web/src/components/common/CheckWalletWithPermission/index.test.tsx index 9add1a7a5d..6341848194 100644 --- a/apps/web/src/components/common/CheckWalletWithPermission/index.test.tsx +++ b/apps/web/src/components/common/CheckWalletWithPermission/index.test.tsx @@ -9,7 +9,7 @@ import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import useSafeInfo from '@/hooks/useSafeInfo' import type Safe from '@safe-global/protocol-kit' import * as useHasPermission from '@/permissions/hooks/useHasPermission' -import { Permission } from '@/permissions/types' +import { Permission } from '@/permissions/config' const mockWalletAddress = faker.finance.ethereumAddress() // mock useWallet diff --git a/apps/web/src/components/common/CheckWalletWithPermission/index.tsx b/apps/web/src/components/common/CheckWalletWithPermission/index.tsx index 9ac63ad895..595a6d5c90 100644 --- a/apps/web/src/components/common/CheckWalletWithPermission/index.tsx +++ b/apps/web/src/components/common/CheckWalletWithPermission/index.tsx @@ -5,7 +5,7 @@ import useConnectWallet from '../ConnectWallet/useConnectWallet' import useIsWrongChain from '@/hooks/useIsWrongChain' import { Tooltip } from '@mui/material' import useSafeInfo from '@/hooks/useSafeInfo' -import type { Permission, PermissionProps } from '@/permissions/types' +import type { Permission, PermissionProps } from '@/permissions/config' import { useHasPermission } from '@/permissions/hooks/useHasPermission' type CheckWalletWithPermissionProps< diff --git a/apps/web/src/components/common/CookieAndTermBanner/index.tsx b/apps/web/src/components/common/CookieAndTermBanner/index.tsx index e4a5d40efd..847e70aba2 100644 --- a/apps/web/src/components/common/CookieAndTermBanner/index.tsx +++ b/apps/web/src/components/common/CookieAndTermBanner/index.tsx @@ -75,7 +75,7 @@ export const CookieAndTermBanner = ({ } return ( - <Paper className={classnames(css.container, { [css.inverted]: inverted })}> + <Paper data-testid="cookies-popup" className={classnames(css.container, { [css.inverted]: inverted })}> {warning && ( <Typography align="center" @@ -164,17 +164,17 @@ export const CookieAndTermBanner = ({ gap: 2, }} > - <Grid item> + {/* <Grid item> <Typography> <Button onClick={handleAccept} variant="text" size="small" color="inherit" disableElevation> Save settings </Button> </Typography> - </Grid> + </Grid> */} <Grid item> <Button onClick={handleAcceptAll} variant="contained" color="secondary" size="small" disableElevation> - Accept all + Save settings </Button> </Grid> </Grid> diff --git a/apps/web/src/components/common/CopyAddressButton/__tests__/index.test.tsx b/apps/web/src/components/common/CopyAddressButton/__tests__/index.test.tsx index 1ce7149c6b..397ab8d55f 100644 --- a/apps/web/src/components/common/CopyAddressButton/__tests__/index.test.tsx +++ b/apps/web/src/components/common/CopyAddressButton/__tests__/index.test.tsx @@ -1,5 +1,5 @@ import { act, render, waitFor } from '@/tests/test-utils' -import { checksumAddress } from '@/utils/addresses' +import { checksumAddress } from '@safe-global/utils/utils/addresses' import { faker } from '@faker-js/faker' import CopyAddressButton from '..' diff --git a/apps/web/src/components/common/CopyAddressButton/index.tsx b/apps/web/src/components/common/CopyAddressButton/index.tsx index 83a378fa73..cc8124e57e 100644 --- a/apps/web/src/components/common/CopyAddressButton/index.tsx +++ b/apps/web/src/components/common/CopyAddressButton/index.tsx @@ -1,4 +1,4 @@ -import { checksumAddress } from '@/utils/addresses' +import { checksumAddress } from '@safe-global/utils/utils/addresses' import { Box, Typography } from '@mui/material' import type { ReactNode, ReactElement } from 'react' import CopyButton from '../CopyButton' diff --git a/apps/web/src/components/common/DateTime/DateTime.tsx b/apps/web/src/components/common/DateTime/DateTime.tsx index b29017a6a0..5a0fde8d87 100644 --- a/apps/web/src/components/common/DateTime/DateTime.tsx +++ b/apps/web/src/components/common/DateTime/DateTime.tsx @@ -1,6 +1,6 @@ import type { ReactElement } from 'react' import { Tooltip } from '@mui/material' -import { formatDateTime, formatTime, formatTimeInWords } from '@/utils/date' +import { formatDateTime, formatTime, formatTimeInWords } from '@safe-global/utils/utils/date' type DateTimeProps = { value: number diff --git a/apps/web/src/components/common/DateTime/index.test.tsx b/apps/web/src/components/common/DateTime/index.test.tsx index d0e07ad038..779e25d236 100644 --- a/apps/web/src/components/common/DateTime/index.test.tsx +++ b/apps/web/src/components/common/DateTime/index.test.tsx @@ -1,7 +1,7 @@ import { render } from '@/tests/test-utils' import DateTime from '.' -import { formatDateTime, formatTime } from '@/utils/date' +import { formatDateTime, formatTime } from '@safe-global/utils/utils/date' import { useTxFilter } from '@/utils/tx-history-filter' jest.mock('@/utils/tx-history-filter', () => ({ diff --git a/apps/web/src/components/common/EnhancedTable/index.tsx b/apps/web/src/components/common/EnhancedTable/index.tsx index 1be2541188..684176fe28 100644 --- a/apps/web/src/components/common/EnhancedTable/index.tsx +++ b/apps/web/src/components/common/EnhancedTable/index.tsx @@ -19,7 +19,7 @@ import { Collapse } from '@mui/material' type EnhancedCell = { content: ReactNode - rawValue: string | number + rawValue: string | number | null sticky?: boolean } @@ -38,20 +38,29 @@ type EnhancedHeadCell = { sticky?: boolean } -function descendingComparator(a: EnhancedRow, b: EnhancedRow, orderBy: string) { - if (b.cells[orderBy].rawValue < a.cells[orderBy].rawValue) { +function descendingComparator(a: string | number, b: string | number) { + if (b < a) { return -1 } - if (b.cells[orderBy].rawValue > a.cells[orderBy].rawValue) { + if (b > a) { return 1 } return 0 } function getComparator(order: SortDirection, orderBy: string) { - return order === 'desc' - ? (a: any, b: any) => descendingComparator(a, b, orderBy) - : (a: any, b: any) => -descendingComparator(a, b, orderBy) + return (a: EnhancedRow, b: EnhancedRow) => { + const aValue = a.cells[orderBy].rawValue + const bValue = b.cells[orderBy].rawValue + + // Handle null/undefined values - always sort to end + if (aValue == null) return 1 + if (bValue == null) return -1 + if (aValue == null && bValue == null) return 0 + + // Use existing comparator for non-null values + return order === 'desc' ? descendingComparator(aValue, bValue) : -descendingComparator(aValue, bValue) + } } type EnhancedTableHeadProps = { @@ -88,7 +97,7 @@ function EnhancedTableHead(props: EnhancedTableHeadProps) { active={orderBy === headCell.id} direction={orderBy === headCell.id ? order : 'asc'} onClick={createSortHandler(headCell.id)} - sx={{ mr: [0, '-26px'] }} + sx={{ mr: [0, '-26px'], textWrap: 'nowrap' }} > {headCell.label} {orderBy === headCell.id ? ( @@ -178,8 +187,9 @@ function EnhancedTable({ rows, headCells, mobileVariant }: EnhancedTableProps) { </Table> </TableContainer> - {rows.length > pagedRows.length && ( + {(rows.length > pageSizes[0] || rowsPerPage !== pageSizes[1]) && ( <TablePagination + data-testid="table-pagination" rowsPerPageOptions={pageSizes} component="div" count={rows.length} diff --git a/apps/web/src/components/common/ErrorBoundary/index.tsx b/apps/web/src/components/common/ErrorBoundary/index.tsx index 8c9238db07..3d91c25ca2 100644 --- a/apps/web/src/components/common/ErrorBoundary/index.tsx +++ b/apps/web/src/components/common/ErrorBoundary/index.tsx @@ -1,12 +1,13 @@ import { Typography, Link } from '@mui/material' -import { HELP_CENTER_URL, IS_PRODUCTION } from '@/config/constants' +import { IS_PRODUCTION } from '@/config/constants' import { AppRoutes } from '@/config/routes' import WarningIcon from '@/public/images/notifications/warning.svg' import css from '@/components/common/ErrorBoundary/styles.module.css' import CircularIcon from '../icons/CircularIcon' import ExternalLink from '../ExternalLink' +import { HELP_CENTER_URL } from '@safe-global/utils/config/constants' interface ErrorBoundaryProps { error: Error componentStack: string diff --git a/apps/web/src/components/common/EthHashInfo/SrcEthHashInfo/index.tsx b/apps/web/src/components/common/EthHashInfo/SrcEthHashInfo/index.tsx index 39cb29ae58..99496d2d21 100644 --- a/apps/web/src/components/common/EthHashInfo/SrcEthHashInfo/index.tsx +++ b/apps/web/src/components/common/EthHashInfo/SrcEthHashInfo/index.tsx @@ -8,7 +8,7 @@ import useMediaQuery from '@mui/material/useMediaQuery' import Identicon from '../../Identicon' import CopyAddressButton from '../../CopyAddressButton' import ExplorerButton, { type ExplorerButtonProps } from '../../ExplorerButton' -import { shortenAddress } from '@/utils/formatters' +import { shortenAddress } from '@safe-global/utils/utils/formatters' import ImageFallback from '../../ImageFallback' import css from './styles.module.css' @@ -31,6 +31,7 @@ export type EthHashInfoProps = { trusted?: boolean ExplorerButtonProps?: ExplorerButtonProps isAddressBookName?: boolean + highlight4bytes?: boolean } const stopPropagation = (e: SyntheticEvent) => e.stopPropagation() @@ -53,6 +54,7 @@ const SrcEthHashInfo = ({ children, trusted = true, isAddressBookName = false, + highlight4bytes = false, }: EthHashInfoProps): ReactElement => { const shouldPrefix = isAddress(address) const theme = useTheme() @@ -60,10 +62,21 @@ const SrcEthHashInfo = ({ const identicon = <Identicon address={address} size={avatarSize} /> const shouldCopyPrefix = shouldPrefix && copyPrefix + const highlightedAddress = highlight4bytes ? ( + <> + {address.slice(0, 2)} + <b>{address.slice(2, 6)}</b> + {address.slice(6, -4)} + <b>{address.slice(-4)}</b> + </> + ) : ( + address + ) + const addressElement = ( <> {showPrefix && shouldPrefix && prefix && <b>{prefix}:</b>} - <span>{shortAddress || isMobile ? shortenAddress(address) : address}</span> + <span>{shortAddress || isMobile ? shortenAddress(address) : highlightedAddress}</span> </> ) @@ -84,7 +97,7 @@ const SrcEthHashInfo = ({ <Box overflow="hidden" className={onlyName ? css.inline : undefined} gap={0.5}> {name && ( - <Box title={name} display="flex" alignItems="center" gap={0.5}> + <Box title={name} className="ethHashInfo-name" display="flex" alignItems="center" gap={0.5}> <Box overflow="hidden" textOverflow="ellipsis"> {name} </Box> diff --git a/apps/web/src/components/common/EthHashInfo/index.tsx b/apps/web/src/components/common/EthHashInfo/index.tsx index a141d2d71b..85dceeee24 100644 --- a/apps/web/src/components/common/EthHashInfo/index.tsx +++ b/apps/web/src/components/common/EthHashInfo/index.tsx @@ -4,7 +4,7 @@ import useAllAddressBooks from '@/hooks/useAllAddressBooks' import useChainId from '@/hooks/useChainId' import { useAppSelector } from '@/store' import { selectSettings } from '@/store/settingsSlice' -import { getBlockExplorerLink } from '@/utils/chains' +import { getBlockExplorerLink } from '@safe-global/utils/utils/chains' import SrcEthHashInfo, { type EthHashInfoProps } from './SrcEthHashInfo' const EthHashInfo = ({ diff --git a/apps/web/src/components/common/ExplorerButton/index.tsx b/apps/web/src/components/common/ExplorerButton/index.tsx index f107320516..54abf3a068 100644 --- a/apps/web/src/components/common/ExplorerButton/index.tsx +++ b/apps/web/src/components/common/ExplorerButton/index.tsx @@ -1,5 +1,5 @@ import type { ReactElement, ComponentType, SyntheticEvent } from 'react' -import { Box, IconButton, SvgIcon, Tooltip, Typography } from '@mui/material' +import { Box, IconButton, SvgIcon, Tooltip, Typography, type TypographyProps } from '@mui/material' import LinkIcon from '@/public/images/common/link.svg' import Link from 'next/link' @@ -10,6 +10,7 @@ export type ExplorerButtonProps = { icon?: ComponentType onClick?: (e: SyntheticEvent) => void isCompact?: boolean + fontSize?: TypographyProps['fontSize'] } const ExplorerButton = ({ @@ -19,6 +20,7 @@ const ExplorerButton = ({ className, onClick, isCompact = true, + fontSize = 'small', }: ExplorerButtonProps): ReactElement | null => { if (!href) return null @@ -47,7 +49,7 @@ const ExplorerButton = ({ onClick={onClick} > <Box display="flex" alignItems="center"> - <Typography fontWeight={700} fontSize="small" mr="var(--space-1)" noWrap> + <Typography fontWeight={700} fontSize={fontSize} mr="var(--space-1)" noWrap> View on explorer </Typography> diff --git a/apps/web/src/components/common/ExternalLink/index.tsx b/apps/web/src/components/common/ExternalLink/index.tsx index c32b9f8004..85676dc234 100644 --- a/apps/web/src/components/common/ExternalLink/index.tsx +++ b/apps/web/src/components/common/ExternalLink/index.tsx @@ -21,11 +21,11 @@ const ExternalLink = ({ sx={{ display: 'inline-flex', alignItems: 'center', - gap: 0.2, + gap: 0.5, cursor: 'pointer', }} > - {children} + {children ?? href} {!noIcon && <OpenInNewRounded fontSize="small" />} </Box> ) diff --git a/apps/web/src/components/common/FiatValue/FiatValue.test.tsx b/apps/web/src/components/common/FiatValue/FiatValue.test.tsx index c4d6e8ce2f..663fe88c6f 100644 --- a/apps/web/src/components/common/FiatValue/FiatValue.test.tsx +++ b/apps/web/src/components/common/FiatValue/FiatValue.test.tsx @@ -34,4 +34,22 @@ describe('FiatValue', () => { expect(getByText((content) => normalizer(content) === '$ 100', { normalizer })).toBeInTheDocument() expect(getByText('.35')).toBeInTheDocument() }) + + it('should render fiat value with maxLength=3', () => { + const FiatValue = require('.').default + const { getByText } = render(<FiatValue value={100.35} maxLength={3} />) + expect(getByText((content) => normalizer(content) === '$ 100', { normalizer })).toBeInTheDocument() + }) + + it('should render fiat value with maxLength=3 and precise=true', () => { + const FiatValue = require('.').default + const { getByText } = render(<FiatValue value={100.35} maxLength={3} precise />) + expect(getByText((content) => normalizer(content) === '$ 100', { normalizer })).toBeInTheDocument() + }) + + it('should render `--` if passed value is null', () => { + const FiatValue = require('.').default + const { getByText } = render(<FiatValue value={null} />) + expect(getByText('--')).toBeInTheDocument() + }) }) diff --git a/apps/web/src/components/common/FiatValue/index.tsx b/apps/web/src/components/common/FiatValue/index.tsx index d934a7b284..86ef883290 100644 --- a/apps/web/src/components/common/FiatValue/index.tsx +++ b/apps/web/src/components/common/FiatValue/index.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react' import { Tooltip, Typography } from '@mui/material' import { useAppSelector } from '@/store' import { selectCurrency } from '@/store/settingsSlice' -import { formatCurrency, formatCurrencyPrecise } from '@/utils/formatNumber' +import { formatCurrency, formatCurrencyPrecise } from '@safe-global/utils/utils/formatNumber' const style = { whiteSpace: 'nowrap' } as CSSProperties @@ -12,25 +12,33 @@ const FiatValue = ({ maxLength, precise, }: { - value: string | number + value: string | number | null maxLength?: number precise?: boolean }): ReactElement => { const currency = useAppSelector(selectCurrency) const fiat = useMemo(() => { - return formatCurrency(value, currency, maxLength) + return value != null ? formatCurrency(value, currency, maxLength) : null }, [value, currency, maxLength]) const preciseFiat = useMemo(() => { - return formatCurrencyPrecise(value, currency) + return value != null ? formatCurrencyPrecise(value, currency) : null }, [value, currency]) const [whole, decimals, endCurrency] = useMemo(() => { - const match = preciseFiat.match(/(.+)(\D\d+)(\D+)?$/) + const match = (preciseFiat ?? '').match(/(.+)(\D\d+)(\D+)?$/) return match ? match.slice(1) : ['', preciseFiat, '', ''] }, [preciseFiat]) + if (fiat == null) { + return ( + <Typography component="span" color="text.secondary"> + -- + </Typography> + ) + } + return ( <Tooltip title={precise ? undefined : preciseFiat}> <span suppressHydrationWarning style={style}> diff --git a/apps/web/src/components/common/Footer/index.tsx b/apps/web/src/components/common/Footer/index.tsx index 4417d35908..e572f846a2 100644 --- a/apps/web/src/components/common/Footer/index.tsx +++ b/apps/web/src/components/common/Footer/index.tsx @@ -47,6 +47,9 @@ const Footer = (): ReactElement | null => { <footer className={css.container}> <ul> <> + <li> + <Typography variant="caption">©{new Date().getFullYear()} Boba Safe</Typography> + </li> <li> <FooterLink href="https://discord.com/invite/Hvu3zpFwWd">Discord</FooterLink> </li> diff --git a/apps/web/src/components/common/GeoblockingProvider/index.tsx b/apps/web/src/components/common/GeoblockingProvider/index.tsx index 47e6605082..a4421887f7 100644 --- a/apps/web/src/components/common/GeoblockingProvider/index.tsx +++ b/apps/web/src/components/common/GeoblockingProvider/index.tsx @@ -1,5 +1,5 @@ import { AppRoutes } from '@/config/routes' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { createContext, type ReactElement, type ReactNode } from 'react' export const GeoblockingContext = createContext<boolean | null>(null) diff --git a/apps/web/src/components/common/Header/index.tsx b/apps/web/src/components/common/Header/index.tsx index 1e5e0a7ce0..c8f9f751ef 100644 --- a/apps/web/src/components/common/Header/index.tsx +++ b/apps/web/src/components/common/Header/index.tsx @@ -19,11 +19,11 @@ import Link from 'next/link' import useSafeAddress from '@/hooks/useSafeAddress' import BatchIndicator from '@/components/batch/BatchIndicator' import WalletConnect from '@/features/walletconnect/components' -import { FEATURES } from '@/utils/chains' import { useHasFeature } from '@/hooks/useChains' import Track from '@/components/common/Track' import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' import { useSafeTokenEnabled } from '@/hooks/useSafeTokenEnabled' +import { FEATURES } from '@/utils/featureToggled' //import { useIsOfficialHost } from '@/hooks/useIsOfficialHost' type HeaderProps = { @@ -95,7 +95,7 @@ const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => { </div> )} - <div className={css.element}> + <div data-testid="notifications-center" className={css.element}> <NotificationCenter /> </div> diff --git a/apps/web/src/components/common/IframeIcon/index.tsx b/apps/web/src/components/common/IframeIcon/index.tsx new file mode 100644 index 0000000000..4276d0f865 --- /dev/null +++ b/apps/web/src/components/common/IframeIcon/index.tsx @@ -0,0 +1,43 @@ +import type { ReactElement } from 'react' + +const getIframeContent = (url: string, width: number, height: number): string => { + return ` + <body style="margin: 0; overflow: hidden; display: flex;"> + <img src="${encodeURI(url)}" alt="Safe App logo" width="${width}" height="${height}" /> + <script> + document.querySelector('img').onerror = (e) => { + e.target.onerror = null + e.target.src = "" + } + </script> + </body> + ` +} + +const IframeIcon = ({ + src, + alt, + width = 48, + height = 48, +}: { + src: string + alt: string + width?: number + height?: number +}): ReactElement => { + return ( + <iframe + title={alt} + srcDoc={getIframeContent(src, width, height)} + sandbox="allow-scripts" + referrerPolicy="strict-origin" + width={width} + height={height} + style={{ pointerEvents: 'none', border: 0 }} + tabIndex={-1} + loading="lazy" + /> + ) +} + +export default IframeIcon diff --git a/apps/web/src/components/common/MetaTags/index.tsx b/apps/web/src/components/common/MetaTags/index.tsx index 66bae4d169..806a172711 100644 --- a/apps/web/src/components/common/MetaTags/index.tsx +++ b/apps/web/src/components/common/MetaTags/index.tsx @@ -3,7 +3,7 @@ import { ContentSecurityPolicy, StrictTransportSecurity } from '@/config/securit import lightPalette from '@/components/theme/lightPalette' import darkPalette from '@/components/theme/darkPalette' -const descriptionText = 'Boba Safe is the most trusted smart account wallet on Ethereum with over $100B secured.' +const descriptionText = 'Boba Safe is the most trusted smart account wallet on chain.' const titleText = 'Boba Safe' const MetaTags = ({ prefetchUrl }: { prefetchUrl: string }) => ( diff --git a/apps/web/src/components/common/ModalDialog/index.tsx b/apps/web/src/components/common/ModalDialog/index.tsx index 6caeadea2e..469eb180ab 100644 --- a/apps/web/src/components/common/ModalDialog/index.tsx +++ b/apps/web/src/components/common/ModalDialog/index.tsx @@ -1,6 +1,12 @@ import { type ReactElement, type ReactNode } from 'react' import { IconButton, type ModalProps } from '@mui/material' -import { Dialog, DialogTitle, type DialogProps, useMediaQuery } from '@mui/material' +import { + Dialog, + DialogTitle, + type DialogProps, + type DialogTitleProps as MuiDialogTitleProps, + useMediaQuery, +} from '@mui/material' import { useTheme } from '@mui/material/styles' import ChainIndicator from '@/components/common/ChainIndicator' import CloseIcon from '@mui/icons-material/Close' @@ -18,6 +24,7 @@ interface DialogTitleProps { onClose?: ModalProps['onClose'] hideChainIndicator?: boolean chainId?: string + sx?: MuiDialogTitleProps['sx'] } export const ModalDialogTitle = ({ @@ -25,12 +32,13 @@ export const ModalDialogTitle = ({ onClose, hideChainIndicator = false, chainId, + sx = {}, ...other }: DialogTitleProps) => { return ( <DialogTitle data-testid="modal-title" - sx={{ m: 0, px: 3, pt: 3, pb: 2, display: 'flex', alignItems: 'center', fontWeight: 'bold' }} + sx={{ m: 0, px: 3, pt: 3, pb: 2, display: 'flex', alignItems: 'center', fontWeight: 'bold', ...sx }} {...other} > {children} diff --git a/apps/web/src/components/common/NameInput/index.tsx b/apps/web/src/components/common/NameInput/index.tsx index be31c2ba94..307d6f8242 100644 --- a/apps/web/src/components/common/NameInput/index.tsx +++ b/apps/web/src/components/common/NameInput/index.tsx @@ -26,7 +26,11 @@ const NameInput = ({ required={required} className={inputCss.input} onKeyDown={(e) => e.stopPropagation()} - {...register(name, { maxLength: 50, required })} + {...register(name, { + maxLength: 50, + required, + setValueAs: (value) => value.trim(), + })} /> ) } diff --git a/apps/web/src/components/common/NamedAddressInfo/index.test.tsx b/apps/web/src/components/common/NamedAddressInfo/index.test.tsx index 9581303651..468085e5e7 100644 --- a/apps/web/src/components/common/NamedAddressInfo/index.test.tsx +++ b/apps/web/src/components/common/NamedAddressInfo/index.test.tsx @@ -1,7 +1,10 @@ -import { render, waitFor } from '@/tests/test-utils' -import NamedAddressInfo from '.' +import { render, waitFor, renderHook } from '@/tests/test-utils' +import NamedAddressInfo, { useAddressName } from '.' import { faker } from '@faker-js/faker' import { getContract, type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { shortenAddress } from '@safe-global/utils/utils/formatters' +import { useWeb3ReadOnly } from '@/hooks/wallets/web3' +import useSafeAddress from '@/hooks/useSafeAddress' const mockChainInfo = { chainId: '4', @@ -19,8 +22,27 @@ jest.mock('@safe-global/safe-gateway-typescript-sdk', () => ({ __esModule: true, })) +jest.mock('@/hooks/wallets/web3', () => ({ + useWeb3ReadOnly: jest.fn(), +})) + +jest.mock('@/hooks/useSafeAddress', () => ({ + __esModule: true, + default: jest.fn(), +})) + +const mockWeb3ReadOnly = useWeb3ReadOnly as jest.Mock +const getContractMock = getContract as jest.Mock +const useSafeAddressMock = useSafeAddress as jest.Mock + +const safeAddress = faker.finance.ethereumAddress() + describe('NamedAddressInfo', () => { - const getContractMock = getContract as jest.Mock + beforeEach(() => { + jest.clearAllMocks() + useSafeAddressMock.mockReturnValue(safeAddress) + }) + it('should not fetch contract info if name / logo is given', async () => { const result = render( <NamedAddressInfo @@ -42,6 +64,13 @@ describe('NamedAddressInfo', () => { expect(getContractMock).not.toHaveBeenCalled() }) + it('should not fetch contract info if the address is not a valid address', async () => { + const address = faker.string.hexadecimal({ length: 64 }) + const result = render(<NamedAddressInfo address={address} />) + expect(result.getByText(shortenAddress(address))).toBeVisible() + expect(getContractMock).not.toHaveBeenCalled() + }) + it('should fetch contract info if name / logo is not given', async () => { const address = faker.finance.ethereumAddress() getContractMock.mockResolvedValue({ @@ -64,4 +93,161 @@ describe('NamedAddressInfo', () => { expect(getContractMock).toHaveBeenCalledWith('4', address) }) + + it('should show "This Safe Account" when address matches Safe address', async () => { + useSafeAddressMock.mockReturnValue(safeAddress) + + const result = render(<NamedAddressInfo address={safeAddress} />, { + initialReduxState: { + chains: { + loading: false, + data: [mockChainInfo], + }, + }, + }) + + expect(result.getByText('This Safe Account')).toBeVisible() + expect(getContractMock).not.toHaveBeenCalled() + }) + + it('should not show "This Safe Account" for different addresses', async () => { + const differentAddress = faker.finance.ethereumAddress() + useSafeAddressMock.mockReturnValue(safeAddress) + + const result = render(<NamedAddressInfo address={differentAddress} />, { + initialReduxState: { + chains: { + loading: false, + data: [mockChainInfo], + }, + }, + }) + + expect(result.queryByText('This Safe Account')).not.toBeInTheDocument() + }) +}) + +describe('useAddressName', () => { + const address = faker.finance.ethereumAddress() + + beforeEach(() => { + jest.clearAllMocks() + mockWeb3ReadOnly.mockReturnValue({ + getCode: jest.fn().mockResolvedValue('0x'), + }) + useSafeAddressMock.mockReturnValue(safeAddress) + }) + + it('should return name and logo from props if provided', async () => { + const { result } = renderHook(() => useAddressName(address, 'Custom Name', 'custom-avatar.png')) + + expect(result.current).toEqual({ + name: 'Custom Name', + logoUri: 'custom-avatar.png', + isUnverifiedContract: false, + }) + expect(getContractMock).not.toHaveBeenCalled() + }) + + it('should fetch and return contract info if no name provided', async () => { + getContractMock.mockResolvedValue({ + displayName: 'Contract Display Name', + name: 'ContractName', + logoUri: 'contract-logo.png', + }) + + const { result } = renderHook(() => useAddressName(address)) + + await waitFor(() => { + expect(result.current).toEqual({ + name: 'Contract Display Name', + logoUri: 'contract-logo.png', + isUnverifiedContract: false, + }) + }) + + expect(getContractMock).toHaveBeenCalledWith('4', address) + }) + + it('should handle unverified contracts', async () => { + getContractMock.mockRejectedValue(new Error('Contract not found')) + mockWeb3ReadOnly.mockReturnValue({ + getCode: jest.fn().mockResolvedValue('0x123'), // Non-empty bytecode indicates a contract + }) + + const { result } = renderHook(() => useAddressName(address)) + + await waitFor(() => { + expect(result.current).toEqual({ + name: 'Unverified contract', + logoUri: undefined, + isUnverifiedContract: true, + }) + }) + }) + + it('should handle EOA addresses (not contracts)', async () => { + getContractMock.mockRejectedValue(new Error('Contract not found')) + mockWeb3ReadOnly.mockReturnValue({ + getCode: jest.fn().mockResolvedValue('0x'), // Empty bytecode indicates EOA + }) + + const { result } = renderHook(() => useAddressName(address)) + + await waitFor(() => { + expect(result.current).toEqual({ + name: undefined, + logoUri: undefined, + isUnverifiedContract: false, + }) + }) + }) + + it('should handle undefined address', () => { + const { result } = renderHook(() => useAddressName(undefined)) + + expect(result.current).toEqual({ + name: undefined, + logoUri: undefined, + isUnverifiedContract: false, + }) + expect(getContractMock).not.toHaveBeenCalled() + }) + + it('should prioritize display name over contract name', async () => { + getContractMock.mockResolvedValue({ + displayName: 'Display Name', + name: 'Contract Name', + logoUri: 'logo.png', + }) + + const { result } = renderHook(() => useAddressName(address)) + + await waitFor(() => { + expect(result.current.name).toBe('Display Name') + }) + }) + + it('should fallback to contract name if display name is not available', async () => { + getContractMock.mockResolvedValue({ + name: 'Contract Name', + logoUri: 'logo.png', + }) + + const { result } = renderHook(() => useAddressName(address)) + + await waitFor(() => { + expect(result.current.name).toBe('Contract Name') + }) + }) + + it('should return "This Safe Account" when address matches Safe address', async () => { + const { result } = renderHook(() => useAddressName(safeAddress)) + + expect(result.current).toEqual({ + name: 'This Safe Account', + logoUri: undefined, + isUnverifiedContract: false, + }) + }) }) diff --git a/apps/web/src/components/common/NamedAddressInfo/index.tsx b/apps/web/src/components/common/NamedAddressInfo/index.tsx index 575f8fa78e..5074553d97 100644 --- a/apps/web/src/components/common/NamedAddressInfo/index.tsx +++ b/apps/web/src/components/common/NamedAddressInfo/index.tsx @@ -1,20 +1,57 @@ -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useChainId from '@/hooks/useChainId' import { getContract } from '@safe-global/safe-gateway-typescript-sdk' import EthHashInfo from '../EthHashInfo' import type { EthHashInfoProps } from '../EthHashInfo/SrcEthHashInfo' +import { useWeb3ReadOnly } from '@/hooks/wallets/web3' +import useSafeAddress from '@/hooks/useSafeAddress' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import { memo, useMemo } from 'react' +import { isAddress } from 'ethers' -const NamedAddressInfo = ({ address, name, customAvatar, ...props }: EthHashInfoProps) => { +const useIsUnverifiedContract = (address?: string, error?: Error): boolean => { + const web3 = useWeb3ReadOnly() + + const [isUnverifiedContract] = useAsync<boolean>(async () => { + if (!error || !address) return false // Only check via RPC if getContract returned an error + const code = await web3?.getCode(address) + return code !== '0x' + }, [address, web3, error]) + + return isUnverifiedContract ?? false +} + +export function useAddressName(address?: string, name?: string | null, customAvatar?: string) { const chainId = useChainId() - const [contract] = useAsync( - () => (!name && !customAvatar ? getContract(chainId, address) : undefined), - [address, chainId, name, customAvatar], + const safeAddress = useSafeAddress() + const displayName = sameAddress(address, safeAddress) ? 'This Safe Account' : name + + const [contract, error] = useAsync( + () => (!displayName && address && isAddress(address) ? getContract(chainId, address) : undefined), + [address, chainId, displayName], + false, ) - const finalName = name || contract?.displayName || contract?.name - const finalAvatar = customAvatar || contract?.logoUri + const isUnverifiedContract = useIsUnverifiedContract(address, error) + + return useMemo( + () => ({ + name: + displayName || + contract?.displayName || + contract?.name || + (isUnverifiedContract ? 'Unverified contract' : undefined), + logoUri: customAvatar || contract?.logoUri, + isUnverifiedContract, + }), + [displayName, contract, customAvatar, isUnverifiedContract], + ) +} + +const NamedAddressInfo = ({ address, name, customAvatar, ...props }: EthHashInfoProps) => { + const { name: finalName, logoUri: finalAvatar } = useAddressName(address, name, customAvatar) return <EthHashInfo address={address} name={finalName} customAvatar={finalAvatar} {...props} /> } -export default NamedAddressInfo +export default memo(NamedAddressInfo) diff --git a/apps/web/src/components/common/NestedSafeBreadcrumbs/index.tsx b/apps/web/src/components/common/NestedSafeBreadcrumbs/index.tsx new file mode 100644 index 0000000000..d562eac1cd --- /dev/null +++ b/apps/web/src/components/common/NestedSafeBreadcrumbs/index.tsx @@ -0,0 +1,37 @@ +import { useRouter } from 'next/router' +import { Typography } from '@mui/material' +import type { ReactElement } from 'react' + +import useSafeInfo from '@/hooks/useSafeInfo' +import { useParentSafe } from '@/hooks/useParentSafe' +import { BreadcrumbItem } from '@/components/common/Breadcrumbs/BreadcrumbItem' +import { formatPrefixedAddress } from '@safe-global/utils/utils/addresses' +import { useChain } from '@/hooks/useChains' + +export function NestedSafeBreadcrumbs(): ReactElement | null { + const { pathname, query } = useRouter() + const { safeAddress } = useSafeInfo() + const parentSafe = useParentSafe() + const currentChain = useChain(parentSafe?.chainId || '') + + if (!parentSafe) { + return null + } + + const prefixedAddress = formatPrefixedAddress(parentSafe.address.value, currentChain?.shortName) + + return ( + <> + <BreadcrumbItem + title="Parent Safe" + address={parentSafe.address.value} + href={{ + pathname, + query: { ...query, safe: prefixedAddress }, + }} + /> + <Typography variant="body2">/</Typography> + <BreadcrumbItem title="Nested Safe" address={safeAddress} /> + </> + ) +} diff --git a/apps/web/src/components/common/NetworkSelector/NetworkMultiSelector.tsx b/apps/web/src/components/common/NetworkSelector/NetworkMultiSelector.tsx index f9605ab4b1..2366e47cdb 100644 --- a/apps/web/src/components/common/NetworkSelector/NetworkMultiSelector.tsx +++ b/apps/web/src/components/common/NetworkSelector/NetworkMultiSelector.tsx @@ -10,9 +10,9 @@ import { useRouter } from 'next/router' import { getNetworkLink } from '.' import { SetNameStepFields } from '@/components/new-safe/create/steps/SetNameStep' import { getSafeSingletonDeployments, getSafeToL2SetupDeployments } from '@safe-global/safe-deployments' -import { getLatestSafeVersion } from '@/utils/chains' -import { hasCanonicalDeployment } from '@/services/contracts/deployments' +import { hasCanonicalDeployment } from '@safe-global/utils/services/contracts/deployments' import { hasMultiChainCreationFeatures } from '@/features/multichain/utils/utils' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' const NetworkMultiSelector = ({ name, diff --git a/apps/web/src/components/common/NetworkSelector/__tests__/ChainIndicatorWithFiatBalance.test.tsx b/apps/web/src/components/common/NetworkSelector/__tests__/ChainIndicatorWithFiatBalance.test.tsx new file mode 100644 index 0000000000..c77b22d93f --- /dev/null +++ b/apps/web/src/components/common/NetworkSelector/__tests__/ChainIndicatorWithFiatBalance.test.tsx @@ -0,0 +1,49 @@ +import { ChainIndicatorWithFiatBalance } from '../index' +import { render } from '@/tests/test-utils' +import { chainBuilder } from '@/tests/builders/chains' +import * as useChainId from '@/hooks/useChainId' +import * as gatewayApi from '@/store/api/gateway' +import * as useBalances from '@/hooks/useBalances' +import { skipToken } from '@reduxjs/toolkit/query' + +const SAFE_ADDRESS = '0x123' + +describe('ChainIndicatorWithFiatBalance', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('uses balances for the current chain', () => { + const chain = chainBuilder().with({ chainId: '1', chainName: 'Ethereum', shortName: 'eth' }).build() + jest.spyOn(useChainId, 'default').mockReturnValue('1') + jest.spyOn(useBalances, 'default').mockReturnValue({ balances: { fiatTotal: '100', items: [] }, loading: false }) + const getOverview = jest + .spyOn(gatewayApi, 'useGetSafeOverviewQuery') + .mockReturnValue({ data: { fiatTotal: '200' } } as any) + + const { getByText } = render( + <ChainIndicatorWithFiatBalance isSelected={false} chain={chain} safeAddress={SAFE_ADDRESS} />, + { initialReduxState: { chains: { data: [chain], loading: false } } }, + ) + + expect(getOverview).toHaveBeenCalledWith(skipToken) + expect(getByText(/100/)).toBeInTheDocument() + }) + + it('fetches overview for inactive chains', () => { + const chain = chainBuilder().with({ chainId: '10', chainName: 'Optimism', shortName: 'oeth' }).build() + jest.spyOn(useChainId, 'default').mockReturnValue('1') + jest.spyOn(useBalances, 'default').mockReturnValue({ balances: { fiatTotal: '100', items: [] }, loading: false }) + const getOverview = jest + .spyOn(gatewayApi, 'useGetSafeOverviewQuery') + .mockReturnValue({ data: { fiatTotal: '50' } } as any) + + const { getByText } = render( + <ChainIndicatorWithFiatBalance isSelected={false} chain={chain} safeAddress={SAFE_ADDRESS} />, + { initialReduxState: { chains: { data: [chain], loading: false } } }, + ) + + expect(getOverview).toHaveBeenCalledWith({ safeAddress: SAFE_ADDRESS, chainId: chain.chainId }) + expect(getByText(/50/)).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/common/NetworkSelector/__tests__/NetworkMultiSelector.test.tsx b/apps/web/src/components/common/NetworkSelector/__tests__/NetworkMultiSelector.test.tsx index 23119e62aa..84e71f9e1e 100644 --- a/apps/web/src/components/common/NetworkSelector/__tests__/NetworkMultiSelector.test.tsx +++ b/apps/web/src/components/common/NetworkSelector/__tests__/NetworkMultiSelector.test.tsx @@ -3,11 +3,11 @@ import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { FormProvider, useForm } from 'react-hook-form' import NetworkMultiSelector from '../NetworkMultiSelector' import { chainBuilder } from '@/tests/builders/chains' -import { FEATURES } from '@/utils/chains' import { render, waitFor } from '@/tests/test-utils' import { act } from 'react' import userEvent from '@testing-library/user-event' import * as router from 'next/router' +import { FEATURES } from '@safe-global/utils/utils/chains' const TestForm = ({ isAdvancedFlow = false }: { isAdvancedFlow?: boolean }) => { const formMethods = useForm<{ networks: ChainInfo[] }>({ diff --git a/apps/web/src/components/common/NetworkSelector/index.tsx b/apps/web/src/components/common/NetworkSelector/index.tsx index 4f494817b0..fe7926f4da 100644 --- a/apps/web/src/components/common/NetworkSelector/index.tsx +++ b/apps/web/src/components/common/NetworkSelector/index.tsx @@ -24,13 +24,11 @@ import useChains, { useCurrentChain } from '@/hooks/useChains' import type { NextRouter } from 'next/router' import { useRouter } from 'next/router' import css from './styles.module.css' -import { useChainId } from '@/hooks/useChainId' import { type ReactElement, useCallback, useMemo, useState } from 'react' import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics' - import { useAllSafesGrouped } from '@/features/myAccounts/hooks/useAllSafesGrouped' import useSafeAddress from '@/hooks/useSafeAddress' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import uniq from 'lodash/uniq' import { useCompatibleNetworks } from '@/features/multichain/hooks/useCompatibleNetworks' import { useSafeCreationData } from '@/features/multichain/hooks/useSafeCreationData' @@ -39,12 +37,14 @@ import PlusIcon from '@/public/images/common/plus.svg' import useAddressBook from '@/hooks/useAddressBook' import { CreateSafeOnSpecificChain } from '@/features/multichain/components/CreateSafeOnNewChain' import { useGetSafeOverviewQuery } from '@/store/api/gateway' +import useChainId from '@/hooks/useChainId' +import useBalances from '@/hooks/useBalances' +import { skipToken } from '@reduxjs/toolkit/query' import { InfoOutlined } from '@mui/icons-material' import { selectUndeployedSafe } from '@/store/slices' -import { skipToken } from '@reduxjs/toolkit/query' import { hasMultiChainAddNetworkFeature } from '@/features/multichain/utils/utils' -const ChainIndicatorWithFiatBalance = ({ +export const ChainIndicatorWithFiatBalance = ({ isSelected, chain, safeAddress, @@ -54,18 +54,16 @@ const ChainIndicatorWithFiatBalance = ({ safeAddress: string }) => { const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chain.chainId, safeAddress)) + const currentChainId = useChainId() + const isCurrentChain = currentChainId === chain.chainId + + const { balances } = useBalances() const { data: safeOverview } = useGetSafeOverviewQuery( - undeployedSafe ? skipToken : { safeAddress, chainId: chain.chainId }, + !isCurrentChain && !undeployedSafe ? { safeAddress, chainId: chain.chainId } : skipToken, ) + const fiatValue = isCurrentChain ? balances.fiatTotal : safeOverview?.fiatTotal - return ( - <ChainIndicator - responsive={isSelected} - chainId={chain.chainId} - fiatValue={safeOverview ? safeOverview.fiatTotal : undefined} - inline - /> - ) + return <ChainIndicator responsive={isSelected} chainId={chain.chainId} fiatValue={fiatValue} inline /> } export const getNetworkLink = (router: NextRouter, safeAddress: string, networkShortName: string) => { @@ -385,6 +383,7 @@ const NetworkSelector = ({ return ( <MenuItem + data-testid="network-selector-item" key={chainId} value={chainId} sx={{ '&:hover': { backgroundColor: isSelected ? 'transparent' : 'inherit' } }} diff --git a/apps/web/src/components/common/PageLayout/SideDrawer.tsx b/apps/web/src/components/common/PageLayout/SideDrawer.tsx index 77eeda7b74..b89589b7ee 100644 --- a/apps/web/src/components/common/PageLayout/SideDrawer.tsx +++ b/apps/web/src/components/common/PageLayout/SideDrawer.tsx @@ -1,3 +1,5 @@ +import SpaceSidebar from 'src/features/spaces/components/SpaceSidebar' +import { useIsSpaceRoute } from '@/hooks/useIsSpaceRoute' import { useRouter } from 'next/router' import { useEffect, type ReactElement } from 'react' import { IconButton, Drawer, useMediaQuery } from '@mui/material' @@ -20,6 +22,8 @@ const SideDrawer = ({ isOpen, onToggle }: SideDrawerProps): ReactElement => { const { breakpoints } = useTheme() const isSmallScreen = useMediaQuery(breakpoints.down('md')) const [, isSafeAppRoute] = useIsSidebarRoute() + const isSpaceRoute = useIsSpaceRoute() + const showSidebarToggle = isSafeAppRoute && !isSmallScreen // Keep the sidebar hidden on small screens via CSS until we collapse it via JS. // With a small delay to avoid flickering. @@ -41,6 +45,8 @@ const SideDrawer = ({ isOpen, onToggle }: SideDrawerProps): ReactElement => { } }, [onToggle, router, isSmallScreen]) + const SidebarComponent = isSpaceRoute ? SpaceSidebar : Sidebar + return ( <> <Drawer @@ -56,7 +62,7 @@ const SideDrawer = ({ isOpen, onToggle }: SideDrawerProps): ReactElement => { className={smDrawerHidden ? css.smDrawerHidden : undefined} > <aside> - <Sidebar /> + <SidebarComponent /> </aside> </Drawer> diff --git a/apps/web/src/components/common/PageLayout/index.tsx b/apps/web/src/components/common/PageLayout/index.tsx index 27300492d3..b89b5ecdde 100644 --- a/apps/web/src/components/common/PageLayout/index.tsx +++ b/apps/web/src/components/common/PageLayout/index.tsx @@ -1,6 +1,5 @@ import { useContext, useEffect, useState, type ReactElement } from 'react' import classnames from 'classnames' -import { Alert } from '@mui/material' import Header from '@/components/common/Header' import css from './styles.module.css' @@ -10,18 +9,7 @@ import SideDrawer from './SideDrawer' import { useIsSidebarRoute } from '@/hooks/useIsSidebarRoute' import { TxModalContext } from '@/components/tx-flow' import BatchSidebar from '@/components/batch/BatchSidebar' -import { TemporaryDialog } from '@/components/common/TemporaryDialog' -import ExternalLink from '../ExternalLink' - -const StickyBanner = () => ( - <Alert severity="warning"> - ALWAYS{' '} - <ExternalLink href="https://help.safe.global/en/articles/276343-how-to-perform-basic-transactions-checks-on-safe-wallet"> - verify transactions - </ExternalLink>{' '} - that you are approving on your signer wallet. If you can’t verify it, don’t sign it. - </Alert> -) +import Breadcrumbs from '@/components/common/Breadcrumbs' const PageLayout = ({ pathname, children }: { pathname: string; children: ReactElement }): ReactElement => { const [isSidebarRoute, isAnimated] = useIsSidebarRoute(pathname) @@ -39,7 +27,7 @@ const PageLayout = ({ pathname, children }: { pathname: string; children: ReactE <Header onMenuToggle={isSidebarRoute ? setSidebarOpen : undefined} onBatchToggle={setBatchOpen} /> </header> - {isSidebarRoute && <SideDrawer isOpen={isSidebarOpen} onToggle={setSidebarOpen} />} + {isSidebarRoute ? <SideDrawer isOpen={isSidebarOpen} onToggle={setSidebarOpen} /> : null} <div className={classnames(css.main, { @@ -48,18 +36,15 @@ const PageLayout = ({ pathname, children }: { pathname: string; children: ReactE })} > <div className={css.content}> - <div className={css.sticky}> - <StickyBanner /> - </div> - - <SafeLoadingError>{children}</SafeLoadingError> + <SafeLoadingError> + <Breadcrumbs /> + {children} + </SafeLoadingError> </div> <BatchSidebar isOpen={isBatchOpen} onToggle={setBatchOpen} /> <Footer /> - - <TemporaryDialog /> </div> </> ) diff --git a/apps/web/src/components/common/PageLayout/styles.module.css b/apps/web/src/components/common/PageLayout/styles.module.css index 50b53414f4..06e20ab762 100644 --- a/apps/web/src/components/common/PageLayout/styles.module.css +++ b/apps/web/src/components/common/PageLayout/styles.module.css @@ -17,12 +17,6 @@ flex-direction: column; } -.sticky { - position: sticky; - top: calc(var(--header-height) + env(safe-area-inset-top)); - z-index: 1200; -} - .mainAnimated { transition: padding 225ms cubic-bezier(0, 0, 0.2, 1) 0ms; } diff --git a/apps/web/src/components/common/PaperViewToggle/index.tsx b/apps/web/src/components/common/PaperViewToggle/index.tsx new file mode 100644 index 0000000000..05894ff346 --- /dev/null +++ b/apps/web/src/components/common/PaperViewToggle/index.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from 'react' +import React, { useCallback, useRef, useState } from 'react' +import { Paper, Stack } from '@mui/material' +import { ToggleButtonGroup } from '@/components/common/ToggleButtonGroup' + +type PaperViewToggleProps = { + children: { + title: ReactNode + content: ReactNode + }[] + activeView?: number + leftAlign?: boolean +} + +export const PaperViewToggle = ({ children, leftAlign, activeView = 0 }: PaperViewToggleProps) => { + const [active, setActive] = useState(activeView) + // Intentionally using undefined to prevent rendering a 0px height on initial render + const [minHeight, setMinHeight] = useState<number>() + const stackRef = useRef<HTMLDivElement>(null) + + const onChangeView = useCallback( + (index: number) => { + // Avoid height change when switching between views + setMinHeight((prev) => { + if (!prev && stackRef.current) { + return stackRef.current.offsetHeight + } + return prev + }) + + setActive(index) + }, + [stackRef], + ) + + const Content = ({ index }: { index: number }) => children?.[index]?.content || null + + return ( + <Paper + sx={{ + backgroundColor: 'background.main', + pt: 1, + pb: 1.5, + }} + > + <Stack spacing={2} height={minHeight ? `${minHeight}px` : undefined} ref={stackRef}> + <Stack direction={leftAlign ? 'row' : 'row-reverse'} justifyContent="space-between" px={2} py={1}> + <ToggleButtonGroup onChange={onChangeView}>{children}</ToggleButtonGroup> + </Stack> + + <Content index={active} /> + </Stack> + </Paper> + ) +} diff --git a/apps/web/src/components/common/ProgressBar/styles.module.css b/apps/web/src/components/common/ProgressBar/styles.module.css index bdc2c6a2eb..ae10c52814 100644 --- a/apps/web/src/components/common/ProgressBar/styles.module.css +++ b/apps/web/src/components/common/ProgressBar/styles.module.css @@ -9,6 +9,10 @@ border-radius: 6px; } +[data-theme='light'] .progressBar :global .MuiLinearProgress-bar { + background: var(--color-secondary-main); +} + @media (max-width: 599.95px) { .progressBar { border-radius: 0; diff --git a/apps/web/src/components/common/SafeIcon/index.tsx b/apps/web/src/components/common/SafeIcon/index.tsx index fc0cb63625..752041a583 100644 --- a/apps/web/src/components/common/SafeIcon/index.tsx +++ b/apps/web/src/components/common/SafeIcon/index.tsx @@ -23,7 +23,7 @@ interface SafeIconProps extends IdenticonProps { isMultiChainItem?: boolean } -const ChainIcon = ({ chainId }: { chainId: string }) => { +export const ChainIcon = ({ chainId }: { chainId: string }) => { const chainConfig = useChain(chainId) if (!chainConfig) { diff --git a/apps/web/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx b/apps/web/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx index b034f73b56..7c83928191 100644 --- a/apps/web/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx +++ b/apps/web/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx @@ -6,7 +6,6 @@ import { toBeHex } from 'ethers' import { AppRoutes } from '@/config/routes' import useSafeTokenAllocation, { useSafeVotingPower } from '@/hooks/useSafeTokenAllocation' import * as safePass from '@/store/api/safePass' -import type { CampaignLeaderboardEntry } from '@/store/api/safePass' jest.mock('@/hooks/useChainId') @@ -72,26 +71,4 @@ describe('SafeTokenWidget', () => { ) }) }) - - it('Should render the Safe{Pass} points', async () => { - ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[], , false]) - ;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigInt(420 * 10 ** 18), , false]) - const mockCampaignRank: CampaignLeaderboardEntry = { - boost: '2.0', - holder: fakeSafeAddress, - position: 421, - totalBoostedPoints: 138, - totalPoints: 69, - } - jest.spyOn(safePass, 'useGetOwnGlobalCampaignRankQuery').mockReturnValue({ - data: mockCampaignRank, - isLoading: false, - refetch: jest.fn(), - }) - const result = render(<SafeTokenWidget />) - await waitFor(() => { - expect(result.queryByText('420')).toBeInTheDocument() // Safe Voting power - expect(result.queryByText('138')).toBeInTheDocument() // Safe Pass points - }) - }) }) diff --git a/apps/web/src/components/common/SafeTokenWidget/index.tsx b/apps/web/src/components/common/SafeTokenWidget/index.tsx index 0fddc5e9d0..9427be0b08 100644 --- a/apps/web/src/components/common/SafeTokenWidget/index.tsx +++ b/apps/web/src/components/common/SafeTokenWidget/index.tsx @@ -6,20 +6,14 @@ import useIsSafeOwner from '@/hooks/useIsSafeOwner' import type { Vesting } from '@/hooks/useSafeTokenAllocation' import useSafeTokenAllocation, { useSafeVotingPower } from '@/hooks/useSafeTokenAllocation' import { OVERVIEW_EVENTS } from '@/services/analytics' -import { formatVisualAmount } from '@/utils/formatters' -import { Box, ButtonBase, Divider, Skeleton, SvgIcon, Tooltip, Typography } from '@mui/material' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' +import { Box, ButtonBase, Skeleton, Tooltip, Typography } from '@mui/material' import Link from 'next/link' import { useSearchParams } from 'next/navigation' import Track from '../Track' import SafeTokenIcon from '@/public/images/common/safe-token.svg' -import SafePassStar from '@/public/images/common/safe-pass-star.svg' import css from './styles.module.css' import { useSanctionedAddress } from '@/hooks/useSanctionedAddress' -import useSafeAddress from '@/hooks/useSafeAddress' -import { skipToken } from '@reduxjs/toolkit/query/react' -import { useDarkMode } from '@/hooks/useDarkMode' -import { useGetOwnGlobalCampaignRankQuery } from '@/store/api/safePass' -import { formatAmount } from '@/utils/formatNumber' const TOKEN_DECIMALS = 18 @@ -47,19 +41,13 @@ const GOVERNANCE_APP_URL = IS_PRODUCTION ? 'https://community.safe.global' : 'ht const SafeTokenWidget = () => { const chainId = useChainId() - const safeAddress = useSafeAddress() const query = useSearchParams() - const darkMode = useDarkMode() const isSafeOwner = useIsSafeOwner() const [allocationData, , allocationDataLoading] = useSafeTokenAllocation() const [allocation, , allocationLoading] = useSafeVotingPower(allocationData) const sanctionedAddress = useSanctionedAddress() - const { data: ownGlobalRank, isLoading: ownGlobalRankLoading } = useGetOwnGlobalCampaignRankQuery( - chainId !== '1' && chainId !== '11155111' ? skipToken : { chainId, safeAddress }, - { refetchOnFocus: false }, - ) const tokenAddress = getSafeTokenAddress(chainId) if (!tokenAddress || Boolean(sanctionedAddress)) { @@ -111,28 +99,6 @@ const SafeTokenWidget = () => { )} </UnreadBadge> </Typography> - - <Divider orientation="vertical" /> - <SvgIcon - component={SafePassStar} - width={24} - height={24} - inheritViewBox - color={darkMode ? 'primary' : undefined} - /> - <Typography - component="div" - variant="body2" - lineHeight="20px" - // Badge does not accept className so must be here - className={css.allocationBadge} - > - {ownGlobalRankLoading ? ( - <Skeleton width="16px" animation="wave" /> - ) : ( - formatAmount(Math.floor(ownGlobalRank?.totalBoostedPoints ?? 0), 0) - )} - </Typography> </ButtonBase> </Link> </Track> diff --git a/apps/web/src/components/common/SplitMenuButton/index.tsx b/apps/web/src/components/common/SplitMenuButton/index.tsx new file mode 100644 index 0000000000..35e6f93cc9 --- /dev/null +++ b/apps/web/src/components/common/SplitMenuButton/index.tsx @@ -0,0 +1,141 @@ +import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react' +import Button from '@mui/material/Button' +import ButtonGroup from '@mui/material/ButtonGroup' +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown' +import MenuItem from '@mui/material/MenuItem' +import MenuList from '@mui/material/MenuList' +import { Box, CircularProgress, ListItemText, Popover, Tooltip } from '@mui/material' +import CheckIcon from '@mui/icons-material/Check' + +type Option = { + id: string + label?: string +} + +export default function SplitMenuButton({ + options, + disabled = false, + tooltip, + onClick, + onChange, + selected, + disabledIndex, + loading = false, +}: { + options: Option[] + disabled?: boolean + tooltip?: string + onClick?: (option: Option, e: SyntheticEvent) => void + onChange?: (option: Option) => void + selected?: Option['id'] + disabledIndex?: number + loading?: boolean +}) { + const [open, setOpen] = useState(false) + const anchorRef = useRef<HTMLDivElement>(null) + const [selectedIndex, setSelectedIndex] = useState(0) + + useEffect(() => { + if (selected) { + const index = options.findIndex((option) => option.id === selected) + if (index !== -1) { + setSelectedIndex(index) + } + } + }, [selected, options]) + + const handleClick = (e: SyntheticEvent) => { + onClick?.(options[selectedIndex], e) + } + + const handleMenuItemClick = (e: React.MouseEvent<HTMLLIElement, MouseEvent>, index: number) => { + e.preventDefault() + + if (index !== selectedIndex) { + setSelectedIndex(index) + onChange?.(options[index]) + } + + setOpen(false) + } + + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen) + } + + const handleClose = (event: Event) => { + if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) { + return + } + + setOpen(false) + } + + const { label, id } = useMemo(() => options[selectedIndex] || {}, [options, selectedIndex]) + const maxCharLen = Math.max(...options.map(({ id, label }) => (label || id).length)) + 2 + + return ( + <> + <ButtonGroup variant="contained" ref={anchorRef} aria-label="Button group with a nested menu" fullWidth> + <Tooltip title={tooltip} placement="top"> + <Box flex={1}> + <Button + data-testid={`combo-submit-${id}`} + onClick={handleClick} + type="submit" + disabled={disabled} + sx={{ minWidth: `${maxCharLen}ch !important` }} + > + {loading ? <CircularProgress size={20} /> : label || id} + </Button> + </Box> + </Tooltip> + + {options.length > 1 && ( + <Button + size="small" + aria-expanded={open ? 'true' : undefined} + aria-label="select action" + aria-haspopup="menu" + onClick={handleToggle} + disabled={loading} + data-testid="combo-submit-dropdown" + sx={{ minWidth: '0 !important', maxWidth: 48, px: 1.5 }} + > + <ArrowDropDownIcon /> + </Button> + )} + </ButtonGroup> + + <Popover + open={open} + anchorEl={anchorRef.current} + onClose={handleClose} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + transformOrigin={{ horizontal: 'right', vertical: -2 }} + slotProps={{ + root: { slotProps: { backdrop: { sx: { backgroundColor: 'transparent' } } } }, + }} + data-testid="combo-submit-popover" + > + <MenuList autoFocusItem> + {options.map((option, index) => ( + <MenuItem + key={option.id} + selected={index === selectedIndex} + disabled={disabledIndex === index} + onClick={(event) => handleMenuItemClick(event, index)} + sx={{ gap: 2 }} + > + <ListItemText>{option.label || option.id}</ListItemText> + {index === selectedIndex ? <CheckIcon /> : <Box sx={{ width: 24 }} />} + </MenuItem> + ))} + </MenuList> + </Popover> + </> + ) +} diff --git a/apps/web/src/components/common/Table/DataRow.tsx b/apps/web/src/components/common/Table/DataRow.tsx index 67a8886d45..f1e68b2082 100644 --- a/apps/web/src/components/common/Table/DataRow.tsx +++ b/apps/web/src/components/common/Table/DataRow.tsx @@ -1,8 +1,9 @@ import type { ReactElement, ReactNode } from 'react' +import { Typography } from '@mui/material' import FieldsGrid from '@/components/tx/FieldsGrid' type DataRowProps = { - datatestid?: String + datatestid?: string title: ReactNode children?: ReactNode } @@ -11,8 +12,10 @@ export const DataRow = ({ datatestid, title, children }: DataRowProps): ReactEle if (children == undefined) return null return ( - <FieldsGrid data-testid={datatestid} title={title}> - {children} + <FieldsGrid testId={datatestid || ''} title={title}> + <Typography variant="body1" component="div"> + {children} + </Typography> </FieldsGrid> ) } diff --git a/apps/web/src/components/common/TemporaryDialog/index.tsx b/apps/web/src/components/common/TemporaryDialog/index.tsx deleted file mode 100644 index cfc135d5c7..0000000000 --- a/apps/web/src/components/common/TemporaryDialog/index.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { - Box, - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - Typography, -} from '@mui/material' -import { useState } from 'react' -import type { ReactElement } from 'react' -import ExternalLink from '../ExternalLink' - -export function TemporaryDialog(): ReactElement { - const [open, setOpen] = useState(true) - - const handleClose = () => { - setOpen(false) - } - - return ( - <Dialog open={open}> - <DialogTitle>Security notice</DialogTitle> - <DialogContent> - <DialogContentText> - <Typography color="text.primary"> - Due to recent security incidents it is important to ALWAYS verify transactions that you are approving on - your signer wallet. If you can’t verify it, don’t sign it. - </Typography> - - <Typography color="text.primary" mt={1}> - More information on how to verify a Safe transaction can be found in the{' '} - <ExternalLink href="https://help.safe.global/en/articles/276343-how-to-perform-basic-transactions-checks-on-safe-wallet"> - corresponding help center article - </ExternalLink> - . - </Typography> - </DialogContentText> - </DialogContent> - <DialogActions> - <Box m={1}> - <Button onClick={handleClose} variant="contained"> - I understand - </Button> - </Box> - </DialogActions> - </Dialog> - ) -} diff --git a/apps/web/src/components/common/ToggleButtonGroup/index.tsx b/apps/web/src/components/common/ToggleButtonGroup/index.tsx new file mode 100644 index 0000000000..3640501233 --- /dev/null +++ b/apps/web/src/components/common/ToggleButtonGroup/index.tsx @@ -0,0 +1,70 @@ +import type { ReactNode, ReactElement } from 'react' +import React from 'react' +import { + ToggleButtonGroup as MuiToggleButtonGroup, + ToggleButton, + toggleButtonGroupClasses, + styled, + svgIconClasses, + Box, +} from '@mui/material' + +// @ts-ignore +const StyledMuiToggleButtonGroup = styled(MuiToggleButtonGroup)(({ theme }) => ({ + '&': { + backgroundColor: theme.palette.background.paper, + }, + [`& .${toggleButtonGroupClasses.grouped}`]: { + margin: theme.spacing(0.5), + padding: theme.spacing(0.5), + border: 0, + borderRadius: theme.shape.borderRadius, + [`&.${toggleButtonGroupClasses.disabled}`]: { + border: 0, + }, + }, + [`& .${toggleButtonGroupClasses.middleButton},& .${toggleButtonGroupClasses.lastButton}`]: { + marginLeft: -1, + borderLeft: '1px solid transparent', + }, + [`& .${svgIconClasses.root}`]: { + width: 16, + height: 16, + }, +})) + +interface ToggleButtonGroupProps { + value?: number + children: { + title: ReactNode + content: ReactNode + }[] + onChange?: (newValue: number) => void +} + +export const ToggleButtonGroup = ({ value = 0, children, onChange }: ToggleButtonGroupProps): ReactElement | null => { + const [currentValue, setCurrentValue] = React.useState(value) + + const changeView = (_: React.MouseEvent, newValue: number) => { + if (newValue != null) { + setCurrentValue(newValue) + onChange?.(newValue) + } + } + + return ( + <StyledMuiToggleButtonGroup + size="small" + value={currentValue} + exclusive + onChange={changeView} + aria-label="text alignment" + > + {children.map(({ title }, index) => ( + <ToggleButton key={index} value={index}> + <Box px={1}>{title}</Box> + </ToggleButton> + ))} + </StyledMuiToggleButtonGroup> + ) +} diff --git a/apps/web/src/components/common/TokenAmount/index.tsx b/apps/web/src/components/common/TokenAmount/index.tsx index ed818ec7b9..1185a0d2c6 100644 --- a/apps/web/src/components/common/TokenAmount/index.tsx +++ b/apps/web/src/components/common/TokenAmount/index.tsx @@ -2,7 +2,7 @@ import { type ReactElement } from 'react' import { Tooltip } from '@mui/material' import { TransferDirection } from '@safe-global/safe-gateway-typescript-sdk' import css from './styles.module.css' -import { formatVisualAmount } from '@/utils/formatters' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' import TokenIcon from '../TokenIcon' import classNames from 'classnames' @@ -16,26 +16,39 @@ const TokenAmount = ({ direction, fallbackSrc, preciseAmount, + iconSize, + chainId, }: { value: string - decimals?: number + decimals?: number | null logoUri?: string tokenSymbol?: string direction?: TransferDirection fallbackSrc?: string preciseAmount?: boolean + iconSize?: number + chainId?: string }): ReactElement => { const sign = direction === TransferDirection.OUTGOING ? '-' : '' const amount = decimals !== undefined ? formatVisualAmount(value, decimals, preciseAmount ? PRECISION : undefined) : value + const fullAmount = decimals !== undefined ? sign + formatVisualAmount(value, decimals, PRECISION) + ' ' + tokenSymbol : value return ( <Tooltip title={fullAmount}> <span className={classNames(css.container, { [css.verticalAlign]: logoUri })}> - {logoUri && <TokenIcon logoUri={logoUri} tokenSymbol={tokenSymbol} fallbackSrc={fallbackSrc} />} - <b> + {logoUri && ( + <TokenIcon + logoUri={logoUri} + tokenSymbol={tokenSymbol} + fallbackSrc={fallbackSrc} + size={iconSize} + chainId={chainId} + /> + )} + <b className={css.tokenText}> {sign} {amount} {tokenSymbol} </b> diff --git a/apps/web/src/components/common/TokenAmount/styles.module.css b/apps/web/src/components/common/TokenAmount/styles.module.css index 4a1e21d44e..0d83ef9bf4 100644 --- a/apps/web/src/components/common/TokenAmount/styles.module.css +++ b/apps/web/src/components/common/TokenAmount/styles.module.css @@ -3,8 +3,15 @@ align-items: center; gap: var(--space-1); color: var(--color-text-primary); + max-width: 100%; } .verticalAlign { vertical-align: middle; } + +.tokenText { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/apps/web/src/components/common/TokenAmountInput/index.tsx b/apps/web/src/components/common/TokenAmountInput/index.tsx index 19c0ebec8e..27cc46e7a1 100644 --- a/apps/web/src/components/common/TokenAmountInput/index.tsx +++ b/apps/web/src/components/common/TokenAmountInput/index.tsx @@ -1,56 +1,106 @@ -import { safeFormatUnits } from '@/utils/formatters' -import { Button, Divider, FormControl, InputLabel, MenuItem, TextField } from '@mui/material' -import { type SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' -import css from './styles.module.css' import NumberField from '@/components/common/NumberField' -import { validateDecimalLength, validateLimitedAmount } from '@/utils/validation' import { AutocompleteItem } from '@/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer' -import { useFormContext } from 'react-hook-form' +import { safeFormatUnits, safeParseUnits } from '@safe-global/utils/utils/formatters' +import { validateDecimalLength, validateLimitedAmount } from '@safe-global/utils/utils/validation' +import { Button, Divider, FormControl, InputLabel, MenuItem, TextField } from '@mui/material' +import { type SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' import classNames from 'classnames' import { useCallback } from 'react' +import { get, useFormContext } from 'react-hook-form' +import type { FieldArrayPath, FieldValues } from 'react-hook-form' +import css from './styles.module.css' +import { MultiTokenTransferFields, type MultiTokenTransferParams } from '@/components/tx-flow/flows/TokenTransfer' +import { sameAddress } from '@safe-global/utils/utils/addresses' export enum TokenAmountFields { tokenAddress = 'tokenAddress', amount = 'amount', } +export const InsufficientFundsValidationError = 'Insufficient funds' + +const getFieldName = (field: TokenAmountFields, fieldArray?: TokenAmountInputProps['fieldArray']) => + fieldArray ? `${fieldArray.name}.${fieldArray.index}.${field}` : field + +type TokenAmountInputProps = { + balances: SafeBalanceResponse['items'] + selectedToken: SafeBalanceResponse['items'][number] | undefined + maxAmount?: bigint + validate?: (value: string) => string | undefined + fieldArray?: { name: FieldArrayPath<FieldValues>; index: number } + deps?: string[] +} + const TokenAmountInput = ({ balances, selectedToken, maxAmount, validate, -}: { - balances: SafeBalanceResponse['items'] - selectedToken: SafeBalanceResponse['items'][number] | undefined - maxAmount?: bigint - validate?: (value: string) => string | undefined -}) => { + fieldArray, + deps, +}: TokenAmountInputProps) => { const { - formState: { errors }, + formState: { errors, defaultValues }, register, resetField, watch, setValue, - } = useFormContext<{ [TokenAmountFields.tokenAddress]: string; [TokenAmountFields.amount]: string }>() + trigger, + } = useFormContext() + + const { getValues } = useFormContext<MultiTokenTransferParams>() - const tokenAddress = watch(TokenAmountFields.tokenAddress) - const isAmountError = !!errors[TokenAmountFields.tokenAddress] || !!errors[TokenAmountFields.amount] + const tokenAddressField = getFieldName(TokenAmountFields.tokenAddress, fieldArray) + const amountField = getFieldName(TokenAmountFields.amount, fieldArray) + + const tokenAddress = watch(tokenAddressField) + + const isAmountError = !!get(errors, tokenAddressField) || !!get(errors, amountField) const validateAmount = useCallback( (value: string) => { const decimals = selectedToken?.tokenInfo.decimals - return validateLimitedAmount(value, decimals, maxAmount?.toString()) || validateDecimalLength(value, decimals) + const maxAmountString = maxAmount?.toString() + + const valueValidationError = + validateLimitedAmount(value, decimals, maxAmountString) || validateDecimalLength(value, decimals) + + if (valueValidationError) { + return valueValidationError + } + + // Validate the total amount of the selected token in the multi transfer + const recipients = getValues(MultiTokenTransferFields.recipients) + const sumAmount = recipients.reduce<bigint>((acc, item) => { + const value = safeParseUnits(item.amount || '0', decimals) || 0n + return acc + (sameAddress(item.tokenAddress, tokenAddress) ? value : 0n) + }, 0n) + + return validateLimitedAmount(sumAmount.toString(), 0, maxAmountString, InsufficientFundsValidationError) }, - [maxAmount, selectedToken?.tokenInfo.decimals], + [maxAmount, selectedToken?.tokenInfo.decimals, getValues, tokenAddress], ) const onMaxAmountClick = useCallback(() => { if (!selectedToken || maxAmount === undefined) return - setValue(TokenAmountFields.amount, safeFormatUnits(maxAmount.toString(), selectedToken.tokenInfo.decimals), { + setValue(amountField, safeFormatUnits(maxAmount.toString(), selectedToken.tokenInfo.decimals), { shouldValidate: true, }) - }, [maxAmount, selectedToken, setValue]) + + trigger(deps) + }, [maxAmount, selectedToken, setValue, amountField, trigger, deps]) + + const onChangeToken = useCallback(() => { + const amountDefaultValue = get( + defaultValues, + getFieldName(TokenAmountFields.amount, fieldArray ? { ...fieldArray, index: 0 } : undefined), + ) + + resetField(amountField, amountDefaultValue) + + trigger(deps) + }, [resetField, amountField, trigger, deps, defaultValues, fieldArray]) return ( <FormControl @@ -59,7 +109,9 @@ const TokenAmountInput = ({ fullWidth > <InputLabel shrink required className={css.label}> - {errors[TokenAmountFields.tokenAddress]?.message || errors[TokenAmountFields.amount]?.message || 'Amount'} + {get(errors, tokenAddressField)?.message?.toString() || + get(errors, amountField)?.message?.toString() || + 'Amount'} </InputLabel> <div className={css.inputs}> <NumberField @@ -76,9 +128,10 @@ const TokenAmountInput = ({ className={css.amount} required placeholder="0" - {...register(TokenAmountFields.amount, { + {...register(amountField, { required: true, validate: validate ?? validateAmount, + deps, })} /> <Divider orientation="vertical" flexItem /> @@ -90,11 +143,9 @@ const TokenAmountInput = ({ disableUnderline: true, }} className={css.select} - {...register(TokenAmountFields.tokenAddress, { + {...register(tokenAddressField, { required: true, - onChange: () => { - resetField(TokenAmountFields.amount, { defaultValue: '' }) - }, + onChange: onChangeToken, })} value={tokenAddress} required diff --git a/apps/web/src/components/common/TokenExplorerLink/index.tsx b/apps/web/src/components/common/TokenExplorerLink/index.tsx index b22951d81c..51115bdccb 100644 --- a/apps/web/src/components/common/TokenExplorerLink/index.tsx +++ b/apps/web/src/components/common/TokenExplorerLink/index.tsx @@ -1,7 +1,7 @@ import { type ReactElement } from 'react' import ExplorerButton from '@/components/common/ExplorerButton' import { useCurrentChain } from '@/hooks/useChains' -import { getBlockExplorerLink } from '@/utils/chains' +import { getBlockExplorerLink } from '@safe-global/utils/utils/chains' import { Typography } from '@mui/material' const ExplorerLink = ({ address }: { address: string }): ReactElement | null => { diff --git a/apps/web/src/components/common/TokenIcon/index.tsx b/apps/web/src/components/common/TokenIcon/index.tsx index 232c08e245..a021703809 100644 --- a/apps/web/src/components/common/TokenIcon/index.tsx +++ b/apps/web/src/components/common/TokenIcon/index.tsx @@ -1,36 +1,46 @@ import { useMemo, type ReactElement } from 'react' import ImageFallback from '../ImageFallback' import css from './styles.module.css' +import { upgradeCoinGeckoThumbToQuality } from '@safe-global/utils/utils/image' +import { Box } from '@mui/material' +import ChainIndicator from '../ChainIndicator' const FALLBACK_ICON = '/images/common/token-placeholder.svg' -const COINGECKO_THUMB = '/thumb/' -const COINGECKO_SMALL = '/small/' const TokenIcon = ({ logoUri, tokenSymbol, size = 26, fallbackSrc, + chainId, }: { logoUri?: string tokenSymbol?: string size?: number fallbackSrc?: string + chainId?: string }): ReactElement => { const src = useMemo(() => { - return logoUri?.replace(COINGECKO_THUMB, COINGECKO_SMALL) + return upgradeCoinGeckoThumbToQuality(logoUri, 'small') }, [logoUri]) return ( - <ImageFallback - src={src} - alt={tokenSymbol} - fallbackSrc={fallbackSrc || FALLBACK_ICON} - height={size} - className={css.image} - referrerPolicy="no-referrer" - loading="lazy" - /> + <Box position="relative" marginRight={chainId ? '8px' : '0px'}> + <ImageFallback + src={src} + alt={tokenSymbol} + fallbackSrc={fallbackSrc || FALLBACK_ICON} + height={size} + className={css.image} + referrerPolicy="no-referrer" + loading="lazy" + /> + {chainId && ( + <div className={css.chainIcon}> + <ChainIndicator chainId={chainId} onlyLogo showLogo showUnknown imageSize={size * 0.666667} /> + </div> + )} + </Box> ) } diff --git a/apps/web/src/components/common/TokenIcon/styles.module.css b/apps/web/src/components/common/TokenIcon/styles.module.css index 25bd7d1499..f32ea1cec7 100644 --- a/apps/web/src/components/common/TokenIcon/styles.module.css +++ b/apps/web/src/components/common/TokenIcon/styles.module.css @@ -8,3 +8,15 @@ border-radius: 4px; padding: 2px; } + +.chainIcon { + position: absolute; + bottom: 0px; + right: -8px; + background-color: var(--color-background-paper); + border: 1px solid var(--color-border-background); + border-radius: 100%; + width: 20px; + height: 20px; + padding: 0; +} diff --git a/apps/web/src/components/common/WalletBalance/index.tsx b/apps/web/src/components/common/WalletBalance/index.tsx index 2f8bb01170..a2550574ff 100644 --- a/apps/web/src/components/common/WalletBalance/index.tsx +++ b/apps/web/src/components/common/WalletBalance/index.tsx @@ -1,4 +1,4 @@ -import { formatVisualAmount } from '@/utils/formatters' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' import { Skeleton } from '@mui/material' import { useCurrentChain } from '@/hooks/useChains' diff --git a/apps/web/src/components/common/WalletInfo/index.tsx b/apps/web/src/components/common/WalletInfo/index.tsx index 23c1af8cbd..1379d443c4 100644 --- a/apps/web/src/components/common/WalletInfo/index.tsx +++ b/apps/web/src/components/common/WalletInfo/index.tsx @@ -6,11 +6,14 @@ import EthHashInfo from '@/components/common/EthHashInfo' import ChainSwitcher from '@/components/common/ChainSwitcher' import useOnboard, { type ConnectedWallet, switchWallet } from '@/hooks/wallets/useOnboard' import useAddressBook from '@/hooks/useAddressBook' -import { useAppSelector } from '@/store' +import { useAppDispatch, useAppSelector } from '@/store' import { selectChainById } from '@/store/chainsSlice' import madProps from '@/utils/mad-props' import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew' import useChainId from '@/hooks/useChainId' +import { useAuthLogoutV1Mutation } from '@safe-global/store/gateway/AUTO_GENERATED/auth' +import { setUnauthenticated } from '@/store/authSlice' +import { logError, Errors } from '@/services/exceptions' type WalletInfoProps = { wallet: ConnectedWallet @@ -22,6 +25,8 @@ type WalletInfoProps = { } export const WalletInfo = ({ wallet, balance, currentChainId, onboard, addressBook, handleClose }: WalletInfoProps) => { + const [authLogout] = useAuthLogoutV1Mutation() + const dispatch = useAppDispatch() const chainInfo = useAppSelector((state) => selectChainById(state, wallet.chainId)) const prefix = chainInfo?.shortName @@ -32,10 +37,16 @@ export const WalletInfo = ({ wallet, balance, currentChainId, onboard, addressBo } } - const handleDisconnect = () => { + const handleDisconnect = async () => { onboard?.disconnectWallet({ label: wallet.label, }) + try { + await authLogout() + dispatch(setUnauthenticated()) + } catch (error) { + logError(Errors._108, error) + } handleClose() } diff --git a/apps/web/src/components/common/WalletProvider/index.tsx b/apps/web/src/components/common/WalletProvider/index.tsx index cf13559e5f..7fb41ee127 100644 --- a/apps/web/src/components/common/WalletProvider/index.tsx +++ b/apps/web/src/components/common/WalletProvider/index.tsx @@ -1,13 +1,13 @@ import { createContext, type ReactElement, type ReactNode, useEffect, useState, useMemo } from 'react' import useOnboard, { type ConnectedWallet, getConnectedWallet } from '@/hooks/wallets/useOnboard' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { getSafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { useCurrentChain } from '@/hooks/useChains' import { useRouter } from 'next/router' import { type Eip1193Provider } from 'ethers' import { getNestedWallet } from '@/utils/nested-safe-wallet' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' export type SignerWallet = { provider: Eip1193Provider | null diff --git a/apps/web/src/components/dashboard/Assets/index.tsx b/apps/web/src/components/dashboard/Assets/index.tsx index 12aca61614..de692c5104 100644 --- a/apps/web/src/components/dashboard/Assets/index.tsx +++ b/apps/web/src/components/dashboard/Assets/index.tsx @@ -1,86 +1,121 @@ import { useMemo } from 'react' -import { Box, Skeleton, Typography, Paper } from '@mui/material' -import type { SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' +import { Box, Skeleton, Typography, Paper, Card, Stack } from '@mui/material' import useBalances from '@/hooks/useBalances' -import FiatValue from '@/components/common/FiatValue' import TokenAmount from '@/components/common/TokenAmount' import SwapButton from '@/features/swap/components/SwapButton' import { AppRoutes } from '@/config/routes' -import { WidgetContainer, WidgetBody, ViewAllLink } from '../styled' -import css from '../PendingTxs/styles.module.css' +import { ViewAllLink } from '../styled' +import css from './styles.module.css' import { useRouter } from 'next/router' import { SWAP_LABELS } from '@/services/analytics/events/swaps' import { useVisibleAssets } from '@/components/balances/AssetsTable/useHideAssets' -import BuyCryptoButton from '@/components/common/BuyCryptoButton' import SendButton from '@/components/balances/AssetsTable/SendButton' import useIsSwapFeatureEnabled from '@/features/swap/hooks/useIsSwapFeatureEnabled' - -const MAX_ASSETS = 5 - -const AssetsDummy = () => ( - <Box className={css.container}> - <Skeleton variant="circular" width={26} height={26} /> - {Array.from({ length: 2 }).map((_, index) => ( - <Skeleton variant="text" sx={{ flex: 1 }} key={index} /> - ))} - <Skeleton variant="text" width={88} /> - </Box> -) +import { FiatBalance } from '@/components/balances/AssetsTable/FiatBalance' +import { type Balances } from '@safe-global/store/gateway/AUTO_GENERATED/balances' +import { FiatChange } from '@/components/balances/AssetsTable/FiatChange' +import { isEligibleEarnToken } from '@/features/earn/utils' +import EarnButton from '@/features/earn/components/EarnButton' +import { EARN_LABELS } from '@/services/analytics/events/earn' +import useIsEarnFeatureEnabled from '@/features/earn/hooks/useIsEarnFeatureEnabled' +import useChainId from '@/hooks/useChainId' +import TokenIcon from '@/components/common/TokenIcon' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import StakeButton from '@/features/stake/components/StakeButton' +import { STAKE_LABELS } from '@/services/analytics/events/stake' +import useIsStakingFeatureEnabled from '@/features/stake/hooks/useIsStakingFeatureEnabled' +import NoAssetsIcon from '@/public/images/common/no-assets.svg' + +const MAX_ASSETS = 4 const NoAssets = () => ( - <Paper elevation={0} sx={{ p: 5 }}> - <Typography variant="h3" fontWeight="bold" mb={1}> - Add funds to get started - </Typography> + <Paper elevation={0} sx={{ p: 5, textAlign: 'center' }}> + <NoAssetsIcon /> - <Typography> - Add funds directly from your bank account or copy your address to send tokens from a different account. + <Typography mb={0.5} mt={3}> + No assets yet </Typography> - <Box display="flex" mt={2}> - <BuyCryptoButton /> - </Box> + <Typography color="primary.light">Onramp crypto or deposit from another wallet to get started.</Typography> </Paper> ) -const AssetRow = ({ item, showSwap }: { item: SafeBalanceResponse['items'][number]; showSwap?: boolean }) => ( - <Box className={css.container} key={item.tokenInfo.address}> - <Box flex={1}> - <TokenAmount - value={item.balance} - decimals={item.tokenInfo.decimals} - tokenSymbol={item.tokenInfo.symbol} - logoUri={item.tokenInfo.logoUri} - /> - </Box> - - <Box flex={1} display={['none', 'block']} textAlign="right" pr={4}> - <FiatValue value={item.fiatBalance} /> - </Box> - - <Box my={-0.7}> - {showSwap ? ( - <SwapButton tokenInfo={item.tokenInfo} amount="0" trackingLabel={SWAP_LABELS.dashboard_assets} /> - ) : ( - <SendButton tokenInfo={item.tokenInfo} isOutlined /> - )} +const AssetRow = ({ + item, + chainId, + showSwap, + showEarn, + showStake, +}: { + item: Balances['items'][number] + chainId: string + showSwap?: boolean + showEarn?: boolean + showStake?: boolean +}) => { + return ( + <Box className={css.container} key={item.tokenInfo.address}> + <Stack direction="row" gap={1.5} alignItems="center"> + <TokenIcon tokenSymbol={item.tokenInfo.symbol} logoUri={item.tokenInfo.logoUri ?? undefined} size={32} /> + <Box> + <Typography fontWeight="600">{item.tokenInfo.name}</Typography> + <Typography variant="body2">{item.tokenInfo.symbol}</Typography> + </Box> + </Stack> + + <Stack display={['none', 'flex']} direction="row" alignItems="center" gap={1}> + <Typography className={css.tokenAmount}> + <TokenAmount value={item.balance} decimals={item.tokenInfo.decimals} tokenSymbol={item.tokenInfo.symbol} /> + </Typography> + </Stack> + + <Box flex={1} display="block" textAlign="right" height="44px"> + <FiatBalance balanceItem={item} /> + <FiatChange balanceItem={item} inline /> + </Box> + + <Box className={css.assetButtons}> + {showSwap ? ( + <SwapButton tokenInfo={item.tokenInfo} amount="0" trackingLabel={SWAP_LABELS.dashboard_assets} light /> + ) : ( + <SendButton tokenInfo={item.tokenInfo} light /> + )} + + {showEarn && isEligibleEarnToken(chainId, item.tokenInfo.address) && ( + <EarnButton tokenInfo={item.tokenInfo} trackingLabel={EARN_LABELS.dashboard_asset} compact={false} /> + )} + + {showStake && item.tokenInfo.type === TokenType.NATIVE_TOKEN && ( + <StakeButton tokenInfo={item.tokenInfo} trackingLabel={STAKE_LABELS.asset} compact={false} /> + )} + </Box> </Box> - </Box> -) + ) +} -const AssetList = ({ items }: { items: SafeBalanceResponse['items'] }) => { +const AssetList = ({ items }: { items: Balances['items'] }) => { const isSwapFeatureEnabled = useIsSwapFeatureEnabled() + const isEarnFeatureEnabled = useIsEarnFeatureEnabled() + const isStakingFeatureEnabled = useIsStakingFeatureEnabled() + const chainId = useChainId() return ( - <Box display="flex" flexDirection="column" gap={1}> + <Box display="flex" flexDirection="column"> {items.map((item) => ( - <AssetRow item={item} key={item.tokenInfo.address} showSwap={isSwapFeatureEnabled} /> + <AssetRow + item={item} + key={item.tokenInfo.address} + chainId={chainId} + showSwap={isSwapFeatureEnabled} + showEarn={isEarnFeatureEnabled} + showStake={isStakingFeatureEnabled} + /> ))} </Box> ) } -const isNonZeroBalance = (item: SafeBalanceResponse['items'][number]) => item.balance !== '0' +export const isNonZeroBalance = (item: Balances['items'][number]) => item.balance !== '0' const AssetsWidget = () => { const router = useRouter() @@ -100,20 +135,18 @@ const AssetsWidget = () => { [safe], ) + if (loading) return <Skeleton height={338} variant="rounded" /> + return ( - <WidgetContainer data-testid="assets-widget"> - <div className={css.title}> - <Typography component="h2" variant="subtitle1" fontWeight={700} mb={2}> - Top assets - </Typography> + <Card data-testid="assets-widget" sx={{ px: 1.5, py: 2.5 }}> + <Stack direction="row" justifyContent="space-between" sx={{ px: 1.5, mb: 1 }}> + <Typography fontWeight={700}>Top assets</Typography> - {items.length > 0 && <ViewAllLink url={viewAllUrl} text={`View all (${visibleAssets.length})`} />} - </div> + {items.length > 0 && <ViewAllLink url={viewAllUrl} text="View all" />} + </Stack> - <WidgetBody> - {loading ? <AssetsDummy /> : items.length > 0 ? <AssetList items={items} /> : <NoAssets />} - </WidgetBody> - </WidgetContainer> + <Box>{items.length > 0 ? <AssetList items={items} /> : <NoAssets />}</Box> + </Card> ) } diff --git a/apps/web/src/components/dashboard/Assets/styles.module.css b/apps/web/src/components/dashboard/Assets/styles.module.css new file mode 100644 index 0000000000..93b74b4e4d --- /dev/null +++ b/apps/web/src/components/dashboard/Assets/styles.module.css @@ -0,0 +1,65 @@ +.container { + width: 100%; + padding: 11px 16px; + background-color: var(--color-background-paper); + border-radius: 8px; + flex-wrap: nowrap; + display: grid; + grid-template-columns: 200px 1fr 1fr; + align-items: center; + gap: var(--space-2); + min-height: 50px; + position: relative; +} + +.container:hover { + background-color: var(--color-background-main); +} + +.bar { + height: 4px; + border-radius: 4px; + background-color: var(--color-border-light); + width: 100px; +} + +.tokenAmount * { + font-weight: normal; +} + +.barPercentage { + display: block; + border-radius: 4px; + height: 100%; + background: linear-gradient(225deg, #5fddff 12.5%, #12ff80 88.07%); +} + +.assetButtons { + position: absolute; + right: 16px; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s; + background-color: var(--color-background-main); + border-radius: var(--space-1); + height: 100%; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + width: calc(100% - 200px); +} + +.container:hover .assetButtons { + opacity: 1; + visibility: visible; +} + +@media (max-width: 600px) { + .container { + flex-direction: column; + align-items: start; + flex-wrap: wrap; + grid-template-columns: 1fr 1fr; + } +} diff --git a/apps/web/src/components/dashboard/FirstSteps/index.tsx b/apps/web/src/components/dashboard/FirstSteps/index.tsx index 67a3990d2b..c61a7be0eb 100644 --- a/apps/web/src/components/dashboard/FirstSteps/index.tsx +++ b/apps/web/src/components/dashboard/FirstSteps/index.tsx @@ -14,7 +14,6 @@ import { OVERVIEW_EVENTS } from '@/services/analytics' import { useAppDispatch, useAppSelector } from '@/store' import { selectSettings, setQrShortName } from '@/store/settingsSlice' import { selectOutgoingTransactions } from '@/store/txHistorySlice' -import { getExplorerLink } from '@/utils/gateway' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import classnames from 'classnames' import { type ReactNode, useState } from 'react' @@ -27,6 +26,7 @@ import LightbulbOutlinedIcon from '@mui/icons-material/LightbulbOutlined' import css from './styles.module.css' import ActivateAccountButton from '@/features/counterfactual/ActivateAccountButton' import { isReplayedSafeProps } from '@/features/counterfactual/utils' +import { getExplorerLink } from '@safe-global/utils/utils/gateway' const calculateProgress = (items: boolean[]) => { const totalNumberOfItems = items.length diff --git a/apps/web/src/components/dashboard/GovernanceSection/GovernanceSection.tsx b/apps/web/src/components/dashboard/GovernanceSection/GovernanceSection.tsx deleted file mode 100644 index 8730c4dce9..0000000000 --- a/apps/web/src/components/dashboard/GovernanceSection/GovernanceSection.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Typography, Card, Box, Link, SvgIcon } from '@mui/material' -import { WidgetBody } from '@/components/dashboard/styled' -import css from './styles.module.css' -import { useBrowserPermissions } from '@/hooks/safe-apps/permissions' -import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' -import { SafeAppsTag } from '@/config/constants' -import { useDarkMode } from '@/hooks/useDarkMode' -import { OpenInNew } from '@mui/icons-material' -import NetworkError from '@/public/images/common/network-error.svg' -import useChainId from '@/hooks/useChainId' -import { getSafeTokenAddress } from '@/components/common/SafeTokenWidget' -import SafeAppIframe from '@/components/safe-apps/AppFrame/SafeAppIframe' -import type { UseAppCommunicatorHandlers } from '@/components/safe-apps/AppFrame/useAppCommunicator' -import useAppCommunicator from '@/components/safe-apps/AppFrame/useAppCommunicator' -import { useCurrentChain } from '@/hooks/useChains' -import useGetSafeInfo from '@/components/safe-apps/AppFrame/useGetSafeInfo' -import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' -import useSafeInfo from '@/hooks/useSafeInfo' -import { fetchSafeAppFromManifest } from '@/services/safe-apps/manifest' -import useAsync from '@/hooks/useAsync' -import { getOrigin } from '@/components/safe-apps/utils' -import InfiniteScroll from '@/components/common/InfiniteScroll' - -// A fallback component when the Safe App fails to load -const WidgetLoadErrorFallback = () => ( - <Box - sx={{ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - height: '100%', - }} - > - <Card className={css.loadErrorCard}> - <Box className={css.loadErrorMsgContainer}> - <Typography - variant="h4" - sx={{ - color: 'text.primary', - fontWeight: 'bold', - }} - > - Couldn't load governance widgets - </Typography> - <SvgIcon component={NetworkError} inheritViewBox className={css.loadErroricon} /> - <Typography - variant="body1" - sx={{ - color: 'text.primary', - }} - > - You can try to reload the page and in case the problem persists, please report it through our{' '} - <Link - target="_blank" - href="https://safe-support.protofire.io/" - sx={{ - fontSize: 'medium', - }} - > - Form - <OpenInNew fontSize="small" color="primary" className={css.loadErroricon} /> - </Link> - </Typography> - </Box> - </Card> - </Box> -) - -// A mini Safe App frame with a minimal set of communication handlers -const MiniAppFrame = ({ app, title }: { app: SafeAppData; title: string }) => { - const chain = useCurrentChain() - const isDarkMode = useDarkMode() - const theme = isDarkMode ? 'dark' : 'light' - const { getAllowedFeaturesList } = useBrowserPermissions() - const iframeRef = useRef<HTMLIFrameElement>(null) - - const [, error] = useAsync(() => { - if (!chain?.chainId) return - return fetchSafeAppFromManifest(app.url, chain.chainId) - }, [app.url, chain?.chainId]) - - // Initialize the app communicator - useAppCommunicator(iframeRef, app, chain, { - onGetSafeInfo: useGetSafeInfo(), - } as Partial<UseAppCommunicatorHandlers> as UseAppCommunicatorHandlers) - - return error ? ( - <WidgetLoadErrorFallback /> - ) : ( - <SafeAppIframe - key={theme} - appUrl={`${app.url}/widgets?theme=${theme}`} - allowedFeaturesList={getAllowedFeaturesList(getOrigin(app.url))} - title={title} - iframeRef={iframeRef} - /> - ) -} - -// Entire section for the governance widgets -const GovernanceSection = () => { - const [matchingApps, errorFetchingGovernanceSafeApp] = useRemoteSafeApps(SafeAppsTag.SAFE_GOVERNANCE_APP) - const governanceApp = matchingApps?.[0] - const fetchingSafeGovernanceApp = !governanceApp && !errorFetchingGovernanceSafeApp - const { safeLoading } = useSafeInfo() - - return ( - <> - {governanceApp || fetchingSafeGovernanceApp ? ( - <WidgetBody> - <Card className={css.widgetWrapper}> - {governanceApp && !safeLoading ? ( - <MiniAppFrame app={governanceApp} title="Safe Governance" /> - ) : ( - <Box - className={css.widgetWrapper} - sx={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - textAlign: 'center', - }} - > - <Typography - variant="h1" - sx={{ - color: 'text.secondary', - }} - > - Loading section... - </Typography> - </Box> - )} - </Card> - </WidgetBody> - ) : ( - <WidgetLoadErrorFallback /> - )} - </> - ) -} - -const LazyGovernanceSection = () => { - const [isVisible, setIsVisible] = useState(false) - const [hasScrolled, setHasScrolled] = useState(false) - - const onVisible = useCallback(() => { - setIsVisible(true) - }, []) - - useEffect(() => { - const handleScroll = () => { - if (window.scrollY > 0) { - setHasScrolled(true) - window.removeEventListener('scroll', handleScroll) - } - } - window.addEventListener('scroll', handleScroll) - return () => window.removeEventListener('scroll', handleScroll) - }, []) - - return ( - <> - {hasScrolled && <InfiniteScroll onLoadMore={onVisible} />} - <Typography - component="h2" - variant="subtitle1" - sx={{ - fontWeight: 700, - }} - > - Governance - </Typography> - <Typography - variant="body2" - sx={{ - mb: 2, - color: 'text.secondary', - }} - > - Use your SAFE tokens to vote on important proposals or participate in forum discussions. - </Typography> - <div className={css.lazyWrapper}>{isVisible && <GovernanceSection />}</div> - </> - ) -} - -// Prevent `GovernanceSection` hooks from needlessly being called -const GovernanceSectionWrapper = () => { - const chainId = useChainId() - if (!getSafeTokenAddress(chainId)) { - return null - } - return <LazyGovernanceSection /> -} - -export default GovernanceSectionWrapper diff --git a/apps/web/src/components/dashboard/GovernanceSection/styles.module.css b/apps/web/src/components/dashboard/GovernanceSection/styles.module.css deleted file mode 100644 index 71592fad43..0000000000 --- a/apps/web/src/components/dashboard/GovernanceSection/styles.module.css +++ /dev/null @@ -1,46 +0,0 @@ -.lazyWrapper { - height: 300px; - overflow: hidden; -} - -.widgetWrapper { - height: 300px; - border: none; -} - -/* iframe sm breakpoint + paddings */ -@media (max-width: 662px) { - .widgetWrapper { - height: 624px; - } -} - -.loadErrorCard { - display: flex; - justify-content: center; - align-items: center; - padding: var(--space-2); - text-align: center; - flex-grow: 1; -} - -.loadErrorCard:last-of-type { - min-width: 300px; -} - -.loadErrorMsgContainer { - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--space-2); - max-width: 80%; -} - -.loadErroricon { - font-size: 54px; - position: relative; - left: 3px; - top: 3px; -} diff --git a/apps/web/src/components/dashboard/NewsCarousel/NewsDisclaimers.tsx b/apps/web/src/components/dashboard/NewsCarousel/NewsDisclaimers.tsx new file mode 100644 index 0000000000..5802627108 --- /dev/null +++ b/apps/web/src/components/dashboard/NewsCarousel/NewsDisclaimers.tsx @@ -0,0 +1,39 @@ +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import { NEWS_BANNER_STORAGE_KEY } from '@/components/dashboard/NewsCarousel/utils' +import { useMemo } from 'react' +import { Typography } from '@mui/material' +import { earnBannerDisclaimer, earnBannerID } from '@/components/dashboard/NewsCarousel/banners/EarnBanner' +import { useVisibleBalances } from '@/hooks/useVisibleBalances' + +const disclaimers = [ + { + id: earnBannerID, + element: earnBannerDisclaimer, + }, +] + +const NewsDisclaimers = () => { + const [dismissed = []] = useLocalStorage<string[]>(NEWS_BANNER_STORAGE_KEY) + const { balances, loading: balancesLoading } = useVisibleBalances() + const nonZeroBalances = useMemo(() => { + return balances.items.filter((item) => item.balance !== '0') + }, [balances.items]) + + const noAssets = !balancesLoading && nonZeroBalances.length === 0 + + const items = useMemo(() => disclaimers.filter((b) => !dismissed.includes(b.id)), [dismissed]) + + if (noAssets) return null + + return ( + <> + {items.map((item) => ( + <Typography component="p" key={item.id} variant="caption" color="text.secondary" mb={1}> + {item.element} + </Typography> + ))} + </> + ) +} + +export default NewsDisclaimers diff --git a/apps/web/src/components/dashboard/NewsCarousel/__tests__/utils.test.ts b/apps/web/src/components/dashboard/NewsCarousel/__tests__/utils.test.ts new file mode 100644 index 0000000000..a057b00445 --- /dev/null +++ b/apps/web/src/components/dashboard/NewsCarousel/__tests__/utils.test.ts @@ -0,0 +1,29 @@ +import { getSlidePosition } from '@/components/dashboard/NewsCarousel/utils' + +describe('getSlidePosition', () => { + const itemWidth = 100 // slide is 100px wide + + it('keeps the same slide when drag right is below the threshold', () => { + expect(getSlidePosition(0, 9, itemWidth)).toBe(0) + }) + + it('moves to the next slide when drag right exceeds the threshold', () => { + expect(getSlidePosition(0, 40, itemWidth)).toBe(100) + }) + + it('keeps the same slide when drag left is below the threshold', () => { + expect(getSlidePosition(100, 91, itemWidth)).toBe(100) + }) + + it('moves to the previous slide when drag left exceeds the threshold', () => { + expect(getSlidePosition(100, 60, itemWidth)).toBe(0) + }) + + it('respects a custom ratio', () => { + expect(getSlidePosition(0, 26, itemWidth, 0.25)).toBe(100) + }) + + it('falls back to the start value when swipe width cannot be measured', () => { + expect(getSlidePosition(0, 40, undefined)).toBe(0) + }) +}) diff --git a/apps/web/src/components/dashboard/NewsCarousel/banners/EarnBanner.tsx b/apps/web/src/components/dashboard/NewsCarousel/banners/EarnBanner.tsx new file mode 100644 index 0000000000..f287ab86f3 --- /dev/null +++ b/apps/web/src/components/dashboard/NewsCarousel/banners/EarnBanner.tsx @@ -0,0 +1,59 @@ +import { Box, Button, Card, IconButton, Stack, Typography } from '@mui/material' +import EarnIllustrationLight from '@/public/images/common/earn-illustration-light.png' +import Image from 'next/image' +import css from './styles.module.css' +import CloseIcon from '@mui/icons-material/Close' +import Track from '@/components/common/Track' +import { EARN_EVENTS, EARN_LABELS } from '@/services/analytics/events/earn' +import Link from 'next/link' +import { AppRoutes } from '@/config/routes' +import ChevronRightIcon from '@mui/icons-material/ChevronRight' +import { useRouter } from 'next/router' + +export const earnBannerID = 'earnBanner' + +export const earnBannerDisclaimer = + '* based on historic averages of USD stablecoin and ETH Morpho vaults. Yields are variable and subject to change. Past performance is not a guarantee of future returns. The Kiln DeFi, Morpho Borrow and Vault products and features described herein are not offered or controlled by Core Contributors GmbH, Safe Ecosystem Foundation, and/or its affiliates.' + +const EarnBanner = ({ onDismiss }: { onDismiss: () => void }) => { + const router = useRouter() + + return ( + <Card className={css.banner}> + <Stack direction={{ xs: 'column', md: 'row' }} alignItems={{ xs: 'flex-start', md: 'center' }} spacing={2}> + <Image className={css.bannerImage} src={EarnIllustrationLight} alt="Earn illustration" width={95} height={95} /> + <Box> + <Typography variant="h4" fontWeight="bold" color="static.main" className={css.bannerText}> + Try enterprise-grade yields with up to 8.10% APY* + </Typography> + + <Typography variant="body2" color="static.light" className={css.bannerText}> + Deposit stablecoins, wstETH, ETH, and WBTC and let your assets compound in minutes. + </Typography> + + <Track {...EARN_EVENTS.OPEN_EARN_PAGE} label={EARN_LABELS.safe_dashboard_banner}> + <Link href={AppRoutes.earn && { pathname: AppRoutes.earn, query: { safe: router.query.safe } }} passHref> + <Button + endIcon={<ChevronRightIcon fontSize="small" />} + variant="text" + size="compact" + sx={{ mt: 1, p: 0.5 }} + color="static" + > + Try now + </Button> + </Link> + </Track> + </Box> + </Stack> + + <Track {...EARN_EVENTS.HIDE_EARN_BANNER}> + <IconButton className={css.closeButton} aria-label="close" onClick={onDismiss}> + <CloseIcon fontSize="small" color="border" /> + </IconButton> + </Track> + </Card> + ) +} + +export default EarnBanner diff --git a/apps/web/src/components/dashboard/NewsCarousel/banners/SpacesBanner.tsx b/apps/web/src/components/dashboard/NewsCarousel/banners/SpacesBanner.tsx new file mode 100644 index 0000000000..a7f6661626 --- /dev/null +++ b/apps/web/src/components/dashboard/NewsCarousel/banners/SpacesBanner.tsx @@ -0,0 +1,53 @@ +import { Box, Button, Card, IconButton, Stack, Typography } from '@mui/material' +import SpacesIllustration from '@/public/images/common/spaces-illustration.png' +import Image from 'next/image' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import CloseIcon from '@mui/icons-material/Close' +import Track from '@/components/common/Track' +import css from './styles.module.css' +import ChevronRightIcon from '@mui/icons-material/ChevronRight' +import { AppRoutes } from '@/config/routes' +import Link from 'next/link' + +export const spacesBannerID = 'spacesBanner' + +const SpacesBanner = ({ onDismiss }: { onDismiss: () => void }) => { + return ( + <Card className={css.banner}> + <Stack direction={{ xs: 'column', md: 'row' }} alignItems={{ xs: 'flex-start', md: 'center' }} spacing={2}> + <Image className={css.bannerImage} src={SpacesIllustration} alt="Spaces illustration" width={95} height={95} /> + <Box> + <Typography variant="h4" fontWeight="bold" color="static.main" className={css.bannerText}> + New! Improved Spaces. + </Typography> + + <Typography variant="body2" color="static.light" className={css.bannerText}> + All your Safe Accounts, finally organized. Streamlined for teams and solo users alike + </Typography> + + <Track {...SPACE_EVENTS.OPEN_SPACE_LIST_PAGE} label={SPACE_LABELS.safe_dashboard_banner}> + <Link href={AppRoutes.welcome.spaces} passHref> + <Button + endIcon={<ChevronRightIcon fontSize="small" />} + variant="text" + size="compact" + sx={{ mt: 1, p: 0.5 }} + color="static" + > + Try now + </Button> + </Link> + </Track> + </Box> + </Stack> + + <Track {...SPACE_EVENTS.HIDE_DASHBOARD_WIDGET}> + <IconButton className={css.closeButton} aria-label="close" onClick={onDismiss}> + <CloseIcon fontSize="small" color="border" /> + </IconButton> + </Track> + </Card> + ) +} + +export default SpacesBanner diff --git a/apps/web/src/components/dashboard/NewsCarousel/banners/StakeBanner.tsx b/apps/web/src/components/dashboard/NewsCarousel/banners/StakeBanner.tsx new file mode 100644 index 0000000000..686248b28c --- /dev/null +++ b/apps/web/src/components/dashboard/NewsCarousel/banners/StakeBanner.tsx @@ -0,0 +1,57 @@ +import css from '@/components/dashboard/NewsCarousel/banners/styles.module.css' +import { Box, Button, Card, IconButton, Stack, Typography } from '@mui/material' +import Image from 'next/image' +import EarnIllustrationLight from '@/public/images/common/earn-illustration-light.png' +import Track from '@/components/common/Track' +import Link from 'next/link' +import { AppRoutes } from '@/config/routes' +import ChevronRightIcon from '@mui/icons-material/ChevronRight' +import CloseIcon from '@mui/icons-material/Close' +import { OVERVIEW_EVENTS } from '@/services/analytics' +import { useRouter } from 'next/router' + +export const stakeBannerID = 'stakeBanner' + +const StakeBanner = ({ onDismiss }: { onDismiss: () => void }) => { + const router = useRouter() + + return ( + <Card className={css.banner}> + <Stack direction={{ xs: 'column', md: 'row' }} alignItems={{ xs: 'flex-start', md: 'center' }} spacing={2}> + <Image className={css.bannerImage} src={EarnIllustrationLight} alt="Earn illustration" width={95} height={95} /> + <Box> + <Typography variant="h4" fontWeight="bold" color="static.main" className={css.bannerText}> + Stake your ETH and earn rewards + </Typography> + + <Typography variant="body2" color="static.light" className={css.bannerText}> + Lock 32 ETH and become a validator easily with the Kiln widget. You can also explore Safe Apps or home + staking for other options. Staking involves risks like slashing. + </Typography> + + <Track {...OVERVIEW_EVENTS.OPEN_STAKING_WIDGET}> + <Link href={AppRoutes.stake && { pathname: AppRoutes.stake, query: { safe: router.query.safe } }} passHref> + <Button + endIcon={<ChevronRightIcon fontSize="small" />} + variant="text" + size="compact" + sx={{ mt: 1, p: 0.5 }} + color="static" + > + Stake ETH + </Button> + </Link> + </Track> + </Box> + </Stack> + + <Track {...OVERVIEW_EVENTS.HIDE_STAKING_BANNER}> + <IconButton className={css.closeButton} aria-label="close" onClick={onDismiss}> + <CloseIcon fontSize="small" color="border" /> + </IconButton> + </Track> + </Card> + ) +} + +export default StakeBanner diff --git a/apps/web/src/components/dashboard/NewsCarousel/banners/styles.module.css b/apps/web/src/components/dashboard/NewsCarousel/banners/styles.module.css new file mode 100644 index 0000000000..2523baa05d --- /dev/null +++ b/apps/web/src/components/dashboard/NewsCarousel/banners/styles.module.css @@ -0,0 +1,27 @@ +.banner { + position: relative; + padding: var(--space-2) var(--space-8) var(--space-2) var(--space-2); + background: linear-gradient(90deg, #b0ffc9, #d7f6ff); + border: 0; + border-radius: 12px; + height: 100%; +} + +.closeButton { + color: var(--color-static-main); + position: absolute; + top: var(--space-2); + right: var(--space-2); +} + +.bannerImage, +.bannerText { + pointer-events: none; + user-select: none; +} + +@media (max-width: 599.95px) { + .banner { + padding: var(--space-2); + } +} diff --git a/apps/web/src/components/dashboard/NewsCarousel/index.tsx b/apps/web/src/components/dashboard/NewsCarousel/index.tsx new file mode 100644 index 0000000000..bfc53de82d --- /dev/null +++ b/apps/web/src/components/dashboard/NewsCarousel/index.tsx @@ -0,0 +1,181 @@ +import React, { createElement, type MouseEvent, useEffect, useMemo, useRef, useState } from 'react' +import classnames from 'classnames' +import { Box, IconButton, Stack } from '@mui/material' +import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeftRounded' +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRightRounded' +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import css from './styles.module.css' +import { getSlidePosition, NEWS_BANNER_STORAGE_KEY } from '@/components/dashboard/NewsCarousel/utils' + +export interface NewsBannerProps { + onDismiss: (e: MouseEvent<HTMLButtonElement>) => void +} + +export interface BannerItem { + id: string + element: React.ComponentType<NewsBannerProps> +} + +export interface NewsCarouselProps { + banners: BannerItem[] +} + +const isInteractive = (element: HTMLElement | null) => + !!element?.closest('button, a, input, textarea, select, #carousel-overlay') + +const NewsCarousel = ({ banners }: NewsCarouselProps) => { + const [dismissed = [], setDismissed] = useLocalStorage<string[]>(NEWS_BANNER_STORAGE_KEY) + + const [isDragging, setIsDragging] = useState(false) + const [prevScrollLeft, setPrevScrollLeft] = useState(0) + const [prevClientX, setPrevClientX] = useState(0) + const [activeIndex, setActiveIndex] = useState(0) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(false) + const sliderRef = useRef<HTMLDivElement>(null) + + const handleDragStart = (e: React.PointerEvent<HTMLDivElement>) => { + if (!sliderRef.current) return + if (isInteractive(e.target as HTMLElement)) return + + setIsDragging(true) + setPrevScrollLeft(sliderRef.current.scrollLeft) + setPrevClientX(e.clientX) + + sliderRef.current.setPointerCapture(e.pointerId) + } + + const handleDragEnd = (e: React.PointerEvent<HTMLDivElement>) => { + if (!sliderRef.current) return + if (!isDragging) return + + const { scrollLeft } = sliderRef.current + const itemWidth = getItemWidth() + const adjustedScrollLeft = getSlidePosition(prevScrollLeft, scrollLeft, itemWidth) ?? scrollLeft + + setIsDragging(false) + setPrevClientX(e.pageX) + setPrevScrollLeft(adjustedScrollLeft) + + sliderRef.current.scrollTo({ + left: adjustedScrollLeft, + behavior: 'smooth', + }) + + // This helps with dragging slides on mobile via touch + if (sliderRef.current.hasPointerCapture(e.pointerId)) { + sliderRef.current.releasePointerCapture(e.pointerId) + } + + if (itemWidth) { + setActiveIndex(Math.round(adjustedScrollLeft / itemWidth)) + } + } + + const handleDrag = (e: React.PointerEvent<HTMLDivElement>) => { + e.preventDefault() + + if (!isDragging) return + if (!sliderRef.current) return + + const change = e.clientX - prevClientX + const newScrollLeft = prevScrollLeft - change + + sliderRef.current.scrollLeft = newScrollLeft + } + + const goToSlide = (index: number) => { + const width = getItemWidth() + if (!sliderRef.current || !width) return + + const position = width * index + sliderRef.current.scrollTo({ left: position, behavior: 'smooth' }) + setActiveIndex(index) + } + + const scrollSlides = (direction: 'left' | 'right') => { + const newIndex = direction === 'left' ? Math.max(0, activeIndex - 1) : Math.min(items.length - 1, activeIndex + 1) + goToSlide(newIndex) + } + + const getItemWidth = () => { + if (!sliderRef.current) return + return sliderRef.current.clientWidth * (ITEM_WIDTH_PERCENT / 100) + } + + const items = useMemo(() => banners.filter((b) => !dismissed.includes(b.id)), [banners, dismissed]) + const ITEM_WIDTH_PERCENT = items.length === 1 ? 100 : 80 + + const dismissItem = (id: string) => { + setDismissed((prev = []) => Array.from(new Set([...prev, id]))) + } + + useEffect(() => { + setCanScrollLeft(activeIndex > 0) + setCanScrollRight(activeIndex < items.length - 1) + }, [activeIndex, items.length]) + + if (!items.length) return null + + return ( + <Stack spacing={1} alignItems="center" mt={3} position="relative"> + <div + className={classnames(css.slider, { [css.grabbing]: isDragging })} + ref={sliderRef} + onPointerDown={handleDragStart} + onPointerMove={handleDrag} + onPointerUp={handleDragEnd} + onPointerLeave={handleDragEnd} + onPointerCancel={handleDragEnd} + > + {items.map((item, index) => ( + <> + <Box width={`${ITEM_WIDTH_PERCENT}%`} flexShrink={0} key={item.id}> + {createElement(item.element, { + onDismiss: () => dismissItem(item.id), + })} + </Box> + + {activeIndex !== items.length - 1 && ( + <Box id="carousel-overlay" className={css.overlay} onClick={() => goToSlide(index)} /> + )} + </> + ))} + </div> + + {items.length > 1 && ( + <div className={css.dots}> + <IconButton + aria-label="previous banner" + onClick={() => scrollSlides('left')} + disabled={!canScrollLeft} + size="medium" + > + <KeyboardArrowLeftIcon fontSize="small" /> + </IconButton> + + {items.map((item, index) => ( + <button + key={item.id} + type="button" + className={classnames(css.dot, { [css.active]: index === activeIndex })} + aria-label={`Go to slide ${index + 1}`} + onClick={() => goToSlide(index)} + /> + ))} + + <IconButton + aria-label="next banner" + onClick={() => scrollSlides('right')} + disabled={!canScrollRight} + size="medium" + > + <KeyboardArrowRightIcon fontSize="small" /> + </IconButton> + </div> + )} + </Stack> + ) +} + +export default NewsCarousel diff --git a/apps/web/src/components/dashboard/NewsCarousel/styles.module.css b/apps/web/src/components/dashboard/NewsCarousel/styles.module.css new file mode 100644 index 0000000000..558c28c611 --- /dev/null +++ b/apps/web/src/components/dashboard/NewsCarousel/styles.module.css @@ -0,0 +1,45 @@ +.overlay { + position: absolute; + top: 0; + right: 0; + background: linear-gradient(270deg, var(--color-background-paper) 0%, rgba(255, 255, 255, 0) 100%); + height: 100%; + width: 15%; + margin: 0 !important; + z-index: 0; + cursor: pointer; +} + +.slider { + overflow: hidden; + width: 100%; + touch-action: pan-y; + display: flex; + gap: var(--space-2); + cursor: grab; +} + +.grabbing { + cursor: grabbing; +} + +.dots { + display: flex; + gap: var(--space-1); + margin-top: var(--space-2) !important; + align-items: center; +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--color-border-light); + border: none; + padding: 0; + cursor: pointer; +} + +.active { + background-color: var(--color-primary-main); +} diff --git a/apps/web/src/components/dashboard/NewsCarousel/utils.ts b/apps/web/src/components/dashboard/NewsCarousel/utils.ts new file mode 100644 index 0000000000..8375c1e5c4 --- /dev/null +++ b/apps/web/src/components/dashboard/NewsCarousel/utils.ts @@ -0,0 +1,21 @@ +export const NEWS_BANNER_STORAGE_KEY = 'dismissedNewsBanners' + +export const getSlidePosition = (start: number, end: number, width: number | undefined, threshold = 0.1) => { + if (!width) return start + + const delta = end - start + if (delta === 0) return start + + const direction = Math.sign(delta) // +1 next slide, –1 previous slide + const distance = Math.abs(delta) // pixels actually dragged + + // If we dragged far enough, jump one slide in the drag direction, + // otherwise snap back to where we started. + if (distance >= width * threshold) { + const targetIndex = Math.abs(Math.round((start + direction * width) / width)) + return targetIndex * width + } + + // Not enough distance: stay on the original slide + return Math.round(start / width) * width +} diff --git a/apps/web/src/components/dashboard/Overview/Overview.tsx b/apps/web/src/components/dashboard/Overview/Overview.tsx index 1a44d0306a..7d6c73256c 100644 --- a/apps/web/src/components/dashboard/Overview/Overview.tsx +++ b/apps/web/src/components/dashboard/Overview/Overview.tsx @@ -13,55 +13,98 @@ import ArrowIconNW from '@/public/images/common/arrow-top-right.svg' import ArrowIconSE from '@/public/images/common/arrow-se.svg' import FiatValue from '@/components/common/FiatValue' import { AppRoutes } from '@/config/routes' -import { Button, Grid, Skeleton, Typography, useMediaQuery } from '@mui/material' +import { Button, Card, Box, Skeleton, Typography, Stack, SvgIcon } from '@mui/material' import { useRouter } from 'next/router' -import { type ReactElement, useContext } from 'react' -import { WidgetBody, WidgetContainer } from '../styled' -import { useTheme } from '@mui/material/styles' +import { type ReactElement, useContext, useMemo } from 'react' import { SWAP_EVENTS, SWAP_LABELS } from '@/services/analytics/events/swaps' import useIsSwapFeatureEnabled from '@/features/swap/hooks/useIsSwapFeatureEnabled' +import NewsCarousel, { type BannerItem } from '@/components/dashboard/NewsCarousel' +import EarnBanner, { earnBannerID } from '@/components/dashboard/NewsCarousel/banners/EarnBanner' +import SpacesBanner, { spacesBannerID } from '@/components/dashboard/NewsCarousel/banners/SpacesBanner' +import useIsEarnFeatureEnabled from '@/features/earn/hooks/useIsEarnFeatureEnabled' +import { useCurrentChain, useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' +import FiatIcon from '@/public/images/common/fiat2.svg' +import CopyIcon from '@/public/images/common/copy.svg' +import CopyTooltip from '@/components/common/CopyTooltip' +import { useAppSelector } from '@/store' +import { selectSettings } from '@/store/settingsSlice' +import useSafeAddress from '@/hooks/useSafeAddress' +import StakeBanner, { stakeBannerID } from '@/components/dashboard/NewsCarousel/banners/StakeBanner' +import useIsStakingBannerVisible from '@/components/dashboard/StakingBanner/useIsStakingBannerVisible' -const SkeletonOverview = ( - <> - <Grid - container - sx={{ - pb: 2, - mt: 3, - gap: 2, - alignItems: 'flex-end', - justifyContent: 'space-between', - }} +const AddFundsToGetStarted = () => { + const { safe } = useSafeInfo() + const safeAddress = useSafeAddress() + const settings = useAppSelector(selectSettings) + const chain = useCurrentChain() + + const addressCopyText = settings.shortName.copy && chain ? `${chain.shortName}:${safeAddress}` : safeAddress + + if (!safe.deployed) return null + + return ( + <Stack + direction={{ xs: 'column', md: 'row' }} + sx={{ backgroundColor: 'info.light' }} + p={2} + gap={2} + alignItems={{ xs: 'flex-start', md: 'center' }} + borderRadius={1} + mt={3} > - <Grid item> - <Skeleton variant="text" width={100} height={30} /> - <Skeleton variant="rounded" width={160} height={40} /> - </Grid> - - <Grid item> - <Grid - container - sx={{ - gap: 1, - flexWrap: 'wrap', - }} - > - <Skeleton variant="rounded" width="115px" height="40px" /> - <Skeleton variant="rounded" width="115px" height="40px" /> - </Grid> - </Grid> - </Grid> - </> -) + <Box + width="40px" + height="40px" + bgcolor="background.paper" + display="flex" + alignItems="center" + justifyContent="center" + borderRadius="6px" + flexShrink="0" + > + <SvgIcon component={FiatIcon} inheritViewBox fontSize="small" /> + </Box> + <Box> + <Typography fontWeight="bold" color="static.main"> + Add funds to get started + </Typography> + <Typography variant="body2" color="primary.light"> + Onramp crypto or send tokens directly to your address from a different wallet.{' '} + </Typography> + </Box> + <Box ml={{ xs: 0, md: 'auto' }}> + <CopyTooltip text={addressCopyText}> + <Button + variant="contained" + color="background.paper" + startIcon={<SvgIcon component={CopyIcon} inheritViewBox fontSize="small" />} + size="small" + disableElevation + > + Copy address + </Button> + </CopyTooltip> + </Box> + </Stack> + ) +} const Overview = (): ReactElement => { const { safe, safeLoading, safeLoaded } = useSafeInfo() const { balances, loading: balancesLoading } = useVisibleBalances() const { setTxFlow } = useContext(TxModalContext) const router = useRouter() - const theme = useTheme() - const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')) const isSwapFeatureEnabled = useIsSwapFeatureEnabled() + const isEarnFeatureEnabled = useIsEarnFeatureEnabled() + const isSpacesFeatureEnabled = useHasFeature(FEATURES.SPACES) + const isStakingBannerVisible = useIsStakingBannerVisible() + + const banners = [ + isEarnFeatureEnabled && { id: earnBannerID, element: EarnBanner }, + isSpacesFeatureEnabled && { id: spacesBannerID, element: SpacesBanner }, + isStakingBannerVisible && { id: stakeBannerID, element: StakeBanner }, + ].filter(Boolean) as BannerItem[] const isInitialState = !safeLoaded && !safeLoading const isLoading = safeLoading || balancesLoading || isInitialState @@ -71,124 +114,113 @@ const Overview = (): ReactElement => { trackEvent(OVERVIEW_EVENTS.NEW_TRANSACTION) } - const buttonWidth = isSwapFeatureEnabled ? 4 : 6 + const items = useMemo(() => { + return balances.items.filter((item) => item.balance !== '0') + }, [balances.items]) + + const noAssets = !balancesLoading && items.length === 0 + + if (isLoading) return <Skeleton height={269} variant="rounded" /> return ( - <WidgetContainer> - <WidgetBody> - {isLoading ? ( - SkeletonOverview - ) : ( - <> - <Grid - container - sx={{ - pb: 2, - mt: 3, - gap: 2, - alignItems: 'flex-end', - justifyContent: 'space-between', - }} + <Card sx={{ border: 0, p: 3 }} component="section"> + <Box> + <Stack + direction={{ xs: 'column', md: 'row' }} + alignItems={{ xs: 'flex-start', md: 'center' }} + justifyContent="space-between" + > + <Box> + <Typography color="primary.light" fontWeight="bold" mb={1}> + Total asset value + </Typography> + <Typography component="div" variant="h1" fontSize="44px" lineHeight="40px"> + {safe.deployed ? ( + <FiatValue value={balances.fiatTotal} maxLength={20} precise /> + ) : ( + <TokenAmount + value={balances.items[0]?.balance} + decimals={balances.items[0]?.tokenInfo.decimals} + tokenSymbol={balances.items[0]?.tokenInfo.symbol} + /> + )} + </Typography> + </Box> + + {safe.deployed && ( + <Stack + direction="row" + alignItems={{ xs: 'flex-start', md: 'center' }} + flexWrap={{ xs: 'wrap', md: 'nowrap' }} + gap={1} + width={{ xs: 1, md: 'auto' }} + mt={{ xs: 2, md: 0 }} > - <Grid item> - <Typography - sx={{ - color: 'primary.light', - fontWeight: 'bold', - mb: 1, - }} - > - Total asset value - </Typography> - <Typography - component="div" - variant="h1" - sx={{ - fontSize: 44, - lineHeight: '40px', - }} - > - {safe.deployed ? ( - <FiatValue value={balances.fiatTotal} maxLength={20} precise /> - ) : ( - <TokenAmount - value={balances.items[0].balance} - decimals={balances.items[0].tokenInfo.decimals} - tokenSymbol={balances.items[0].tokenInfo.symbol} - /> - )} - </Typography> - </Grid> - - {safe.deployed && ( - <Grid - item - container - spacing={1} - xs={12} - sm - sx={{ - justifyContent: 'flex-end', - flexWrap: { xs: 'wrap', sm: 'nowrap' }, - }} - > - <Grid item xs={12} sm="auto"> - <BuyCryptoButton /> - </Grid> - - <Grid item xs={buttonWidth} sm="auto"> + {!noAssets && ( + <Box flex={1}> + <Button + onClick={handleOnSend} + size="compact" + variant="contained" + disableElevation + startIcon={<ArrowIconNW fontSize="small" />} + sx={{ height: '42px' }} + fullWidth + > + Send + </Button> + </Box> + )} + + {isSwapFeatureEnabled && !noAssets && ( + <Box flex={1}> + <Track {...SWAP_EVENTS.OPEN_SWAPS} label={SWAP_LABELS.dashboard}> + <Link href={{ pathname: AppRoutes.swap, query: router.query }} passHref type="button"> + <Button + data-testid="overview-swap-btn" + size="compact" + variant="contained" + color="background" + disableElevation + startIcon={<SwapIcon fontSize="small" />} + sx={{ height: '42px' }} + fullWidth + > + Swap + </Button> + </Link> + </Track> + </Box> + )} + + <Box flexShrink="0" width={{ xs: 1, md: 'auto' }}> + <BuyCryptoButton /> + </Box> + + <Box flex={1}> + <Track {...OVERVIEW_EVENTS.SHOW_QR} label="dashboard"> + <QrCodeButton> <Button - onClick={handleOnSend} - size={isSmallScreen ? 'medium' : 'small'} - variant="outlined" - color="primary" - startIcon={<ArrowIconNW />} + size="compact" + variant="contained" + color="background" + disableElevation + startIcon={<ArrowIconSE fontSize="small" />} + sx={{ height: '42px' }} fullWidth > - Send + Receive </Button> - </Grid> - <Grid item xs={buttonWidth} sm="auto"> - <Track {...OVERVIEW_EVENTS.SHOW_QR} label="dashboard"> - <QrCodeButton> - <Button - size={isSmallScreen ? 'medium' : 'small'} - variant="outlined" - color="primary" - startIcon={<ArrowIconSE />} - fullWidth - > - Receive - </Button> - </QrCodeButton> - </Track> - </Grid> - - {isSwapFeatureEnabled && ( - <Grid item xs={buttonWidth} sm="auto"> - <Track {...SWAP_EVENTS.OPEN_SWAPS} label={SWAP_LABELS.dashboard}> - <Link href={{ pathname: AppRoutes.swap, query: router.query }} passHref type="button"> - <Button - data-testid="overview-swap-btn" - size={isSmallScreen ? 'medium' : 'small'} - variant="outlined" - color="primary" - startIcon={<SwapIcon />} - fullWidth - > - Swap - </Button> - </Link> - </Track> - </Grid> - )} - </Grid> - )} - </Grid> - </> - )} - </WidgetBody> - </WidgetContainer> + </QrCodeButton> + </Track> + </Box> + </Stack> + )} + </Stack> + </Box> + + {noAssets ? <AddFundsToGetStarted /> : <NewsCarousel banners={banners} />} + </Card> ) } diff --git a/apps/web/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx b/apps/web/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx index f1214d107a..682d9ecdc7 100644 --- a/apps/web/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx +++ b/apps/web/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' import { useMemo } from 'react' import { useRouter } from 'next/router' import { ChevronRight } from '@mui/icons-material' -import { Box } from '@mui/material' +import { Box, Stack } from '@mui/material' import type { ReactElement } from 'react' import { RecoveryInfo } from '@/features/recovery/components/RecoveryInfo' @@ -12,6 +12,7 @@ import { AppRoutes } from '@/config/routes' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' import css from './styles.module.css' +import classnames from 'classnames' function PendingRecoveryListItem({ transaction }: { transaction: RecoveryQueueItem }): ReactElement { const router = useRouter() @@ -27,16 +28,15 @@ function PendingRecoveryListItem({ transaction }: { transaction: RecoveryQueueIt return ( <Link href={url} passHref> - <Box className={css.container} sx={{ minHeight: 50 }}> - <Box flex={1}> - <RecoveryType isMalicious={isMalicious} /> - </Box> + <Box className={classnames(css.container, css.recoveryContainer)} sx={{ minHeight: 50 }}> + <RecoveryType isMalicious={isMalicious} date={transaction.timestamp} isDashboard /> <RecoveryInfo isMalicious={isMalicious} /> - <RecoveryStatus recovery={transaction} /> - - <ChevronRight color="border" /> + <Stack direction="row" gap={1.5} alignItems="center" ml="auto"> + <RecoveryStatus recovery={transaction} /> + <ChevronRight color="border" fontSize="small" /> + </Stack> </Box> </Link> ) diff --git a/apps/web/src/components/dashboard/PendingTxs/PendingTxListItem.tsx b/apps/web/src/components/dashboard/PendingTxs/PendingTxListItem.tsx index c3e098ed3e..cf532e6e65 100644 --- a/apps/web/src/components/dashboard/PendingTxs/PendingTxListItem.tsx +++ b/apps/web/src/components/dashboard/PendingTxs/PendingTxListItem.tsx @@ -4,13 +4,14 @@ import type { ReactElement } from 'react' import { useMemo } from 'react' import ChevronRight from '@mui/icons-material/ChevronRight' import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' -import { Box } from '@mui/material' +import { Box, Stack, Typography } from '@mui/material' import { isMultisigExecutionInfo } from '@/utils/transaction-guards' import TxInfo from '@/components/transactions/TxInfo' -import TxType from '@/components/transactions/TxType' +import { TxTypeIcon, TxTypeText } from '@/components/transactions/TxType' import css from './styles.module.css' import { AppRoutes } from '@/config/routes' import TxConfirmations from '@/components/transactions/TxConfirmations' +import { DateTime } from '@/components/common/DateTime/DateTime' type PendingTxType = { transaction: TransactionSummary @@ -34,21 +35,22 @@ const PendingTx = ({ transaction }: PendingTxType): ReactElement => { return ( <NextLink data-testid="tx-pending-item" href={url} passHref> <Box className={css.container}> - <Box className={css.innerContainer}> - <Box sx={{ minWidth: 30 }}> - {isMultisigExecutionInfo(transaction.executionInfo) && transaction.executionInfo.nonce} + <Stack direction="row" gap={1.5} alignItems="center"> + <Box className={css.iconWrapper}> + <TxTypeIcon tx={transaction} /> </Box> - - <Box sx={{ minWidth: 62 }}> - <TxType tx={transaction} /> - </Box> - - <Box sx={{ minWidth: 0, flexGrow: 1 }}> - <TxInfo info={transaction.txInfo} /> + <Box> + <Typography className={css.txDescription}> + <TxTypeText tx={transaction} /> + <TxInfo info={transaction.txInfo} /> + </Typography> + <Typography variant="body2" color="primary.light"> + <DateTime value={transaction.timestamp} showDateTime={false} showTime={false} /> + </Typography> </Box> - </Box> + </Stack> - <Box alignSelf="flex-start" display="flex" flexWrap="nowrap" alignItems="center" gap={1.5}> + <Box className={css.confirmations}> {isMultisigExecutionInfo(transaction.executionInfo) && ( <TxConfirmations submittedConfirmations={transaction.executionInfo.confirmationsSubmitted} @@ -56,7 +58,7 @@ const PendingTx = ({ transaction }: PendingTxType): ReactElement => { /> )} - <ChevronRight color="border" /> + <ChevronRight color="border" fontSize="small" /> </Box> </Box> </NextLink> diff --git a/apps/web/src/components/dashboard/PendingTxs/PendingTxsList.tsx b/apps/web/src/components/dashboard/PendingTxs/PendingTxsList.tsx index ce6e96bc1a..3893075e71 100644 --- a/apps/web/src/components/dashboard/PendingTxs/PendingTxsList.tsx +++ b/apps/web/src/components/dashboard/PendingTxs/PendingTxsList.tsx @@ -1,21 +1,23 @@ -import type { ReactElement } from 'react' +import React, { type ReactElement } from 'react' import { useMemo } from 'react' import { useRouter } from 'next/router' import dynamic from 'next/dynamic' import { getLatestTransactions } from '@/utils/tx-list' -import { Box, Skeleton, Typography } from '@mui/material' -import { Card, ViewAllLink, WidgetBody, WidgetContainer } from '../styled' +import { Box, Typography, Card, Stack, Paper, Skeleton } from '@mui/material' +import { ViewAllLink } from '../styled' import PendingTxListItem from './PendingTxListItem' -import useTxQueue from '@/hooks/useTxQueue' +import useTxQueue, { useQueuedTxsLength } from '@/hooks/useTxQueue' import { AppRoutes } from '@/config/routes' -import NoTransactionsIcon from '@/public/images/transactions/no-transactions.svg' import css from './styles.module.css' import { isSignableBy, isExecutable } from '@/utils/transaction-guards' import useWallet from '@/hooks/wallets/useWallet' import useSafeInfo from '@/hooks/useSafeInfo' import { useRecoveryQueue } from '@/features/recovery/hooks/useRecoveryQueue' -import type { SafeInfo, Transaction } from '@safe-global/safe-gateway-typescript-sdk' +import type { Transaction } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' +import NoTxsIcon from '@/public/images/common/no-txs.svg' +import { SidebarListItemCounter } from '@/components/sidebar/SidebarList' const PendingRecoveryListItem = dynamic(() => import('./PendingRecoveryListItem')) @@ -23,27 +25,21 @@ const MAX_TXS = 4 const EmptyState = () => { return ( - <Card> - <Box display="flex" flexDirection="column" alignItems="center" justifyContent="center" height="100%" gap={2}> - <NoTransactionsIcon data-testid="no-tx-icon" /> + <Paper elevation={0} sx={{ p: 5, textAlign: 'center' }}> + <NoTxsIcon data-testid="no-tx-icon" /> - <Typography data-testid="no-tx-text" variant="body1" color="primary.light"> - This Safe Account has no queued transactions - </Typography> - </Box> - </Card> + <Typography mb={0.5} mt={3}> + No transactions to sign + </Typography> + + <Typography data-testid="no-tx-text" variant="body1" color="primary.light"> + Once you create pending transactions, they will appear here + </Typography> + </Paper> ) } -const LoadingState = () => ( - <div className={css.list}> - {Array.from(Array(MAX_TXS).keys()).map((key) => ( - <Skeleton key={key} variant="rectangular" height={52} /> - ))} - </div> -) - -function getActionableTransactions(txs: Transaction[], safe: SafeInfo, walletAddress?: string): Transaction[] { +function getActionableTransactions(txs: Transaction[], safe: SafeState, walletAddress?: string): Transaction[] { if (!walletAddress) { return txs } @@ -62,7 +58,7 @@ export function _getTransactionsToDisplay({ recoveryQueue: RecoveryQueueItem[] queue: Transaction[] walletAddress?: string - safe: SafeInfo + safe: SafeState }): [RecoveryQueueItem[], Transaction[]] { if (recoveryQueue.length >= MAX_TXS) { return [recoveryQueue.slice(0, MAX_TXS), []] @@ -82,6 +78,7 @@ const PendingTxsList = (): ReactElement | null => { const wallet = useWallet() const queuedTxns = useMemo(() => getLatestTransactions(page?.results), [page?.results]) const recoveryQueue = useRecoveryQueue() + const queueSize = useQueuedTxsLength() const [recoveryTxs, queuedTxs] = useMemo(() => { return _getTransactionsToDisplay({ @@ -102,20 +99,19 @@ const PendingTxsList = (): ReactElement | null => { [router.query.safe], ) + if (loading) return <Skeleton variant="rounded" height={338} /> + return ( - <WidgetContainer data-testid="pending-tx-widget"> - <div className={css.title}> - <Typography component="h2" variant="subtitle1" fontWeight={700} mb={2}> - Pending transactions + <Card data-testid="pending-tx-widget" sx={{ px: 1.5, py: 2.5, height: 1 }} component="section"> + <Stack direction="row" justifyContent="space-between" sx={{ px: 1.5, mb: 1 }}> + <Typography fontWeight={700} className={css.pendingTxHeader}> + Pending transactions <SidebarListItemCounter count={queueSize} /> </Typography> - {totalTxs > 0 && <ViewAllLink url={queueUrl} />} - </div> + </Stack> - <WidgetBody> - {loading ? ( - <LoadingState /> - ) : totalTxs > 0 ? ( + <Box> + {totalTxs > 0 ? ( <div className={css.list}> {recoveryTxs.map((tx) => ( <PendingRecoveryListItem transaction={tx} key={tx.transactionHash} /> @@ -128,8 +124,8 @@ const PendingTxsList = (): ReactElement | null => { ) : ( <EmptyState /> )} - </WidgetBody> - </WidgetContainer> + </Box> + </Card> ) } diff --git a/apps/web/src/components/dashboard/PendingTxs/styles.module.css b/apps/web/src/components/dashboard/PendingTxs/styles.module.css index 5d17ec773e..3b0dd092d3 100644 --- a/apps/web/src/components/dashboard/PendingTxs/styles.module.css +++ b/apps/web/src/components/dashboard/PendingTxs/styles.module.css @@ -2,14 +2,28 @@ width: 100%; padding: 11px 16px; background-color: var(--color-background-paper); - border: 1px solid var(--color-background-paper); border-radius: 8px; flex-wrap: nowrap; - display: flex; + display: grid; + grid-template-columns: 1fr 150px; align-items: center; gap: var(--space-2); min-height: 50px; + position: relative; +} + +.recoveryContainer { + grid-template-columns: 1fr 170px; +} + +.pendingTxHeader :global .MuiBadge-root { + margin-left: var(--space-1); } + +.pendingTxHeader :global .MuiBadge-badge { + position: relative; +} + .innerContainer { min-width: 0; flex-grow: 1; @@ -19,15 +33,19 @@ align-items: center; gap: var(--space-2); } + .container:hover { - background-color: var(--color-background-light); - border-color: var(--color-secondary-light); + background-color: var(--color-background-main); +} + +.tokenAmount * { + font-weight: normal; + color: var(--color-primary-light); } .list { display: flex; flex-direction: column; - gap: var(--space-1); width: 100%; } @@ -41,10 +59,84 @@ justify-content: space-between; } +.assetButtons { + position: absolute; + right: 16px; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s; + background-color: var(--color-background-main); + border-radius: var(--space-1); + height: 100%; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + width: calc(100% - 200px); +} + +.container:hover .assetButtons { + opacity: 1; + visibility: visible; +} + +.bar { + height: 4px; + border-radius: 4px; + background-color: var(--color-border-light); + width: 100px; +} + +.barPercentage { + display: block; + border-radius: 4px; + height: 100%; + background: linear-gradient(225deg, #5fddff 12.5%, #12ff80 88.07%); +} + +.iconWrapper { + width: 32px; + height: 32px; + border-radius: 50%; + background-color: var(--color-background-main); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.txDescription { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} + +.txDescription img { + width: 24px; + height: 24px; + object-fit: contain; +} + +.confirmations { + align-self: center; + margin-left: auto; + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 12px; +} + @media (max-width: 600px) { .container { flex-direction: column; - align-items: start; + align-items: flex-start; flex-wrap: wrap; + grid-template-columns: 1fr; + gap: var(--space-1); + } + + .confirmations { + margin-left: 40px; } } diff --git a/apps/web/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx b/apps/web/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx index ce0236022f..8bc56cf8c8 100644 --- a/apps/web/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx +++ b/apps/web/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx @@ -1,50 +1,103 @@ -import { useRouter } from 'next/router' -import Typography from '@mui/material/Typography' -import Grid from '@mui/material/Grid' -import Button from '@mui/material/Button' - -import { WidgetContainer } from '../styled' +import { Typography, Card, Stack } from '@mui/material' import { useSafeApps } from '@/hooks/safe-apps/useSafeApps' import useSafeAppPreviewDrawer from '@/hooks/safe-apps/useSafeAppPreviewDrawer' import SafeAppPreviewDrawer from '@/components/safe-apps/SafeAppPreviewDrawer' -import SafeAppCard, { SafeAppCardContainer } from '@/components/safe-apps/SafeAppCard' -import { AppRoutes } from '@/config/routes' -import ExploreSafeAppsIcon from '@/public/images/apps/explore.svg' +import SafeAppCard from '@/components/safe-apps/SafeAppCard' import { SAFE_APPS_LABELS } from '@/services/analytics' - import css from './styles.module.css' +import IconButton from '@mui/material/IconButton' +import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeftRounded' +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRightRounded' +import { useEffect, useRef, useState } from 'react' + +const ITEM_GAP = 16 const SafeAppsDashboardSection = () => { const { rankedSafeApps, togglePin, pinnedSafeAppIds } = useSafeApps() const { isPreviewDrawerOpen, previewDrawerApp, openPreviewDrawer, closePreviewDrawer } = useSafeAppPreviewDrawer() + const listRef = useRef<HTMLUListElement>(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(false) - return ( - <WidgetContainer> - <Typography component="h2" variant="subtitle1" fontWeight={700} mb={2}> - Safe Apps - </Typography> + useEffect(() => { + const list = listRef.current + if (!list) return + + setCanScrollLeft(list.scrollLeft > 0) + setCanScrollRight(list.scrollLeft + list.clientWidth < list.scrollWidth) + }, [rankedSafeApps.length]) + + const scrollList = (direction: 'left' | 'right') => { + const list = listRef.current + if (!list) return + + const firstItem = list.firstElementChild as HTMLElement | null + if (!firstItem) return + + const itemWidth = firstItem.offsetWidth + ITEM_GAP + const itemsInView = Math.max(1, Math.floor(list.clientWidth / itemWidth)) + const scrollAmount = itemWidth * itemsInView + const newScrollLeft = + direction === 'left' + ? Math.max(0, list.scrollLeft - scrollAmount) + : Math.min(list.scrollWidth - list.clientWidth, list.scrollLeft + scrollAmount) + + list.scrollBy({ left: direction === 'left' ? -scrollAmount : scrollAmount, behavior: 'smooth' }) + setCanScrollLeft(newScrollLeft > 0) + setCanScrollRight(newScrollLeft + list.clientWidth < list.scrollWidth) + } - <Grid container spacing={3}> - {rankedSafeApps.map((rankedSafeApp) => ( - <Grid key={rankedSafeApp.id} item xs={12} sm={6} md={4} xl={4}> - <SafeAppCard - safeApp={rankedSafeApp} - onBookmarkSafeApp={(appId) => togglePin(appId, SAFE_APPS_LABELS.dashboard)} - isBookmarked={pinnedSafeAppIds.has(rankedSafeApp.id)} - onClickSafeApp={(e) => { - // Don't open link - e.preventDefault() - openPreviewDrawer(rankedSafeApp) - }} - openPreviewDrawer={openPreviewDrawer} - /> - </Grid> - ))} + if (rankedSafeApps.length === 0) return null - <Grid item xs={12} sm={6} md={4} xl={4}> - <ExploreSafeAppsCard /> - </Grid> - </Grid> + const showNav = canScrollLeft || canScrollRight + + return ( + <Card sx={{ px: 3, py: 2.5 }} component="section"> + <Stack direction="row" justifyContent="space-between" mb={2}> + <Typography fontWeight={700}>Featured Apps</Typography> + {showNav && ( + <> + <div className={css.carouselNav}> + <IconButton + aria-label="previous apps" + onClick={() => scrollList('left')} + disabled={!canScrollLeft} + size="medium" + > + <KeyboardArrowLeftIcon fontSize="small" /> + </IconButton> + <IconButton + aria-label="next apps" + onClick={() => scrollList('right')} + disabled={!canScrollRight} + size="medium" + > + <KeyboardArrowRightIcon fontSize="small" /> + </IconButton> + </div> + </> + )} + </Stack> + + <div className={css.carouselWrapper}> + <ul className={css.carouselList} ref={listRef} style={{ gap: ITEM_GAP }}> + {rankedSafeApps.map((rankedSafeApp) => ( + <li key={rankedSafeApp.id}> + <SafeAppCard + safeApp={rankedSafeApp} + onBookmarkSafeApp={(appId) => togglePin(appId, SAFE_APPS_LABELS.dashboard)} + isBookmarked={pinnedSafeAppIds.has(rankedSafeApp.id)} + onClickSafeApp={(e) => { + e.preventDefault() + openPreviewDrawer(rankedSafeApp) + }} + openPreviewDrawer={openPreviewDrawer} + compact + /> + </li> + ))} + </ul> + </div> <SafeAppPreviewDrawer isOpen={isPreviewDrawerOpen} @@ -53,23 +106,8 @@ const SafeAppsDashboardSection = () => { onClose={closePreviewDrawer} onBookmark={(appId) => togglePin(appId, SAFE_APPS_LABELS.apps_sidebar)} /> - </WidgetContainer> + </Card> ) } export default SafeAppsDashboardSection - -const ExploreSafeAppsCard = () => { - const router = useRouter() - const safeAppsLink = `${AppRoutes.apps.index}?safe=${router.query.safe}` - - return ( - <SafeAppCardContainer safeAppUrl={safeAppsLink} className={css.container}> - <ExploreSafeAppsIcon alt="Explore Safe Apps icon" /> - - <Button data-testid="explore-apps-btn" variant="contained" size="small"> - Explore Safe Apps - </Button> - </SafeAppCardContainer> - ) -} diff --git a/apps/web/src/components/dashboard/SafeAppsDashboardSection/__tests__/SafeAppsDashboardSection.test.tsx b/apps/web/src/components/dashboard/SafeAppsDashboardSection/__tests__/SafeAppsDashboardSection.test.tsx index d235037244..7873fb34b8 100644 --- a/apps/web/src/components/dashboard/SafeAppsDashboardSection/__tests__/SafeAppsDashboardSection.test.tsx +++ b/apps/web/src/components/dashboard/SafeAppsDashboardSection/__tests__/SafeAppsDashboardSection.test.tsx @@ -106,29 +106,14 @@ describe('Safe Apps Dashboard Section', () => { it('should display the Safe Apps Section', async () => { render(<SafeAppsDashboardSection />) - await waitFor(() => expect(screen.getByText('Safe Apps')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText('Featured Apps')).toBeInTheDocument()) }) it('should display Safe Apps Cards (Name & Description)', async () => { render(<SafeAppsDashboardSection />) await waitFor(() => expect(screen.getByText('Compound')).toBeInTheDocument()) - await waitFor(() => expect(screen.getByText('Money markets on the Ethereum blockchain')).toBeInTheDocument()) - await waitFor(() => expect(screen.getByText('ENS App')).toBeInTheDocument()) - await waitFor(() => - expect(screen.getByText('Decentralised naming for wallets, websites, & more.')).toBeInTheDocument(), - ) - - // Synthetix is not displayed as it is not featured - await waitFor(() => expect(screen.getByText('Transaction Builder')).toBeInTheDocument()) - await waitFor(() => expect(screen.getByText('A Safe app to compose custom transactions')).toBeInTheDocument()) - }) - - it('should show the Explore Safe Apps Link', async () => { - render(<SafeAppsDashboardSection />) - - await waitFor(() => expect(screen.getByText('Explore Safe Apps')).toBeInTheDocument()) }) }) diff --git a/apps/web/src/components/dashboard/SafeAppsDashboardSection/styles.module.css b/apps/web/src/components/dashboard/SafeAppsDashboardSection/styles.module.css index a63a8dd8f2..b150a78230 100644 --- a/apps/web/src/components/dashboard/SafeAppsDashboardSection/styles.module.css +++ b/apps/web/src/components/dashboard/SafeAppsDashboardSection/styles.module.css @@ -7,3 +7,27 @@ gap: var(--space-1); padding: var(--space-2); } + +.carouselWrapper { + position: relative; +} + +.carouselNav { + display: flex; + justify-content: flex-end; + gap: 4px; +} + +.carouselList { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 250px; + list-style: none; + padding: 0; + margin: 0; + overflow: hidden; +} + +.carouselList::-webkit-scrollbar { + display: none; +} diff --git a/apps/web/src/components/dashboard/StakingBanner/index.tsx b/apps/web/src/components/dashboard/StakingBanner/index.tsx index d7b085ca9f..67c817e2c3 100644 --- a/apps/web/src/components/dashboard/StakingBanner/index.tsx +++ b/apps/web/src/components/dashboard/StakingBanner/index.tsx @@ -1,9 +1,5 @@ -import { useMemo } from 'react' -import { Typography, Card, SvgIcon, Grid, Button, Box, Stack, Link } from '@mui/material' +import { Typography, Card, SvgIcon, Button, Box, Stack, Link } from '@mui/material' import css from './styles.module.css' -import Kiln from '@/public/images/common/kiln.svg' -import StakeIllustrationLight from '@/public/images/common/stake-illustration-light.svg' -import StakeIllustrationDark from '@/public/images/common/stake-illustration-dark.svg' import StakeIcon from '@/public/images/common/stake.svg' import classNames from 'classnames' import { useDarkMode } from '@/hooks/useDarkMode' @@ -12,40 +8,21 @@ import NextLink from 'next/link' import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import useLocalStorage from '@/services/local-storage/useLocalStorage' import ExternalLink from '@/components/common/ExternalLink' -import { useSanctionedAddress } from '@/hooks/useSanctionedAddress' import { AppRoutes } from '@/config/routes' -import useBalances from '@/hooks/useBalances' -import { formatUnits } from 'ethers' -import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' -import useIsStakingBannerEnabled from '@/features/stake/hooks/useIsStakingBannerEnabled' +import useIsStakingBannerVisible from '@/components/dashboard/StakingBanner/useIsStakingBannerVisible' const LEARN_MORE_LINK = 'https://help.safe.global/en/articles/222615-safe-staking' -const MIN_NATIVE_TOKEN_BALANCE = 32 const StakingBanner = ({ - large = false, hideLocalStorageKey = 'hideStakingBanner', }: { large?: boolean; hideLocalStorageKey?: string } = {}) => { const isDarkMode = useDarkMode() const router = useRouter() - const { balances } = useBalances() + const isStakingBannerVisible = useIsStakingBannerVisible() - const nativeTokenBalance = useMemo( - () => balances.items.find((balance) => balance.tokenInfo.type === TokenType.NATIVE_TOKEN), - [balances.items], - ) - - const hasSufficientFunds = - nativeTokenBalance != null && - Number(formatUnits(nativeTokenBalance.balance, nativeTokenBalance.tokenInfo.decimals)) >= MIN_NATIVE_TOKEN_BALANCE - - const [widgetHidden = false, setWidgetHidden] = useLocalStorage<boolean>(hideLocalStorageKey) + const [_, setWidgetHidden] = useLocalStorage<boolean>(hideLocalStorageKey) - const isStakingBannerEnabled = useIsStakingBannerEnabled() - - const sanctionedAddress = useSanctionedAddress(isStakingBannerEnabled && !widgetHidden) - - if (!isStakingBannerEnabled || widgetHidden || Boolean(sanctionedAddress) || !hasSufficientFunds) return null + if (!isStakingBannerVisible) return null const onClick = () => { trackEvent(OVERVIEW_EVENTS.OPEN_STAKING_WIDGET) @@ -60,123 +37,6 @@ const StakingBanner = ({ trackEvent(OVERVIEW_EVENTS.OPEN_LEARN_MORE_STAKING_BANNER) } - if (large) { - return ( - <> - <Card className={`${css.bannerWrapper} ${css.bannerWrapperLarge}`}> - <Box - sx={{ - mr: { sm: -8, md: -4, lg: 0 }, - display: { xs: 'none', sm: 'block' }, - position: 'relative', - }} - > - <Box className={classNames(css.gradientShadow, { [css.gradientShadowDarkMode]: isDarkMode })} /> - <SvgIcon - component={isDarkMode ? StakeIllustrationLight : StakeIllustrationDark} - inheritViewBox - className={classNames(css.stakeIllustration)} - /> - </Box> - - <Grid container rowSpacing={2}> - <Grid - item - xs={12} - sx={{ - zIndex: 2, - mb: 1, - }} - > - <Stack spacing={0.5} direction="row"> - <Typography - variant="overline" - sx={{ - color: 'primary.light', - }} - > - Powered by - </Typography> - <SvgIcon - component={Kiln} - inheritViewBox - color="border" - className={classNames(css.kilnIcon, { [css.kilnIconDarkMode]: isDarkMode })} - /> - </Stack> - </Grid> - - <Grid - item - xs={12} - sx={{ - zIndex: 2, - }} - > - <Typography - variant="h2" - className={classNames(css.header, { [css.gradientText]: isDarkMode })} - sx={{ - fontWeight: 700, - }} - > - Stake your ETH and earn rewards - </Typography> - </Grid> - - <Grid - item - xs={12} - sm={6} - sx={{ - zIndex: 2, - mb: 1, - }} - > - <Typography variant="body1"> - Lock 32 ETH and become a validator easily with the Kiln widget — faster and more cost-effective. You can - also explore Safe Apps or home staking for other options. Staking involves risks like slashing. - </Typography> - {LEARN_MORE_LINK && ( - <ExternalLink onClick={onLearnMore} href={LEARN_MORE_LINK}> - Learn more - </ExternalLink> - )} - </Grid> - - <Grid - item - container - xs={12} - spacing={2} - sx={{ - textAlign: 'center', - }} - > - <Grid item xs={12} md="auto"> - <NextLink - href={AppRoutes.stake && { pathname: AppRoutes.stake, query: { safe: router.query.safe } }} - passHref - rel="noreferrer" - onClick={onClick} - > - <Button fullWidth variant="contained"> - Stake ETH - </Button> - </NextLink> - </Grid> - <Grid item xs={12} md="auto"> - <Button variant="text" onClick={onHide}> - Don't show again - </Button> - </Grid> - </Grid> - </Grid> - </Card> - </> - ) - } - return ( <> <Card className={css.bannerWrapper}> diff --git a/apps/web/src/components/dashboard/StakingBanner/useIsStakingBannerVisible.test.ts b/apps/web/src/components/dashboard/StakingBanner/useIsStakingBannerVisible.test.ts new file mode 100644 index 0000000000..8aa57bc7ff --- /dev/null +++ b/apps/web/src/components/dashboard/StakingBanner/useIsStakingBannerVisible.test.ts @@ -0,0 +1,91 @@ +import { renderHook } from '@testing-library/react' +import useIsStakingBannerVisible from './useIsStakingBannerVisible' + +jest.mock('@safe-global/safe-gateway-typescript-sdk', () => ({ + __esModule: true, + TokenType: { NATIVE_TOKEN: 'NATIVE_TOKEN', ERC20: 'ERC20' }, +})) + +jest.mock('@/features/stake/hooks/useIsStakingBannerEnabled', () => ({ + __esModule: true, + default: jest.fn(), +})) +import useIsStakingBannerEnabled from '@/features/stake/hooks/useIsStakingBannerEnabled' + +jest.mock('@/hooks/useBalances', () => ({ + __esModule: true, + default: jest.fn(), +})) +import useBalances from '@/hooks/useBalances' + +jest.mock('@/hooks/useSanctionedAddress', () => ({ + __esModule: true, + useSanctionedAddress: jest.fn(), +})) +import { useSanctionedAddress } from '@/hooks/useSanctionedAddress' + +// `ethers/formatUnits` is used unchanged – we just re‑export it so Jest can spy if you ever need to +jest.mock('ethers', () => { + const real = jest.requireActual('ethers') + return { ...real } +}) + +const nativeBalance = ( + etherAmount: number, // human‑readable ETH + decimals = 18, +) => ({ + balance: (BigInt(etherAmount) * 10n ** BigInt(decimals)).toString(), + tokenInfo: { type: 'NATIVE_TOKEN', decimals }, +}) + +const mockIsEnabled = useIsStakingBannerEnabled as jest.MockedFunction<() => boolean> +const mockBalances = useBalances as jest.MockedFunction<any> +const mockSanctions = useSanctionedAddress as jest.MockedFunction<(f: boolean) => string | undefined> + +describe('useIsStakingBannerVisible', () => { + beforeEach(() => { + jest.resetAllMocks() + + // sensible happy‑path defaults for every test + mockIsEnabled.mockReturnValue(true) + mockSanctions.mockReturnValue(undefined) + mockBalances.mockReturnValue({ + balances: { items: [nativeBalance(32)] }, // exactly the min + }) + }) + + it('returns TRUE when the feature is enabled, wallet is not sanctioned, and balance ≥ 32 ETH', () => { + const { result } = renderHook(() => useIsStakingBannerVisible()) + expect(result.current).toBe(true) + }) + + it('returns FALSE when the feature‑flag is off', () => { + mockIsEnabled.mockReturnValue(false) + + const { result } = renderHook(() => useIsStakingBannerVisible()) + expect(result.current).toBe(false) + }) + + it('returns FALSE for a sanctioned wallet', () => { + mockSanctions.mockReturnValue('0xDEADbeEf') + + const { result } = renderHook(() => useIsStakingBannerVisible()) + expect(result.current).toBe(false) + }) + + it('returns FALSE when the wallet has no native‑token entry at all', () => { + mockBalances.mockReturnValue({ balances: { items: [] } }) + + const { result } = renderHook(() => useIsStakingBannerVisible()) + expect(result.current).toBe(false) + }) + + it('returns FALSE when the native‑token balance is below the 32 ETH threshold', () => { + mockBalances.mockReturnValue({ + balances: { items: [nativeBalance(31)] }, + }) + + const { result } = renderHook(() => useIsStakingBannerVisible()) + expect(result.current).toBe(false) + }) +}) diff --git a/apps/web/src/components/dashboard/StakingBanner/useIsStakingBannerVisible.ts b/apps/web/src/components/dashboard/StakingBanner/useIsStakingBannerVisible.ts new file mode 100644 index 0000000000..5e3e6cad72 --- /dev/null +++ b/apps/web/src/components/dashboard/StakingBanner/useIsStakingBannerVisible.ts @@ -0,0 +1,28 @@ +import useBalances from '@/hooks/useBalances' +import useIsStakingBannerEnabled from '@/features/stake/hooks/useIsStakingBannerEnabled' +import { useSanctionedAddress } from '@/hooks/useSanctionedAddress' +import { useMemo } from 'react' +import { formatUnits } from 'ethers' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' + +const MIN_NATIVE_TOKEN_BALANCE = 32 + +const useIsStakingBannerVisible = () => { + const { balances } = useBalances() + const isStakingBannerEnabled = useIsStakingBannerEnabled() + const sanctionedAddress = useSanctionedAddress(isStakingBannerEnabled) + + const nativeTokenBalance = useMemo( + () => balances.items.find((balance) => balance.tokenInfo.type === TokenType.NATIVE_TOKEN), + [balances.items], + ) + + const hasSufficientFunds = + nativeTokenBalance != null && + Number(formatUnits(nativeTokenBalance.balance, nativeTokenBalance.tokenInfo.decimals ?? 0)) >= + MIN_NATIVE_TOKEN_BALANCE + + return isStakingBannerEnabled && !Boolean(sanctionedAddress) && hasSufficientFunds +} + +export default useIsStakingBannerVisible diff --git a/apps/web/src/components/dashboard/index.tsx b/apps/web/src/components/dashboard/index.tsx index 2d78e54404..53b9c320b6 100644 --- a/apps/web/src/components/dashboard/index.tsx +++ b/apps/web/src/components/dashboard/index.tsx @@ -7,21 +7,19 @@ import PendingTxsList from '@/components/dashboard/PendingTxs/PendingTxsList' import AssetsWidget from '@/components/dashboard/Assets' import Overview from '@/components/dashboard/Overview/Overview' import SafeAppsDashboardSection from '@/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection' -import GovernanceSection from '@/components/dashboard/GovernanceSection/GovernanceSection' import { useIsRecoverySupported } from '@/features/recovery/hooks/useIsRecoverySupported' -import StakingBanner from '@/components/dashboard/StakingBanner' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' import css from './styles.module.css' import { InconsistentSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning' -import useIsStakingBannerEnabled from '@/features/stake/hooks/useIsStakingBannerEnabled' +import { UnsupportedMastercopyWarning } from '@/features/multichain/components/UnsupportedMastercopyWarning/UnsupportedMasterCopyWarning' +import { FEATURES } from '@safe-global/utils/utils/chains' +import NewsDisclaimers from '@/components/dashboard/NewsCarousel/NewsDisclaimers' const RecoveryHeader = dynamic(() => import('@/features/recovery/components/RecoveryHeader')) const Dashboard = (): ReactElement => { const { safe } = useSafeInfo() const showSafeApps = useHasFeature(FEATURES.SAFE_APPS) - const isStakingBannerEnabled = useIsStakingBannerEnabled() const supportsRecovery = useIsRecoverySupported() return ( @@ -29,10 +27,14 @@ const Dashboard = (): ReactElement => { <Grid container spacing={3}> {supportsRecovery && <RecoveryHeader />} - <Grid item xs={12}> + <Grid item xs={12} className={css.hideIfEmpty} sx={{ '& > div': { m: 0 } }}> <InconsistentSignerSetupWarning /> </Grid> + <Grid item xs={12} className={css.hideIfEmpty}> + <UnsupportedMastercopyWarning /> + </Grid> + <Grid item xs={12}> <Overview /> </Grid> @@ -43,14 +45,6 @@ const Dashboard = (): ReactElement => { {safe.deployed && ( <> - {isStakingBannerEnabled && ( - <Grid item xs={12} className={css.hideIfEmpty}> - <StakingBanner hideLocalStorageKey="hideStakingBannerDashboard" large /> - </Grid> - )} - - <Grid item xs={12} /> - <Grid item xs={12} lg={6}> <AssetsWidget /> </Grid> @@ -65,8 +59,8 @@ const Dashboard = (): ReactElement => { </Grid> )} - <Grid item xs={12} className={css.hideIfEmpty}> - <GovernanceSection /> + <Grid item xs={12}> + <NewsDisclaimers /> </Grid> </> )} diff --git a/apps/web/src/components/dashboard/styled.tsx b/apps/web/src/components/dashboard/styled.tsx index 14de0b75d9..381a5a4d52 100644 --- a/apps/web/src/components/dashboard/styled.tsx +++ b/apps/web/src/components/dashboard/styled.tsx @@ -11,10 +11,6 @@ export const WidgetContainer = styled.section` height: 100%; ` -export const WidgetTitle = styled.h2` - margin-top: 0; -` - export const WidgetBody = styled.div` display: flex; flex-direction: column; @@ -37,20 +33,22 @@ export const Card = styled.div` } ` -const StyledLink = styled(Link)` - text-decoration: none; - font-weight: bold; - display: flex; - align-items: center; - gap: var(--space-1); - margin-bottom: 10px; - padding-right: 17px; -` - export const ViewAllLink = ({ url, text }: { url: LinkProps['href']; text?: string }): ReactElement => ( <NextLink href={url} passHref legacyBehavior> - <StyledLink data-testid="view-all-link"> - {text || 'View all'} <ChevronRightIcon /> - </StyledLink> + <Link + data-testid="view-all-link" + sx={{ + textDecoration: 'none', + fontWeight: 'bold', + display: 'flex', + alignItems: 'center', + gap: '4px', + color: 'primary.light', + fontSize: '14px', + '&:hover': { color: 'primary.main' }, + }} + > + {text || 'View all'} <ChevronRightIcon fontSize="small" /> + </Link> </NextLink> ) diff --git a/apps/web/src/components/dashboard/styles.module.css b/apps/web/src/components/dashboard/styles.module.css index e88d9deebe..10d1d8d044 100644 --- a/apps/web/src/components/dashboard/styles.module.css +++ b/apps/web/src/components/dashboard/styles.module.css @@ -1,3 +1,17 @@ .hideIfEmpty:empty { display: none; } + +.topBanner { + margin-left: calc(var(--space-3) * -1); + margin-right: calc(var(--space-3) * -1); + margin-top: calc(var(--space-3) * -1); +} + +@media (max-width: 599.95px) { + .topBanner { + margin-left: calc(var(--space-2) * -1); + margin-right: calc(var(--space-2) * -1); + margin-top: calc(var(--space-2) * -1); + } +} diff --git a/apps/web/src/components/new-safe/OwnerRow/index.tsx b/apps/web/src/components/new-safe/OwnerRow/index.tsx index c4a9d8773a..3e43f0948f 100644 --- a/apps/web/src/components/new-safe/OwnerRow/index.tsx +++ b/apps/web/src/components/new-safe/OwnerRow/index.tsx @@ -9,9 +9,10 @@ import { useAddressResolver } from '@/hooks/useAddressResolver' import EthHashInfo from '@/components/common/EthHashInfo' import type { NamedAddress } from '@/components/new-safe/create/types' import useWallet from '@/hooks/wallets/useWallet' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import css from './styles.module.css' import classNames from 'classnames' +import useSafeInfo from '@/hooks/useSafeInfo' export const OwnerRow = ({ index, @@ -26,6 +27,7 @@ export const OwnerRow = ({ remove?: (index: number) => void readOnly?: boolean }) => { + const { safeAddress } = useSafeInfo() const wallet = useWallet() const fieldName = `${groupName}.${index}` const { control, getValues, setValue } = useFormContext() @@ -42,14 +44,17 @@ export const OwnerRow = ({ return Array.from({ length: owners.length }, (_, i) => `${groupName}.${i}`) }, [owners, groupName]) - const validateSafeAddress = useCallback( + const validateOwnerAddress = useCallback( async (address: string) => { + if (sameAddress(address, safeAddress)) { + return 'The Safe Account cannot own itself' + } const owners = getValues('owners') if (owners.filter((owner: NamedAddress) => sameAddress(owner.address, address)).length > 1) { return 'Signer is already added' } }, - [getValues], + [getValues, safeAddress], ) const { name, ens, resolving } = useAddressResolver(owner.address) @@ -105,7 +110,13 @@ export const OwnerRow = ({ </Typography> ) : ( <FormControl fullWidth> - <AddressBookInput name={`${fieldName}.address`} label="Signer" validate={validateSafeAddress} deps={deps} /> + <AddressBookInput + name={`${fieldName}.address`} + label="Signer" + validate={validateOwnerAddress} + deps={deps} + onReset={() => setValue(`${fieldName}.name`, '')} + /> </FormControl> )} </Grid> diff --git a/apps/web/src/components/new-safe/create/AdvancedCreateSafe.tsx b/apps/web/src/components/new-safe/create/AdvancedCreateSafe.tsx index 5dd08899bb..11074f4670 100644 --- a/apps/web/src/components/new-safe/create/AdvancedCreateSafe.tsx +++ b/apps/web/src/components/new-safe/create/AdvancedCreateSafe.tsx @@ -17,8 +17,8 @@ import CreateSafeInfos from '@/components/new-safe/create/CreateSafeInfos' import { useState } from 'react' import { type NewSafeFormData } from '.' import AdvancedOptionsStep from './steps/AdvancedOptionsStep' -import { getLatestSafeVersion } from '@/utils/chains' import { useCurrentChain } from '@/hooks/useChains' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' const AdvancedCreateSafe = () => { const router = useRouter() diff --git a/apps/web/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts b/apps/web/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts index 42bb6210bb..a9b4970892 100644 --- a/apps/web/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts +++ b/apps/web/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts @@ -12,7 +12,7 @@ import { JsonRpcProvider } from 'ethers' import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { waitFor } from '@testing-library/react' import { type EIP1193Provider } from '@web3-onboard/core' -import { type ReplayedSafeProps } from '@/store/slices' +import { type ReplayedSafeProps } from '@safe-global/utils/features/counterfactual/store/types' import { faker } from '@faker-js/faker' const mockProps: ReplayedSafeProps = { diff --git a/apps/web/src/components/new-safe/create/index.tsx b/apps/web/src/components/new-safe/create/index.tsx index 788abf45b7..50d5142b93 100644 --- a/apps/web/src/components/new-safe/create/index.tsx +++ b/apps/web/src/components/new-safe/create/index.tsx @@ -17,11 +17,11 @@ import type { CreateSafeInfoItem } from '@/components/new-safe/create/CreateSafe import CreateSafeInfos from '@/components/new-safe/create/CreateSafeInfos' import { type ReactElement, useMemo, useState } from 'react' import ExternalLink from '@/components/common/ExternalLink' -import { HelpCenterArticle } from '@/config/constants' -import { type SafeVersion } from '@safe-global/safe-core-sdk-types' -import { getLatestSafeVersion } from '@/utils/chains' +import { type SafeVersion } from '@safe-global/types-kit' import { useCurrentChain } from '@/hooks/useChains' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' export type NewSafeFormData = { name: string diff --git a/apps/web/src/components/new-safe/create/logic/address-book.ts b/apps/web/src/components/new-safe/create/logic/address-book.ts index 09f16d2ce2..af4a02df7e 100644 --- a/apps/web/src/components/new-safe/create/logic/address-book.ts +++ b/apps/web/src/components/new-safe/create/logic/address-book.ts @@ -1,7 +1,7 @@ import type { AppThunk } from '@/store' import { addOrUpdateSafe } from '@/store/addedSafesSlice' import { upsertAddressBookEntries } from '@/store/addressBookSlice' -import { defaultSafeInfo } from '@/store/safeInfoSlice' +import { defaultSafeInfo } from '@safe-global/store/slices/SafeInfo/utils' import type { NamedAddress } from '@/components/new-safe/create/types' export const updateAddressBook = ( diff --git a/apps/web/src/components/new-safe/create/logic/index.test.ts b/apps/web/src/components/new-safe/create/logic/index.test.ts index ac350705ba..1f407db970 100644 --- a/apps/web/src/components/new-safe/create/logic/index.test.ts +++ b/apps/web/src/components/new-safe/create/logic/index.test.ts @@ -15,16 +15,15 @@ import { toBeHex } from 'ethers' import { Gnosis_safe__factory, Proxy_factory__factory, -} from '@/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' +} from '@safe-global/utils/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' import { getReadOnlyFallbackHandlerContract, getReadOnlyGnosisSafeContract, getReadOnlyProxyFactoryContract, } from '@/services/contracts/safeContracts' import * as gateway from '@safe-global/safe-gateway-typescript-sdk' -import { FEATURES, getLatestSafeVersion } from '@/utils/chains' import { chainBuilder } from '@/tests/builders/chains' -import { type ReplayedSafeProps } from '@/store/slices' +import { type ReplayedSafeProps } from '@safe-global/utils/features/counterfactual/store/types' import { faker } from '@faker-js/faker' import { ECOSYSTEM_ID_ADDRESS } from '@/config/constants' import { @@ -34,7 +33,8 @@ import { getSafeSingletonDeployment, getSafeToL2SetupDeployment, } from '@safe-global/safe-deployments' -import { Safe_to_l2_setup__factory } from '@/types/contracts' +import { Safe_to_l2_setup__factory } from '@safe-global/utils/types/contracts' +import { FEATURES, getLatestSafeVersion } from '@safe-global/utils/utils/chains' const provider = new JsonRpcProvider(undefined, { name: 'ethereum', chainId: 1 }) @@ -42,10 +42,6 @@ const latestSafeVersion = getLatestSafeVersion( chainBuilder().with({ chainId: '1', recommendedMasterCopyVersion: '1.4.1' }).build(), ) -const safeToL2SetupDeployment = getSafeToL2SetupDeployment() -const safeToL2SetupAddress = safeToL2SetupDeployment?.defaultAddress -const safeToL2SetupInterface = Safe_to_l2_setup__factory.createInterface() - describe('create/logic', () => { describe('createNewSafeViaRelayer', () => { const owner1 = toBeHex('0x1', 20) @@ -80,11 +76,9 @@ describe('create/logic', () => { const expectedSaltNonce = 69 const expectedThreshold = 1 - const proxyFactoryAddress = await (await getReadOnlyProxyFactoryContract(latestSafeVersion)).getAddress() + const proxyFactoryAddress = (await getReadOnlyProxyFactoryContract(latestSafeVersion)).getAddress() const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(latestSafeVersion) - const safeContractAddress = await ( - await getReadOnlyGnosisSafeContract(mockChainInfo, latestSafeVersion) - ).getAddress() + const safeContractAddress = (await getReadOnlyGnosisSafeContract(mockChainInfo, latestSafeVersion)).getAddress() const undeployedSafeProps: ReplayedSafeProps = { safeAccountConfig: { @@ -92,7 +86,7 @@ describe('create/logic', () => { threshold: 1, data: EMPTY_DATA, to: ZERO_ADDRESS, - fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), + fallbackHandler: readOnlyFallbackHandlerContract.getAddress(), paymentReceiver: ZERO_ADDRESS, payment: 0, paymentToken: ZERO_ADDRESS, @@ -108,7 +102,7 @@ describe('create/logic', () => { expectedThreshold, ZERO_ADDRESS, EMPTY_DATA, - await readOnlyFallbackHandlerContract.getAddress(), + readOnlyFallbackHandlerContract.getAddress(), ZERO_ADDRESS, 0, ZERO_ADDRESS, @@ -289,23 +283,24 @@ describe('create/logic', () => { owners: [faker.finance.ethereumAddress()], threshold: 1, } + const chainSetup = chainBuilder() + .with({ chainId: '137' }) + // Multichain creation is toggled on + .with({ features: [FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ recommendedMasterCopyVersion: '1.4.1' }) + .with({ l2: true }) + .build() + const safeL2SingletonDeployment = getSafeL2SingletonDeployment({ version: '1.4.1', network: '137', })?.defaultAddress - expect( - createNewUndeployedSafeWithoutSalt( - '1.4.1', - safeSetup, - chainBuilder() - .with({ chainId: '137' }) - // Multichain creation is toggled on - .with({ features: [FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) - .with({ recommendedMasterCopyVersion: '1.4.1' }) - .with({ l2: true }) - .build(), - ), - ).toEqual({ + + const safeToL2SetupDeployment = getSafeToL2SetupDeployment({ version: '1.4.1', network: chainSetup.chainId }) + const safeToL2SetupAddress = safeToL2SetupDeployment?.networkAddresses[chainSetup.chainId] + const safeToL2SetupInterface = Safe_to_l2_setup__factory.createInterface() + + expect(createNewUndeployedSafeWithoutSalt('1.4.1', safeSetup, chainSetup)).toEqual({ safeAccountConfig: { ...safeSetup, fallbackHandler: getFallbackHandlerDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, diff --git a/apps/web/src/components/new-safe/create/logic/index.ts b/apps/web/src/components/new-safe/create/logic/index.ts index 0361d80caf..b67ed08922 100644 --- a/apps/web/src/components/new-safe/create/logic/index.ts +++ b/apps/web/src/components/new-safe/create/logic/index.ts @@ -1,5 +1,5 @@ -import type { SafeVersion } from '@safe-global/safe-core-sdk-types' -import { type Eip1193Provider, type Provider } from 'ethers' +import type { SafeVersion, TransactionOptions } from '@safe-global/types-kit' +import { type TransactionResponse, type Eip1193Provider, type Provider } from 'ethers' import semverSatisfies from 'semver/functions/satisfies' import { getSafeInfo, type SafeInfo, type ChainInfo, relayTransaction } from '@safe-global/safe-gateway-typescript-sdk' @@ -7,13 +7,11 @@ import { getReadOnlyProxyFactoryContract } from '@/services/contracts/safeContra import type { UrlObject } from 'url' import { AppRoutes } from '@/config/routes' import { SAFE_APPS_EVENTS, trackEvent } from '@/services/analytics' -import { predictSafeAddress, SafeFactory, SafeProvider } from '@safe-global/protocol-kit' -import type { DeploySafeProps, PredictedSafeProps } from '@safe-global/protocol-kit' -import { isValidSafeVersion } from '@/hooks/coreSDK/safeCoreSDK' +import Safe, { predictSafeAddress, SafeProvider } from '@safe-global/protocol-kit' +import type { PredictedSafeProps } from '@safe-global/protocol-kit' import { backOff } from 'exponential-backoff' import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' -import { getLatestSafeVersion } from '@/utils/chains' import { getCompatibilityFallbackHandlerDeployment, getProxyFactoryDeployment, @@ -22,12 +20,17 @@ import { getSafeToL2SetupDeployment, } from '@safe-global/safe-deployments' import { ECOSYSTEM_ID_ADDRESS } from '@/config/constants' -import type { ReplayedSafeProps, UndeployedSafeProps } from '@/store/slices' +import type { ReplayedSafeProps, UndeployedSafeProps } from '@safe-global/utils/features/counterfactual/store/types' import { activateReplayedSafe, isPredictedSafeProps } from '@/features/counterfactual/utils' -import { getSafeContractDeployment } from '@/services/contracts/deployments' -import { Safe__factory, Safe_proxy_factory__factory, Safe_to_l2_setup__factory } from '@/types/contracts' +import { getSafeContractDeployment } from '@safe-global/utils/services/contracts/deployments' +import { + Safe__factory, + Safe_proxy_factory__factory, + Safe_to_l2_setup__factory, +} from '@safe-global/utils/types/contracts' import { createWeb3 } from '@/hooks/wallets/web3' import { hasMultiChainCreationFeatures } from '@/features/multichain/utils/utils' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' export type SafeCreationProps = { owners: string[] @@ -35,37 +38,37 @@ export type SafeCreationProps = { saltNonce: number } -const getSafeFactory = async ( - provider: Eip1193Provider, - safeVersion: SafeVersion, - isL1SafeSingleton?: boolean, -): Promise<SafeFactory> => { - if (!isValidSafeVersion(safeVersion)) { - throw new Error('Invalid Safe version') - } - return SafeFactory.init({ provider, safeVersion, isL1SafeSingleton }) -} - /** * Create a Safe creation transaction via Core SDK and submits it to the wallet */ export const createNewSafe = async ( provider: Eip1193Provider, undeployedSafeProps: UndeployedSafeProps, - safeVersion: SafeVersion, chain: ChainInfo, - options: DeploySafeProps['options'], + options: TransactionOptions, callback: (txHash: string) => void, isL1SafeSingleton?: boolean, ): Promise<void> => { - const safeFactory = await getSafeFactory(provider, safeVersion, isL1SafeSingleton) - + let txResponse: TransactionResponse if (isPredictedSafeProps(undeployedSafeProps)) { - await safeFactory.deploySafe({ ...undeployedSafeProps, options, callback }) + const safe = await Safe.init({ + predictedSafe: undeployedSafeProps, + provider, + isL1SafeSingleton, + }) + + const creationTx = await safe.createSafeDeploymentTransaction() + + const signer = await createWeb3(provider).getSigner() + + txResponse = await signer?.sendTransaction({ + ...creationTx, + ...options, + }) } else { - const txResponse = await activateReplayedSafe(chain, undeployedSafeProps, createWeb3(provider), options) - callback(txResponse.hash) + txResponse = await activateReplayedSafe(chain, undeployedSafeProps, createWeb3(provider), options) } + callback(txResponse.hash) } /** @@ -73,9 +76,8 @@ export const createNewSafe = async ( */ export const computeNewSafeAddress = async ( provider: Eip1193Provider | string, - props: DeploySafeProps, + props: PredictedSafeProps, chain: ChainInfo, - safeVersion?: SafeVersion, ): Promise<string> => { const safeProvider = new SafeProvider({ provider }) @@ -83,10 +85,7 @@ export const computeNewSafeAddress = async ( safeProvider, chainId: BigInt(chain.chainId), safeAccountConfig: props.safeAccountConfig, - safeDeploymentConfig: { - saltNonce: props.saltNonce, - safeVersion: safeVersion ?? getLatestSafeVersion(chain), - }, + safeDeploymentConfig: props.safeDeploymentConfig, }) } @@ -129,7 +128,7 @@ export const estimateSafeCreationGas = async ( const gas = await provider.estimateGas({ from, - to: await readOnlyProxyFactoryContract.getAddress(), + to: readOnlyProxyFactoryContract.getAddress(), data: encodedSafeCreationTx, }) diff --git a/apps/web/src/components/new-safe/create/logic/utils.test.ts b/apps/web/src/components/new-safe/create/logic/utils.test.ts index 94818a43bc..a16e17ff9c 100644 --- a/apps/web/src/components/new-safe/create/logic/utils.test.ts +++ b/apps/web/src/components/new-safe/create/logic/utils.test.ts @@ -2,11 +2,11 @@ import * as creationUtils from '@/components/new-safe/create/logic/index' import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils' import { faker } from '@faker-js/faker' import { chainBuilder } from '@/tests/builders/chains' -import { type ReplayedSafeProps } from '@/store/slices' +import { type ReplayedSafeProps } from '@safe-global/utils/features/counterfactual/store/types' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import * as web3Hooks from '@/hooks/wallets/web3' import { type JsonRpcProvider, id } from 'ethers' -import { Safe_proxy_factory__factory } from '@/types/contracts' +import { Safe_proxy_factory__factory } from '@safe-global/utils/types/contracts' import { predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils' // Proxy Factory 1.3.0 creation code diff --git a/apps/web/src/components/new-safe/create/logic/utils.ts b/apps/web/src/components/new-safe/create/logic/utils.ts index 68b5993aee..91c27acd2b 100644 --- a/apps/web/src/components/new-safe/create/logic/utils.ts +++ b/apps/web/src/components/new-safe/create/logic/utils.ts @@ -1,8 +1,8 @@ import { isSmartContract } from '@/utils/wallets' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { createWeb3ReadOnly, getRpcServiceUrl } from '@/hooks/wallets/web3' -import { type ReplayedSafeProps } from '@/store/slices' +import { type ReplayedSafeProps } from '@safe-global/utils/features/counterfactual/store/types' import { predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils' import chains from '@/config/chains' import { computeNewSafeAddress } from '.' @@ -36,16 +36,19 @@ export const getAvailableSaltNonce = async ( throw new Error('Could not initiate RPC') } let safeAddress: string - if (chain.chainId === chains['zksync']) { + // FIXME a new check to indicate ZKsync chain will be added to the config service and available under ChainInfo + if (chain.chainId === chains['zksync'] || chain.chainId === chains['lens']) { // ZK-sync is using a different create2 method which is supported by the SDK safeAddress = await computeNewSafeAddress( rpcUrl, { safeAccountConfig: replayedSafe.safeAccountConfig, - saltNonce: replayedSafe.saltNonce, + safeDeploymentConfig: { + saltNonce: replayedSafe.saltNonce, + safeVersion: replayedSafe.safeVersion, + }, }, chain, - replayedSafe.safeVersion, ) } else { safeAddress = await predictAddressBasedOnReplayData(replayedSafe, web3ReadOnly) diff --git a/apps/web/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx b/apps/web/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx index 9c8dbc35b6..6d81ff6975 100644 --- a/apps/web/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx +++ b/apps/web/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx @@ -9,10 +9,10 @@ import type { NewSafeFormData } from '@/components/new-safe/create' import useSyncSafeCreationStep from '@/components/new-safe/create/useSyncSafeCreationStep' import ArrowBackIcon from '@mui/icons-material/ArrowBack' import layoutCss from '@/components/new-safe/create/styles.module.css' -import { type SafeVersion } from '@safe-global/safe-core-sdk-types' +import { type SafeVersion } from '@safe-global/types-kit' import NumberField from '@/components/common/NumberField' import { useCurrentChain } from '@/hooks/useChains' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { createNewUndeployedSafeWithoutSalt } from '../../logic' import EthHashInfo from '@/components/common/EthHashInfo' import InfoIcon from '@/public/images/notifications/info.svg' diff --git a/apps/web/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx b/apps/web/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx index 97754f3e80..4c48d82bf6 100644 --- a/apps/web/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx +++ b/apps/web/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx @@ -16,7 +16,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack' import layoutCss from '@/components/new-safe/create/styles.module.css' import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' import OwnerRow from '@/components/new-safe/OwnerRow' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' enum OwnerPolicyStepFields { owners = 'owners', @@ -110,7 +110,7 @@ const OwnerPolicyStep = ({ /> ))} <Button - data-testid="add-owner-btn" + data-testid="add-new-signer" variant="text" onClick={() => appendOwner({ name: '', address: '' }, { shouldFocus: true })} startIcon={<SvgIcon component={AddIcon} inheritViewBox fontSize="small" />} diff --git a/apps/web/src/components/new-safe/create/steps/ReviewStep/index.test.tsx b/apps/web/src/components/new-safe/create/steps/ReviewStep/index.test.tsx index a677f08cb6..0d41ce868c 100644 --- a/apps/web/src/components/new-safe/create/steps/ReviewStep/index.test.tsx +++ b/apps/web/src/components/new-safe/create/steps/ReviewStep/index.test.tsx @@ -8,8 +8,8 @@ import ReviewStep, { NetworkFee } from '@/components/new-safe/create/steps/Revie import * as useWallet from '@/hooks/wallets/useWallet' import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' import { act, fireEvent, screen } from '@testing-library/react' -import { LATEST_SAFE_VERSION } from '@/config/constants' -import { type SafeVersion } from '@safe-global/safe-core-sdk-types' +import { LATEST_SAFE_VERSION } from '@safe-global/utils/config/constants' +import { type SafeVersion } from '@safe-global/types-kit' const mockChainInfo = { chainId: '100', diff --git a/apps/web/src/components/new-safe/create/steps/ReviewStep/index.tsx b/apps/web/src/components/new-safe/create/steps/ReviewStep/index.tsx index 1e388ad0ce..0e800137c3 100644 --- a/apps/web/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/apps/web/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -19,7 +19,7 @@ import useSyncSafeCreationStep from '@/components/new-safe/create/useSyncSafeCre import ReviewRow from '@/components/new-safe/ReviewRow' import ErrorMessage from '@/components/tx/ErrorMessage' import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector' -import PayNowPayLater, { PayMethod } from '@/features/counterfactual/PayNowPayLater' +import PayNowPayLater from '@/features/counterfactual/PayNowPayLater' import { CF_TX_GROUP_KEY, replayCounterfactualSafeDeployment } from '@/features/counterfactual/utils' import { useCurrentChain, useHasFeature } from '@/hooks/useChains' import useGasPrice from '@/hooks/useGasPrice' @@ -29,9 +29,8 @@ import useWalletCanPay from '@/hooks/useWalletCanPay' import useWallet from '@/hooks/wallets/useWallet' import { CREATE_SAFE_CATEGORY, CREATE_SAFE_EVENTS, OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import { gtmSetChainId, gtmSetSafeAddress } from '@/services/analytics/gtm' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { useAppDispatch, useAppSelector } from '@/store' -import { FEATURES, hasFeature } from '@/utils/chains' import { hasRemainingRelays } from '@/utils/relaying' import { isWalletRejection } from '@/utils/wallets' import ArrowBackIcon from '@mui/icons-material/ArrowBack' @@ -46,12 +45,14 @@ import useAllSafes from '@/features/myAccounts/hooks/useAllSafes' import uniq from 'lodash/uniq' import { selectRpc } from '@/store/settingsSlice' import { AppRoutes } from '@/config/routes' -import { type ReplayedSafeProps } from '@/store/slices' +import { type ReplayedSafeProps } from '@safe-global/utils/features/counterfactual/store/types' import { predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils' import { createWeb3ReadOnly, getRpcServiceUrl } from '@/hooks/wallets/web3' -import { type DeploySafeProps } from '@safe-global/protocol-kit' import { updateAddressBook } from '../../logic/address-book' import chains from '@/config/chains' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' +import { PayMethod } from '@safe-global/utils/features/counterfactual/types' +import { type TransactionOptions } from '@safe-global/types-kit' export const NetworkFee = ({ totalFee, @@ -240,15 +241,18 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps<NewSafe let safeAddress: string - if (chain.chainId === chains['zksync']) { + // FIXME a new check to indicate ZKsync chain will be added to the config service and available under ChainInfo + if (chain.chainId === chains['zksync'] || chain.chainId === chains['lens']) { safeAddress = await computeNewSafeAddress( customRpcUrl || getRpcServiceUrl(chain.rpcUri), { safeAccountConfig: replayedSafeWithNonce.safeAccountConfig, - saltNonce: nextAvailableNonce, + safeDeploymentConfig: { + saltNonce: nextAvailableNonce, + safeVersion: replayedSafeWithNonce.safeVersion, + }, }, chain, - replayedSafeWithNonce.safeVersion, ) } else { safeAddress = await predictAddressBasedOnReplayData(replayedSafeWithNonce, provider) @@ -305,7 +309,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps<NewSafe return } - const options: DeploySafeProps['options'] = isEIP1559 + const options: TransactionOptions = isEIP1559 ? { maxFeePerGas: maxFeePerGas?.toString(), maxPriorityFeePerGas: maxPriorityFeePerGas?.toString(), @@ -342,7 +346,6 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps<NewSafe await createNewSafe( wallet.provider, props, - data.safeVersion, chain, options, (txHash) => { diff --git a/apps/web/src/components/new-safe/create/steps/SetNameStep/index.tsx b/apps/web/src/components/new-safe/create/steps/SetNameStep/index.tsx index 9c33c0b454..25f8ad1ee0 100644 --- a/apps/web/src/components/new-safe/create/steps/SetNameStep/index.tsx +++ b/apps/web/src/components/new-safe/create/steps/SetNameStep/index.tsx @@ -11,10 +11,9 @@ import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' import { AppRoutes } from '@/config/routes' import { useRouter } from 'next/router' import NoWalletConnectedWarning from '../../NoWalletConnectedWarning' -import { type SafeVersion } from '@safe-global/safe-core-sdk-types' +import { type SafeVersion } from '@safe-global/types-kit' import { useCurrentChain } from '@/hooks/useChains' import { useEffect } from 'react' -import { getLatestSafeVersion } from '@/utils/chains' import Link from 'next/link' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { useSafeSetupHints } from '../OwnerPolicyStep/useSafeSetupHints' @@ -23,6 +22,7 @@ import NetworkMultiSelector from '@/components/common/NetworkSelector/NetworkMul import { useAppSelector } from '@/store' import { selectChainById } from '@/store/chainsSlice' import useWallet from '@/hooks/wallets/useWallet' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' type SetNameStepForm = { name: string diff --git a/apps/web/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx b/apps/web/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx index bf681f515d..6b7906907a 100644 --- a/apps/web/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx +++ b/apps/web/src/components/new-safe/create/steps/StatusStep/StatusMessage.tsx @@ -1,11 +1,11 @@ import ExternalLink from '@/components/common/ExternalLink' import LoadingSpinner, { SpinnerStatus } from '@/components/new-safe/create/steps/StatusStep/LoadingSpinner' import { SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' -import type { UndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' import { useCurrentChain } from '@/hooks/useChains' -import { getBlockExplorerLink } from '@/utils/chains' +import { getBlockExplorerLink } from '@safe-global/utils/utils/chains' import { Box, Typography } from '@mui/material' import FailedIcon from '@/public/images/common/tx-failed.svg' +import type { UndeployedSafe } from '@safe-global/utils/features/counterfactual/store/types' const getStep = (status: SafeCreationEvent) => { switch (status) { diff --git a/apps/web/src/components/new-safe/create/steps/StatusStep/index.tsx b/apps/web/src/components/new-safe/create/steps/StatusStep/index.tsx index 14e8e37ea9..9c95ac5bd6 100644 --- a/apps/web/src/components/new-safe/create/steps/StatusStep/index.tsx +++ b/apps/web/src/components/new-safe/create/steps/StatusStep/index.tsx @@ -16,8 +16,8 @@ import { Alert, AlertTitle, Box, Button, Paper, Stack, SvgIcon, Typography } fro import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' -import { getLatestSafeVersion } from '@/utils/chains' import { isPredictedSafeProps } from '@/features/counterfactual/utils' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' const SPEED_UP_THRESHOLD_IN_SECONDS = 15 diff --git a/apps/web/src/components/new-safe/create/steps/StatusStep/useUndeployedSafe.ts b/apps/web/src/components/new-safe/create/steps/StatusStep/useUndeployedSafe.ts index 029019d86e..2083629f4c 100644 --- a/apps/web/src/components/new-safe/create/steps/StatusStep/useUndeployedSafe.ts +++ b/apps/web/src/components/new-safe/create/steps/StatusStep/useUndeployedSafe.ts @@ -1,7 +1,7 @@ -import { PayMethod } from '@/features/counterfactual/PayNowPayLater' import { selectUndeployedSafes } from '@/features/counterfactual/store/undeployedSafesSlice' import useChainId from '@/hooks/useChainId' import { useAppSelector } from '@/store' +import { PayMethod } from '@safe-global/utils/features/counterfactual/types' // Returns the undeployed safe for the current network const useUndeployedSafe = () => { diff --git a/apps/web/src/components/new-safe/create/useEstimateSafeCreationGas.ts b/apps/web/src/components/new-safe/create/useEstimateSafeCreationGas.ts index 4adcef1f5b..68a9d555d4 100644 --- a/apps/web/src/components/new-safe/create/useEstimateSafeCreationGas.ts +++ b/apps/web/src/components/new-safe/create/useEstimateSafeCreationGas.ts @@ -1,10 +1,10 @@ import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import useWallet from '@/hooks/wallets/useWallet' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { useCurrentChain } from '@/hooks/useChains' import { estimateSafeCreationGas } from '@/components/new-safe/create/logic' -import { type SafeVersion } from '@safe-global/safe-core-sdk-types' -import { type UndeployedSafeProps } from '@/store/slices' +import { type SafeVersion } from '@safe-global/types-kit' +import { type UndeployedSafeProps } from '@safe-global/utils/features/counterfactual/store/types' export const useEstimateSafeCreationGas = ( undeployedSafe: UndeployedSafeProps | undefined, diff --git a/apps/web/src/components/new-safe/load/steps/SafeOwnerStep/index.tsx b/apps/web/src/components/new-safe/load/steps/SafeOwnerStep/index.tsx index c59bcbb332..4c98a5c1ae 100644 --- a/apps/web/src/components/new-safe/load/steps/SafeOwnerStep/index.tsx +++ b/apps/web/src/components/new-safe/load/steps/SafeOwnerStep/index.tsx @@ -5,7 +5,7 @@ import { Box, Button, Divider } from '@mui/material' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' import type { LoadSafeFormData } from '@/components/new-safe/load' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useChainId from '@/hooks/useChainId' import type { NamedAddress } from '@/components/new-safe/create/types' import layoutCss from '@/components/new-safe/create/styles.module.css' diff --git a/apps/web/src/components/new-safe/load/steps/SafeReviewStep/index.tsx b/apps/web/src/components/new-safe/load/steps/SafeReviewStep/index.tsx index cd44eb6d41..4c79a27345 100644 --- a/apps/web/src/components/new-safe/load/steps/SafeReviewStep/index.tsx +++ b/apps/web/src/components/new-safe/load/steps/SafeReviewStep/index.tsx @@ -12,12 +12,12 @@ import { useCurrentChain } from '@/hooks/useChains' import { useAppDispatch } from '@/store' import { useRouter } from 'next/router' import { addOrUpdateSafe } from '@/store/addedSafesSlice' -import { defaultSafeInfo } from '@/store/safeInfoSlice' +import { defaultSafeInfo } from '@safe-global/store/slices/SafeInfo/utils' import { LOAD_SAFE_EVENTS, OPEN_SAFE_LABELS, OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import { AppRoutes } from '@/config/routes' import ReviewRow from '@/components/new-safe/ReviewRow' import { upsertAddressBookEntries } from '@/store/addressBookSlice' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' const SafeReviewStep = ({ data, onBack }: StepRenderProps<LoadSafeFormData>) => { const chain = useCurrentChain() diff --git a/apps/web/src/components/nfts/NftSendForm/index.tsx b/apps/web/src/components/nfts/NftSendForm/index.tsx index 819f188493..eca21e570c 100644 --- a/apps/web/src/components/nfts/NftSendForm/index.tsx +++ b/apps/web/src/components/nfts/NftSendForm/index.tsx @@ -4,7 +4,7 @@ import ArrowIcon from '@/public/images/common/arrow-nw.svg' import type { SafeCollectibleResponse } from '@safe-global/safe-gateway-typescript-sdk' import { Sticky } from '@/components/common/Sticky' import CheckWallet from '@/components/common/CheckWallet' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' type NftSendFormProps = { selectedNfts: SafeCollectibleResponse[] diff --git a/apps/web/src/components/notification-center/NotificationCenter/index.tsx b/apps/web/src/components/notification-center/NotificationCenter/index.tsx index 893a46ec37..4c93a9f2fb 100644 --- a/apps/web/src/components/notification-center/NotificationCenter/index.tsx +++ b/apps/web/src/components/notification-center/NotificationCenter/index.tsx @@ -26,8 +26,8 @@ import css from './styles.module.css' import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' import SvgIcon from '@mui/icons-material/ExpandLess' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' import { useShowNotificationsRenewalMessage } from '@/components/settings/PushNotifications/hooks/useShowNotificationsRenewalMessage' +import { FEATURES } from '@safe-global/utils/utils/chains' const NOTIFICATION_CENTER_LIMIT = 4 @@ -139,7 +139,7 @@ const NotificationCenter = (): ReactElement => { <Paper className={css.popoverContainer}> <div className={css.popoverHeader}> <div> - <Typography variant="h4" component="span" fontWeight={700}> + <Typography data-testid="notifications-title" variant="h4" component="span" fontWeight={700}> Notifications </Typography> {hasUnread && ( @@ -188,7 +188,12 @@ const NotificationCenter = (): ReactElement => { passHref legacyBehavior > - <MuiLink className={css.settingsLink} variant="body2" onClick={onSettingsClick}> + <MuiLink + data-testid="notifications-button" + className={css.settingsLink} + variant="body2" + onClick={onSettingsClick} + > <SvgIcon component={SettingsIcon} inheritViewBox fontSize="small" /> Push notifications settings </MuiLink> </Link> diff --git a/apps/web/src/components/notification-center/NotificationCenterItem/index.tsx b/apps/web/src/components/notification-center/NotificationCenterItem/index.tsx index 8fb0e70a78..ffcb8069a6 100644 --- a/apps/web/src/components/notification-center/NotificationCenterItem/index.tsx +++ b/apps/web/src/components/notification-center/NotificationCenterItem/index.tsx @@ -11,7 +11,7 @@ import type { ReactElement } from 'react' import type { Notification } from '@/store/notificationsSlice' import UnreadBadge from '@/components/common/UnreadBadge' -import { formatTimeInWords } from '@/utils/date' +import { formatTimeInWords } from '@safe-global/utils/utils/date' import css from './styles.module.css' import classnames from 'classnames' diff --git a/apps/web/src/components/notification-center/NotificationCenterList/index.tsx b/apps/web/src/components/notification-center/NotificationCenterList/index.tsx index a1f5875a30..9f8c4636b3 100644 --- a/apps/web/src/components/notification-center/NotificationCenterList/index.tsx +++ b/apps/web/src/components/notification-center/NotificationCenterList/index.tsx @@ -18,7 +18,7 @@ const NotificationCenterList = ({ notifications, handleClose }: NotificationCent if (!notifications.length) { return ( <div className={css.wrapper}> - <NoNotificationsIcon alt="No notifications" /> + <NoNotificationsIcon data-testid="notifications-icon" alt="No notifications" /> <Typography sx={{ paddingTop: '8px', diff --git a/apps/web/src/components/notification-center/NotificationRenewal/index.tsx b/apps/web/src/components/notification-center/NotificationRenewal/index.tsx index 3cfc0e6b22..e8c824af18 100644 --- a/apps/web/src/components/notification-center/NotificationRenewal/index.tsx +++ b/apps/web/src/components/notification-center/NotificationRenewal/index.tsx @@ -5,7 +5,7 @@ import CheckWalletWithPermission from '@/components/common/CheckWalletWithPermis import { useNotificationsRenewal } from '@/components/settings/PushNotifications/hooks/useNotificationsRenewal' import { useIsNotificationsRenewalEnabled } from '@/components/settings/PushNotifications/hooks/useNotificationsTokenVersion' import { RENEWAL_MESSAGE } from '@/components/settings/PushNotifications/constants' -import { Permission } from '@/permissions/types' +import { Permission } from '@/permissions/config' const NotificationRenewal = (): ReactElement => { const { safe } = useSafeInfo() diff --git a/apps/web/src/components/safe-apps/AddCustomAppModal/index.tsx b/apps/web/src/components/safe-apps/AddCustomAppModal/index.tsx index a343fc98a5..eb8ebb233e 100644 --- a/apps/web/src/components/safe-apps/AddCustomAppModal/index.tsx +++ b/apps/web/src/components/safe-apps/AddCustomAppModal/index.tsx @@ -16,9 +16,9 @@ import CheckIcon from '@mui/icons-material/Check' import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' import ModalDialog from '@/components/common/ModalDialog' -import { isValidURL } from '@/utils/validation' +import { isValidURL } from '@safe-global/utils/utils/validation' import { useCurrentChain } from '@/hooks/useChains' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useDebounce from '@/hooks/useDebounce' import { fetchSafeAppFromManifest } from '@/services/safe-apps/manifest' import { SAFE_APPS_EVENTS, trackSafeAppEvent } from '@/services/analytics' diff --git a/apps/web/src/components/safe-apps/AppFrame/SafeAppIframe.tsx b/apps/web/src/components/safe-apps/AppFrame/SafeAppIframe.tsx index b6eaf46017..5271086521 100644 --- a/apps/web/src/components/safe-apps/AppFrame/SafeAppIframe.tsx +++ b/apps/web/src/components/safe-apps/AppFrame/SafeAppIframe.tsx @@ -1,5 +1,7 @@ import type { MutableRefObject, ReactElement } from 'react' +import type { SafeAppDataWithPermissions } from '@/components/safe-apps/types' import css from './styles.module.css' +import { sanitizeUrl } from '@/utils/url' type SafeAppIFrameProps = { appUrl: string @@ -7,19 +9,43 @@ type SafeAppIFrameProps = { title?: string iframeRef?: MutableRefObject<HTMLIFrameElement | null> onLoad?: () => void + safeApp?: SafeAppDataWithPermissions } // see sandbox mdn docs for more details https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox const IFRAME_SANDBOX_ALLOWED_FEATURES = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms allow-downloads allow-orientation-lock' -const SafeAppIframe = ({ appUrl, allowedFeaturesList, iframeRef, onLoad, title }: SafeAppIFrameProps): ReactElement => { +const SafeAppIframe = ({ + appUrl, + allowedFeaturesList, + iframeRef, + onLoad, + title, + safeApp, +}: SafeAppIFrameProps): ReactElement => { + // Use the original URL with parameters if available, otherwise fallback to the provided URL + const safeAppUrl = safeApp?.originalUrl || appUrl + + // Ensure the URL is valid and sanitized + const isValidUrl = (url: string): boolean => { + try { + const parsedUrl = new URL(url) + return ['http:', 'https:'].includes(parsedUrl.protocol) + } catch { + return false + } + } + + const sanitizedSafeAppUrl = isValidUrl(safeAppUrl) ? sanitizeUrl(safeAppUrl) : '' + const encodedAppUrl = encodeURIComponent(appUrl) + return ( <iframe className={css.iframe} - id={`iframe-${appUrl}`} + id={`iframe-${encodedAppUrl}`} ref={iframeRef} - src={appUrl} + src={sanitizedSafeAppUrl} title={title} onLoad={onLoad} sandbox={IFRAME_SANDBOX_ALLOWED_FEATURES} diff --git a/apps/web/src/components/safe-apps/AppFrame/ThirdPartyCookiesWarning.tsx b/apps/web/src/components/safe-apps/AppFrame/ThirdPartyCookiesWarning.tsx index 3e195771d0..adcc308ca6 100644 --- a/apps/web/src/components/safe-apps/AppFrame/ThirdPartyCookiesWarning.tsx +++ b/apps/web/src/components/safe-apps/AppFrame/ThirdPartyCookiesWarning.tsx @@ -1,7 +1,8 @@ import React from 'react' import { Alert, AlertTitle } from '@mui/material' import ExternalLink from '@/components/common/ExternalLink' -import { HelpCenterArticle } from '@/config/constants' + +import { HelpCenterArticle } from '@safe-global/utils/config/constants' type ThirdPartyCookiesWarningProps = { onClose: () => void diff --git a/apps/web/src/components/safe-apps/AppFrame/__tests__/AppFrame.test.tsx b/apps/web/src/components/safe-apps/AppFrame/__tests__/AppFrame.test.tsx index 584485a9ad..bb11988b44 100644 --- a/apps/web/src/components/safe-apps/AppFrame/__tests__/AppFrame.test.tsx +++ b/apps/web/src/components/safe-apps/AppFrame/__tests__/AppFrame.test.tsx @@ -8,7 +8,7 @@ import { TransactionListItemType, TransactionStatus, } from '@safe-global/safe-gateway-typescript-sdk' -import { defaultSafeInfo } from '@/store/safeInfoSlice' +import { defaultSafeInfo } from '@safe-global/store/slices/SafeInfo/utils' import { getEmptySafeApp } from '@/components/safe-apps/utils' const emptySafeApp = getEmptySafeApp() diff --git a/apps/web/src/components/safe-apps/AppFrame/index.tsx b/apps/web/src/components/safe-apps/AppFrame/index.tsx index ec6494ec91..92e16c089c 100644 --- a/apps/web/src/components/safe-apps/AppFrame/index.tsx +++ b/apps/web/src/components/safe-apps/AppFrame/index.tsx @@ -55,7 +55,7 @@ const AppFrame = ({ appUrl, allowedFeaturesList, safeAppFromManifest, isNativeEm dismissQueueBar, transactions, } = useTransactionQueueBarState() - const queueBarVisible = transactions.results.length > 0 && !queueBarDismissed + const queueBarVisible = transactions.results.length > 0 && !queueBarDismissed && !isNativeEmbed const [remoteApp] = useSafeAppFromBackend(appUrl, safe.chainId) const { thirdPartyCookiesDisabled, setThirdPartyCookiesDisabled } = useThirdPartyCookies() const { iframeRef, appIsLoading, isLoadingSlow, setAppIsLoading } = useAppIsLoading() diff --git a/apps/web/src/components/safe-apps/AppFrame/useAppCommunicator.ts b/apps/web/src/components/safe-apps/AppFrame/useAppCommunicator.ts index 3da1ed16b6..b7e8cbc4d2 100644 --- a/apps/web/src/components/safe-apps/AppFrame/useAppCommunicator.ts +++ b/apps/web/src/components/safe-apps/AppFrame/useAppCommunicator.ts @@ -20,7 +20,6 @@ import type { SignMessageParams, SignTypedMessageParams, ChainInfo, - SafeBalances, SafeInfoExtended, } from '@safe-global/safe-apps-sdk' import { Methods, RPC_CALLS } from '@safe-global/safe-apps-sdk' @@ -34,6 +33,7 @@ import { useAppSelector } from '@/store' import { selectRpc } from '@/store/settingsSlice' import { createSafeAppsWeb3Provider } from '@/hooks/wallets/web3' import { useDarkMode } from '@/hooks/useDarkMode' +import type { Balances } from '@safe-global/store/gateway/AUTO_GENERATED/balances' export enum CommunicatorMessages { REJECT_TRANSACTION_MESSAGE = 'Transaction was rejected', @@ -56,7 +56,7 @@ export type UseAppCommunicatorHandlers = { ) => void onGetTxBySafeTxHash: (transactionId: string) => Promise<TransactionDetails> onGetEnvironmentInfo: () => EnvironmentInfo - onGetSafeBalances: (currency: string) => Promise<SafeBalances> + onGetSafeBalances: (currency: string) => Promise<Balances> onGetSafeInfo: () => SafeInfoExtended onGetChainInfo: () => ChainInfo | undefined onGetPermissions: (origin: string) => Permission[] diff --git a/apps/web/src/components/safe-apps/AppFrame/useGetSafeInfo.ts b/apps/web/src/components/safe-apps/AppFrame/useGetSafeInfo.ts index 62ddada412..5d279cbddd 100644 --- a/apps/web/src/components/safe-apps/AppFrame/useGetSafeInfo.ts +++ b/apps/web/src/components/safe-apps/AppFrame/useGetSafeInfo.ts @@ -26,7 +26,7 @@ const useGetSafeInfo = () => { modules: safe.modules ? safe.modules.map((module) => module.value) : null, fallbackHandler: safe.fallbackHandler ? safe.fallbackHandler?.value : null, guard: safe.guard?.value || null, - version: safe.version, + version: safe.version || null, network: getLegacyChainName(chainName || '', chainId).toUpperCase(), } }, [ diff --git a/apps/web/src/components/safe-apps/SafeAppCard/index.tsx b/apps/web/src/components/safe-apps/SafeAppCard/index.tsx index 35876a469c..1585771eb1 100644 --- a/apps/web/src/components/safe-apps/SafeAppCard/index.tsx +++ b/apps/web/src/components/safe-apps/SafeAppCard/index.tsx @@ -26,6 +26,7 @@ type SafeAppCardProps = { onBookmarkSafeApp?: (safeAppId: number) => void removeCustomApp?: (safeApp: SafeAppData) => void openPreviewDrawer?: (safeApp: SafeAppData) => void + compact?: boolean } const SafeAppCard = ({ @@ -35,6 +36,7 @@ const SafeAppCard = ({ onBookmarkSafeApp, removeCustomApp, openPreviewDrawer, + compact = false, }: SafeAppCardProps) => { const router = useRouter() @@ -49,6 +51,7 @@ const SafeAppCard = ({ removeCustomApp={removeCustomApp} onClickSafeApp={onClickSafeApp} openPreviewDrawer={openPreviewDrawer} + compact={compact} /> ) } @@ -64,14 +67,8 @@ export const getSafeAppUrl = (router: NextRouter, safeAppUrl: string) => { return resolveHref(router, shareUrlObj) } -type SafeAppCardViewProps = { - safeApp: SafeAppData - onClickSafeApp?: (e: SyntheticEvent) => void +type SafeAppCardViewProps = SafeAppCardProps & { safeAppUrl: string - isBookmarked?: boolean - onBookmarkSafeApp?: (safeAppId: number) => void - removeCustomApp?: (safeApp: SafeAppData) => void - openPreviewDrawer?: (safeApp: SafeAppData) => void } const SafeAppCardGridView = ({ @@ -82,9 +79,16 @@ const SafeAppCardGridView = ({ onBookmarkSafeApp, removeCustomApp, openPreviewDrawer, + compact, }: SafeAppCardViewProps) => { return ( - <SafeAppCardContainer safeAppUrl={safeAppUrl} onClickSafeApp={onClickSafeApp} height="100%"> + <SafeAppCardContainer + className={compact ? css.compactContainer : undefined} + safeAppUrl={safeAppUrl} + onClickSafeApp={onClickSafeApp} + height="100%" + compact={compact} + > {/* Safe App Header */} <CardHeader className={css.safeAppHeader} @@ -102,13 +106,15 @@ const SafeAppCardGridView = ({ action={ <> {/* Safe App Action Buttons */} - <SafeAppActionButtons - safeApp={safeApp} - isBookmarked={isBookmarked} - onBookmarkSafeApp={onBookmarkSafeApp} - removeCustomApp={removeCustomApp} - openPreviewDrawer={openPreviewDrawer} - /> + {!compact && ( + <SafeAppActionButtons + safeApp={safeApp} + isBookmarked={isBookmarked} + onBookmarkSafeApp={onBookmarkSafeApp} + removeCustomApp={removeCustomApp} + openPreviewDrawer={openPreviewDrawer} + /> + )} </> } /> @@ -120,12 +126,14 @@ const SafeAppCardGridView = ({ </Typography> {/* Safe App Description */} - <Typography className={css.safeAppDescription} variant="body2" color="text.secondary"> - {safeApp.description} - </Typography> + {!compact && ( + <Typography className={css.safeAppDescription} variant="body2" color="text.secondary"> + {safeApp.description} + </Typography> + )} {/* Safe App Tags */} - <SafeAppTags tags={safeApp.tags} /> + <SafeAppTags tags={safeApp.tags} compact={compact} /> </CardContent> </SafeAppCardContainer> ) @@ -137,6 +145,7 @@ type SafeAppCardContainerProps = { children: ReactNode height?: string className?: string + compact?: boolean } export const SafeAppCardContainer = ({ diff --git a/apps/web/src/components/safe-apps/SafeAppCard/styles.module.css b/apps/web/src/components/safe-apps/SafeAppCard/styles.module.css index e693fc35df..de3fc0e4be 100644 --- a/apps/web/src/components/safe-apps/SafeAppCard/styles.module.css +++ b/apps/web/src/components/safe-apps/SafeAppCard/styles.module.css @@ -10,12 +10,22 @@ border: 1px solid var(--color-secondary-light); } +.compactContainer { + transition: none; + border: 1px solid var(--color-border-light); +} + +.compactContainer:hover { + background-color: var(--color-background-main); + border: 1px solid var(--color-border-light); +} + .safeAppHeader { padding: var(--space-3) var(--space-2) 0 var(--space-2); } .safeAppContent { - padding: var(--space-2); + padding: var(--space-2) !important; } .safeAppIconContainer { diff --git a/apps/web/src/components/safe-apps/SafeAppLandingPage/AppActions.tsx b/apps/web/src/components/safe-apps/SafeAppLandingPage/AppActions.tsx index a4b30e5baa..26024e08a8 100644 --- a/apps/web/src/components/safe-apps/SafeAppLandingPage/AppActions.tsx +++ b/apps/web/src/components/safe-apps/SafeAppLandingPage/AppActions.tsx @@ -8,7 +8,7 @@ import { useAppSelector } from '@/store' import { selectAllAddressBooks } from '@/store/addressBookSlice' import { selectChains } from '@/store/chainsSlice' import useLastSafe from '@/hooks/useLastSafe' -import { parsePrefixedAddress } from '@/utils/addresses' +import { parsePrefixedAddress } from '@safe-global/utils/utils/addresses' import SafeIcon from '@/components/common/SafeIcon' import EthHashInfo from '@/components/common/EthHashInfo' import { AppRoutes } from '@/config/routes' diff --git a/apps/web/src/components/safe-apps/SafeAppLandingPage/index.tsx b/apps/web/src/components/safe-apps/SafeAppLandingPage/index.tsx index 76f21c0e6c..f4ecb1d390 100644 --- a/apps/web/src/components/safe-apps/SafeAppLandingPage/index.tsx +++ b/apps/web/src/components/safe-apps/SafeAppLandingPage/index.tsx @@ -41,7 +41,7 @@ const SafeAppLanding = ({ appUrl, chain }: Props) => { trackEvent(OVERVIEW_EVENTS.OPEN_ONBOARD) - onboard.connectWallet().catch((e) => logError(Errors._302, e)) + onboard.connectWallet().catch((e) => logError(Errors._107, e)) } const handleDemoClick = () => { diff --git a/apps/web/src/components/safe-apps/SafeAppList/index.tsx b/apps/web/src/components/safe-apps/SafeAppList/index.tsx index 62b86f65ac..195b565c3d 100644 --- a/apps/web/src/components/safe-apps/SafeAppList/index.tsx +++ b/apps/web/src/components/safe-apps/SafeAppList/index.tsx @@ -11,7 +11,15 @@ import css from './styles.module.css' import { Skeleton } from '@mui/material' import { useOpenedSafeApps } from '@/hooks/safe-apps/useOpenedSafeApps' import NativeSwapsCard from '@/components/safe-apps/NativeSwapsCard' -import { SAFE_APPS_EVENTS, SAFE_APPS_LABELS, trackSafeAppEvent } from '@/services/analytics' +import { + SAFE_APPS_EVENTS, + SAFE_APPS_LABELS, + trackSafeAppEvent, + trackMixPanelEvent, + MixPanelEvent, + safeAppToMixPanelEventProperties, + SafeAppLaunchLocation, +} from '@/services/analytics' import { useSafeApps } from '@/hooks/safe-apps/useSafeApps' type SafeAppListProps = { @@ -55,6 +63,12 @@ const SafeAppList = ({ } else { // We only track if not previously opened as it is then tracked in preview drawer trackSafeAppEvent({ ...SAFE_APPS_EVENTS.OPEN_APP, label: eventLabel }, safeApp.name) + trackMixPanelEvent( + MixPanelEvent.SAFE_APP_LAUNCHED, + safeAppToMixPanelEventProperties(safeApp, { + launchLocation: SafeAppLaunchLocation.SAFE_APPS_LIST, + }), + ) } }, [eventLabel, openPreviewDrawer, openedSafeAppIds], diff --git a/apps/web/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx b/apps/web/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx index 442d239e16..497e91b2af 100644 --- a/apps/web/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx +++ b/apps/web/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx @@ -18,7 +18,15 @@ import SafeAppSocialLinksCard from '@/components/safe-apps/SafeAppSocialLinksCar import CloseIcon from '@/public/images/common/close.svg' import { useOpenedSafeApps } from '@/hooks/safe-apps/useOpenedSafeApps' import css from './styles.module.css' -import { SAFE_APPS_EVENTS, SAFE_APPS_LABELS, trackSafeAppEvent } from '@/services/analytics' +import { + SAFE_APPS_EVENTS, + SAFE_APPS_LABELS, + trackSafeAppEvent, + trackMixPanelEvent, + MixPanelEvent, + safeAppToMixPanelEventProperties, + SafeAppLaunchLocation, +} from '@/services/analytics' type SafeAppPreviewDrawerProps = { safeApp?: SafeAppData @@ -37,6 +45,12 @@ const SafeAppPreviewDrawer = ({ isOpen, safeApp, isBookmarked, onClose, onBookma if (safeApp) { markSafeAppOpened(safeApp.id) trackSafeAppEvent({ ...SAFE_APPS_EVENTS.OPEN_APP, label: SAFE_APPS_LABELS.apps_sidebar }, safeApp.name) + trackMixPanelEvent( + MixPanelEvent.SAFE_APP_LAUNCHED, + safeAppToMixPanelEventProperties(safeApp, { + launchLocation: SafeAppLaunchLocation.PREVIEW_DRAWER, + }), + ) } } diff --git a/apps/web/src/components/safe-apps/SafeAppTags/index.tsx b/apps/web/src/components/safe-apps/SafeAppTags/index.tsx index 1f264677e6..c26f1ced4e 100644 --- a/apps/web/src/components/safe-apps/SafeAppTags/index.tsx +++ b/apps/web/src/components/safe-apps/SafeAppTags/index.tsx @@ -3,17 +3,19 @@ import Chip from '@mui/material/Chip' import { filterInternalCategories } from '@/components/safe-apps/utils' import css from './styles.module.css' +import classnames from 'classnames' type SafeAppTagsProps = { tags: string[] + compact?: boolean } -const SafeAppTags = ({ tags = [] }: SafeAppTagsProps) => { +const SafeAppTags = ({ tags = [], compact }: SafeAppTagsProps) => { const displayedTags = filterInternalCategories(tags) return ( <Stack - className={css.safeAppTagContainer} + className={classnames(css.safeAppTagContainer, { [css.compact]: compact })} sx={{ flexDirection: 'row', gap: 1, diff --git a/apps/web/src/components/safe-apps/SafeAppTags/styles.module.css b/apps/web/src/components/safe-apps/SafeAppTags/styles.module.css index 4937821d7a..4ed14bc1c6 100644 --- a/apps/web/src/components/safe-apps/SafeAppTags/styles.module.css +++ b/apps/web/src/components/safe-apps/SafeAppTags/styles.module.css @@ -6,3 +6,7 @@ border-radius: 4px; height: 24px; } + +.compact { + padding-top: var(--space-1); +} diff --git a/apps/web/src/components/safe-apps/types.ts b/apps/web/src/components/safe-apps/types.ts index 27e4fd120a..63d09d7693 100644 --- a/apps/web/src/components/safe-apps/types.ts +++ b/apps/web/src/components/safe-apps/types.ts @@ -50,4 +50,7 @@ export const isBrowserFeature = (featureKey: string): featureKey is AllowedFeatu export type AllowedFeatureSelection = { feature: AllowedFeatures; checked: boolean } -export type SafeAppDataWithPermissions = SafeAppData & { safeAppsPermissions: AllowedFeatures[] } +export type SafeAppDataWithPermissions = SafeAppData & { + safeAppsPermissions: AllowedFeatures[] + originalUrl?: string +} diff --git a/apps/web/src/components/safe-apps/utils.ts b/apps/web/src/components/safe-apps/utils.ts index 8a4265a40f..6e42a7d198 100644 --- a/apps/web/src/components/safe-apps/utils.ts +++ b/apps/web/src/components/safe-apps/utils.ts @@ -4,8 +4,8 @@ import { SafeAppFeatures } from '@safe-global/safe-gateway-typescript-sdk' import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' import type { BaseTransaction, ChainInfo } from '@safe-global/safe-apps-sdk' -import { formatVisualAmount } from '@/utils/formatters' -import { validateAddress } from '@/utils/validation' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' +import { validateAddress } from '@safe-global/utils/utils/validation' import type { SafeAppDataWithPermissions } from './types' import { SafeAppsTag } from '@/config/constants' diff --git a/apps/web/src/components/safe-messages/DecodedMsg/index.tsx b/apps/web/src/components/safe-messages/DecodedMsg/index.tsx index dbb4303ac5..d20075f734 100644 --- a/apps/web/src/components/safe-messages/DecodedMsg/index.tsx +++ b/apps/web/src/components/safe-messages/DecodedMsg/index.tsx @@ -1,9 +1,10 @@ +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { generateDataRowValue, TxDataRow } from '@/components/transactions/TxDetails/Summary/TxDataRow' import { Value } from '@/components/transactions/TxDetails/TxData/DecodedData/ValueArray' import { isByte } from '@/utils/transaction-guards' -import { type EIP712Normalized, normalizeTypedData } from '@/utils/web3' +import { normalizeTypedData } from '@safe-global/utils/utils/web3' +import { type TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { Box, Typography } from '@mui/material' -import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import { ErrorBoundary } from '@sentry/react' import classNames from 'classnames' import { isAddress } from 'ethers' @@ -14,7 +15,7 @@ import { logError, Errors } from '@/services/exceptions' const EIP712_DOMAIN_TYPE = 'EIP712Domain' -const DecodedTypedObject = ({ displayedType, eip712Msg }: { displayedType: string; eip712Msg: EIP712Normalized }) => { +const DecodedTypedObject = ({ displayedType, eip712Msg }: { displayedType: string; eip712Msg: TypedData }) => { const { types, message: msg, domain } = eip712Msg const findType = (paramName: string) => types[displayedType].find((paramType) => paramType.name === paramName)?.type return ( @@ -37,7 +38,7 @@ const DecodedTypedObject = ({ displayedType, eip712Msg }: { displayedType: strin const inlineType = isAddress(paramValue as string) ? 'address' : isByte(type) ? 'bytes' : undefined const paramValueAsString = typeof paramValue === 'string' ? paramValue : JSON.stringify(paramValue, null, 2) return ( - <TxDataRow key={`${displayedType}_param-${index}`} title={`${param[0]}(${type}):`}> + <TxDataRow key={`${displayedType}_param-${index}`} title={`${param[0]}(${type})`}> {isNested ? ( <Box className={css.nestedMsg} @@ -63,7 +64,7 @@ export const DecodedMsg = ({ message, isInModal = false, }: { - message: SafeMessage['message'] | undefined + message: MessageItem['message'] | undefined isInModal?: boolean }): ReactElement | null => { const isTextMessage = typeof message === 'string' @@ -76,12 +77,12 @@ export const DecodedMsg = ({ } // Normalize message such that we know the primaryType - let normalizedMsg: EIP712Normalized + let normalizedMsg: TypedData try { normalizedMsg = normalizeTypedData(message) } catch (error) { logError(Errors._809, error) - normalizedMsg = message as EIP712Normalized + normalizedMsg = message } return ( diff --git a/apps/web/src/components/safe-messages/Msg/index.tsx b/apps/web/src/components/safe-messages/Msg/index.tsx index 6a06e4833c..15466ea32e 100644 --- a/apps/web/src/components/safe-messages/Msg/index.tsx +++ b/apps/web/src/components/safe-messages/Msg/index.tsx @@ -1,13 +1,13 @@ +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { TextField } from '@mui/material' import { useMemo } from 'react' import type { ReactElement } from 'react' -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 Msg = ({ message }: { message: MessageItem['message'] }): ReactElement => { const isTextMessage = typeof message === 'string' const readableData = useMemo(() => { diff --git a/apps/web/src/components/safe-messages/MsgDetails/index.tsx b/apps/web/src/components/safe-messages/MsgDetails/index.tsx index db757ba5b9..b217c02009 100644 --- a/apps/web/src/components/safe-messages/MsgDetails/index.tsx +++ b/apps/web/src/components/safe-messages/MsgDetails/index.tsx @@ -1,19 +1,19 @@ +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { useMemo, type ReactElement } from 'react' import { Accordion, AccordionSummary, Typography, AccordionDetails, Box } from '@mui/material' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import CodeIcon from '@mui/icons-material/Code' import classNames from 'classnames' -import { SafeMessageStatus, type SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import { ErrorBoundary } from '@sentry/react' -import { formatDateTime } from '@/utils/date' +import { formatDateTime } from '@safe-global/utils/utils/date' import EthHashInfo from '@/components/common/EthHashInfo' import { InfoDetails } from '@/components/transactions/InfoDetails' import { generateDataRowValue, TxDataRow } from '@/components/transactions/TxDetails/Summary/TxDataRow' import MsgSigners from '@/components/safe-messages/MsgSigners' import useWallet from '@/hooks/wallets/useWallet' import SignMsgButton from '@/components/safe-messages/SignMsgButton' -import { generateSafeMessageMessage, isEIP712TypedData } from '@/utils/safe-messages' +import { generateSafeMessageMessage, isEIP712TypedData } from '@safe-global/utils/utils/safe-messages' import txDetailsCss from '@/components/transactions/TxDetails/styles.module.css' import singleTxDecodedCss from '@/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/styles.module.css' @@ -23,9 +23,9 @@ import CopyButton from '@/components/common/CopyButton' import NamedAddressInfo from '@/components/common/NamedAddressInfo' import MsgShareLink from '../MsgShareLink' -const MsgDetails = ({ msg }: { msg: SafeMessage }): ReactElement => { +const MsgDetails = ({ msg }: { msg: MessageItem }): ReactElement => { const wallet = useWallet() - const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED + const isConfirmed = msg.status === 'CONFIRMED' const safeMessage = useMemo(() => { try { return generateSafeMessageMessage(msg.message) @@ -46,7 +46,7 @@ const MsgDetails = ({ msg }: { msg: SafeMessage }): ReactElement => { <EthHashInfo address={msg.proposedBy.value || ''} name={msg.proposedBy.name} - customAvatar={msg.proposedBy.logoUri} + customAvatar={msg.proposedBy.logoUri || undefined} shortAddress={false} showCopyButton hasExplorer @@ -77,10 +77,10 @@ const MsgDetails = ({ msg }: { msg: SafeMessage }): ReactElement => { </div> <div className={txDetailsCss.txSummary}> - <TxDataRow title="Created:">{formatDateTime(msg.creationTimestamp)}</TxDataRow> - <TxDataRow title="Last modified:">{formatDateTime(msg.modifiedTimestamp)}</TxDataRow> - <TxDataRow title="Message hash:">{generateDataRowValue(msg.messageHash, 'hash')}</TxDataRow> - {safeMessage && <TxDataRow title="SafeMessage:">{generateDataRowValue(safeMessage, 'hash')}</TxDataRow>} + <TxDataRow title="Created">{formatDateTime(msg.creationTimestamp)}</TxDataRow> + <TxDataRow title="Last modified">{formatDateTime(msg.modifiedTimestamp)}</TxDataRow> + <TxDataRow title="Message hash">{generateDataRowValue(msg.messageHash, 'hash')}</TxDataRow> + {safeMessage && <TxDataRow title="SafeMessage">{generateDataRowValue(safeMessage, 'hash')}</TxDataRow>} </div> {msg.preparedSignature && ( @@ -108,7 +108,7 @@ const MsgDetails = ({ msg }: { msg: SafeMessage }): ReactElement => { <EthHashInfo address={confirmation.owner.value || ''} name={confirmation.owner.name} - customAvatar={confirmation.owner.logoUri} + customAvatar={confirmation.owner.logoUri || undefined} shortAddress={false} showCopyButton hasExplorer diff --git a/apps/web/src/components/safe-messages/MsgList/index.tsx b/apps/web/src/components/safe-messages/MsgList/index.tsx index 6239b68490..0f50492709 100644 --- a/apps/web/src/components/safe-messages/MsgList/index.tsx +++ b/apps/web/src/components/safe-messages/MsgList/index.tsx @@ -1,10 +1,10 @@ +import type { MessagePage } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import type { ReactElement } from 'react' -import type { SafeMessageListPage } from '@safe-global/safe-gateway-typescript-sdk' import { TxListGrid } from '@/components/transactions/TxList' import MsgListItem from '@/components/safe-messages/MsgListItem' -const MsgList = ({ items }: { items: SafeMessageListPage['results'] }): ReactElement => { +const MsgList = ({ items }: { items: MessagePage['results'] }): ReactElement => { return ( <TxListGrid> {items.map((item, i) => ( diff --git a/apps/web/src/components/safe-messages/MsgListItem/ExpandableMsgItem.tsx b/apps/web/src/components/safe-messages/MsgListItem/ExpandableMsgItem.tsx index 8e73fa8909..9b4e8ebe97 100644 --- a/apps/web/src/components/safe-messages/MsgListItem/ExpandableMsgItem.tsx +++ b/apps/web/src/components/safe-messages/MsgListItem/ExpandableMsgItem.tsx @@ -1,7 +1,7 @@ +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { Accordion, AccordionDetails, AccordionSummary, Box } from '@mui/material' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import type { ReactElement } from 'react' -import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import { ErrorBoundary } from '@sentry/react' import MsgDetails from '@/components/safe-messages/MsgDetails' @@ -9,7 +9,7 @@ import MsgSummary from '@/components/safe-messages/MsgSummary' import txListItemCss from '@/components/transactions/TxListItem/styles.module.css' -const ExpandableMsgItem = ({ msg, expanded = false }: { msg: SafeMessage; expanded?: boolean }): ReactElement => { +const ExpandableMsgItem = ({ msg, expanded = false }: { msg: MessageItem; expanded?: boolean }): ReactElement => { return ( <Accordion defaultExpanded={expanded} diff --git a/apps/web/src/components/safe-messages/MsgListItem/index.tsx b/apps/web/src/components/safe-messages/MsgListItem/index.tsx index ff055bae53..0c00beba89 100644 --- a/apps/web/src/components/safe-messages/MsgListItem/index.tsx +++ b/apps/web/src/components/safe-messages/MsgListItem/index.tsx @@ -1,5 +1,5 @@ +import type { SafeMessageListItem } from '@safe-global/store/gateway/types' import type { ReactElement } from 'react' -import type { SafeMessageListItem } from '@safe-global/safe-gateway-typescript-sdk' import { isSafeMessageListDateLabel, isSafeMessageListItem } from '@/utils/safe-message-guards' import TxDateLabel from '@/components/transactions/TxDateLabel' diff --git a/apps/web/src/components/safe-messages/MsgSigners/MsgSigners.test.tsx b/apps/web/src/components/safe-messages/MsgSigners/MsgSigners.test.tsx index 2395195975..3425d48d87 100644 --- a/apps/web/src/components/safe-messages/MsgSigners/MsgSigners.test.tsx +++ b/apps/web/src/components/safe-messages/MsgSigners/MsgSigners.test.tsx @@ -1,11 +1,11 @@ +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { render } from '@/tests/test-utils' -import { SafeMessageStatus, SafeMessageListItemType, type SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import { toBeHex } from 'ethers' import MsgSigners from '.' describe('MsgSigners', () => { it('Message with more confirmations submitted than required', () => { - const mockMessage: SafeMessage = { + const mockMessage: MessageItem = { confirmations: [ { owner: { @@ -31,8 +31,8 @@ describe('MsgSigners', () => { proposedBy: { value: '', }, - status: SafeMessageStatus.NEEDS_CONFIRMATION, - type: SafeMessageListItemType.MESSAGE, + status: 'NEEDS_CONFIRMATION', + type: 'MESSAGE', } const result = render(<MsgSigners msg={mockMessage} />) @@ -43,7 +43,7 @@ describe('MsgSigners', () => { }) it('should show missing signatures if prop is enabled', () => { - const mockMessage: SafeMessage = { + const mockMessage: MessageItem = { confirmations: [ { owner: { @@ -63,8 +63,8 @@ describe('MsgSigners', () => { proposedBy: { value: '', }, - status: SafeMessageStatus.NEEDS_CONFIRMATION, - type: SafeMessageListItemType.MESSAGE, + status: 'NEEDS_CONFIRMATION', + type: 'MESSAGE', } const result = render(<MsgSigners msg={mockMessage} showMissingSignatures />) diff --git a/apps/web/src/components/safe-messages/MsgSigners/index.tsx b/apps/web/src/components/safe-messages/MsgSigners/index.tsx index 96f64fd05d..9927b5e184 100644 --- a/apps/web/src/components/safe-messages/MsgSigners/index.tsx +++ b/apps/web/src/components/safe-messages/MsgSigners/index.tsx @@ -1,7 +1,6 @@ +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { useState, type ReactElement } from 'react' 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' @@ -38,8 +37,8 @@ const Signed = () => ( const Dot = () => <SvgIcon component={DotIcon} inheritViewBox className={css.dot} /> -const shouldHideConfirmations = (msg: SafeMessage): boolean => { - const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED +const shouldHideConfirmations = (msg: MessageItem): boolean => { + const isConfirmed = msg.status === 'CONFIRMED' // Threshold reached or more than 3 confirmations return isConfirmed || msg.confirmations.length > 3 @@ -51,7 +50,7 @@ export const MsgSigners = ({ showMissingSignatures = false, backgroundColor, }: { - msg: SafeMessage + msg: MessageItem showOnlyConfirmations?: boolean showMissingSignatures?: boolean backgroundColor?: string @@ -66,7 +65,7 @@ export const MsgSigners = ({ const missingConfirmations = [...new Array(Math.max(0, confirmationsRequired - confirmationsSubmitted))] - const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED + const isConfirmed = msg.status === 'CONFIRMED' return ( <List className={css.signers}> diff --git a/apps/web/src/components/safe-messages/MsgSummary/index.tsx b/apps/web/src/components/safe-messages/MsgSummary/index.tsx index a8e1057cf1..e021c2e142 100644 --- a/apps/web/src/components/safe-messages/MsgSummary/index.tsx +++ b/apps/web/src/components/safe-messages/MsgSummary/index.tsx @@ -1,7 +1,7 @@ +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' +import type { SafeMessageStatus } from '@safe-global/store/gateway/types' import { Box, CircularProgress, type Palette, Typography } from '@mui/material' import type { ReactElement } from 'react' -import { SafeMessageStatus } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import DateTime from '@/components/common/DateTime' import MsgType from '@/components/safe-messages/MsgType' @@ -11,23 +11,23 @@ import TxConfirmations from '@/components/transactions/TxConfirmations' import css from '@/components/transactions/TxSummary/styles.module.css' import useIsSafeMessagePending from '@/hooks/messages/useIsSafeMessagePending' -import { isEIP712TypedData } from '@/utils/safe-messages' +import { isEIP712TypedData } from '@safe-global/utils/utils/safe-messages' const getStatusColor = (value: SafeMessageStatus, palette: Palette): string => { switch (value) { - case SafeMessageStatus.CONFIRMED: + case 'CONFIRMED': return palette.success.main - case SafeMessageStatus.NEEDS_CONFIRMATION: + case 'NEEDS_CONFIRMATION': return palette.warning.main default: return palette.text.primary } } -const MsgSummary = ({ msg }: { msg: SafeMessage }): ReactElement => { +const MsgSummary = ({ msg }: { msg: MessageItem }): ReactElement => { const { confirmationsSubmitted, confirmationsRequired } = msg const txStatusLabel = useSafeMessageStatus(msg) - const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED + const isConfirmed = msg.status === 'CONFIRMED' const isPending = useIsSafeMessagePending(msg.messageHash) let type = '' if (isEIP712TypedData(msg.message)) { diff --git a/apps/web/src/components/safe-messages/MsgType/index.tsx b/apps/web/src/components/safe-messages/MsgType/index.tsx index 7b0fb197a7..507363bf4e 100644 --- a/apps/web/src/components/safe-messages/MsgType/index.tsx +++ b/apps/web/src/components/safe-messages/MsgType/index.tsx @@ -1,14 +1,14 @@ +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { Box, SvgIcon } from '@mui/material' -import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import RequiredIcon from '@/public/images/messages/required.svg' import ImageFallback from '@/components/common/ImageFallback' import txTypeCss from '@/components/transactions/TxType/styles.module.css' -import { isEIP712TypedData } from '@/utils/safe-messages' +import { isEIP712TypedData } from '@safe-global/utils/utils/safe-messages' const FALLBACK_LOGO_URI = '/images/transactions/custom.svg' const MAX_TRIMMED_LENGTH = 20 -const getMessageName = (msg: SafeMessage) => { +const getMessageName = (msg: MessageItem) => { if (msg.name != null) return msg.name if (isEIP712TypedData(msg.message)) { @@ -23,7 +23,7 @@ const getMessageName = (msg: SafeMessage) => { return trimmed } -const MsgType = ({ msg }: { msg: SafeMessage }) => { +const MsgType = ({ msg }: { msg: MessageItem }) => { return ( <Box className={txTypeCss.txType}> {msg.logoUri ? ( diff --git a/apps/web/src/components/safe-messages/PaginatedMsgs/index.tsx b/apps/web/src/components/safe-messages/PaginatedMsgs/index.tsx index d140c9b876..4829bfbd7c 100644 --- a/apps/web/src/components/safe-messages/PaginatedMsgs/index.tsx +++ b/apps/web/src/components/safe-messages/PaginatedMsgs/index.tsx @@ -11,8 +11,8 @@ 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' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' const NoMessages = (): ReactElement => { return ( diff --git a/apps/web/src/components/safe-messages/SignMsgButton/index.tsx b/apps/web/src/components/safe-messages/SignMsgButton/index.tsx index c0cc6bd915..19092c423f 100644 --- a/apps/web/src/components/safe-messages/SignMsgButton/index.tsx +++ b/apps/web/src/components/safe-messages/SignMsgButton/index.tsx @@ -1,7 +1,7 @@ +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { Button, Tooltip } from '@mui/material' import { useContext } from 'react' import type { SyntheticEvent, ReactElement } from 'react' -import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import useWallet from '@/hooks/wallets/useWallet' import Track from '@/components/common/Track' @@ -11,14 +11,14 @@ import { TxModalContext } from '@/components/tx-flow' import { SignMessageFlow } from '@/components/tx-flow/flows' import CheckWallet from '@/components/common/CheckWallet' -const SignMsgButton = ({ msg, compact = false }: { msg: SafeMessage; compact?: boolean }): ReactElement => { +const SignMsgButton = ({ msg, compact = false }: { msg: MessageItem; compact?: boolean }): ReactElement => { const wallet = useWallet() const isSignable = useIsSafeMessageSignableBy(msg, wallet?.address || '') const { setTxFlow } = useContext(TxModalContext) const onClick = (e: SyntheticEvent) => { e.stopPropagation() - setTxFlow(<SignMessageFlow {...msg} />) + setTxFlow(<SignMessageFlow {...msg} origin={msg.origin || undefined} />) } return ( diff --git a/apps/web/src/components/settings/ClearPendingTxs/index.test.tsx b/apps/web/src/components/settings/ClearPendingTxs/index.test.tsx new file mode 100644 index 0000000000..d753717471 --- /dev/null +++ b/apps/web/src/components/settings/ClearPendingTxs/index.test.tsx @@ -0,0 +1,104 @@ +import React, { act } from 'react' +import { fireEvent, screen } from '@testing-library/react' +import { ClearPendingTxs } from '../ClearPendingTxs' +import { render } from '@/tests/test-utils' +import { faker } from '@faker-js/faker' +import { PendingStatus, PendingTxType } from '@/store/pendingTxsSlice' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' + +const safeAddress = faker.finance.ethereumAddress() + +describe('ClearPendingTxs', () => { + it('clears a single transaction', () => { + render(<ClearPendingTxs />, { + initialReduxState: { + pendingTxs: { + ['0x123']: { + chainId: '1', + safeAddress, + nonce: 0, + data: faker.string.hexadecimal({ length: 64 }), + to: faker.finance.ethereumAddress(), + status: PendingStatus.PROCESSING, + txHash: faker.string.hexadecimal({ length: 64 }), + signerAddress: faker.finance.ethereumAddress(), + submittedAt: Date.now(), + signerNonce: 0, + txType: PendingTxType.CUSTOM_TX, + }, + }, + safeInfo: { + data: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ chainId: '1' }) + .build(), + loading: false, + }, + }, + }) + expect(screen.getByText('Clear 1 transaction')).toBeInTheDocument() + act(() => { + fireEvent.click(screen.getByText('Clear 1 transaction')) + }) + expect(screen.getByText('No pending transactions')).toBeInTheDocument() + }) + it('clears multiple transactions', () => { + render(<ClearPendingTxs />, { + initialReduxState: { + pendingTxs: { + ['0x123']: { + chainId: '1', + safeAddress, + nonce: 0, + data: faker.string.hexadecimal({ length: 64 }), + to: faker.finance.ethereumAddress(), + status: PendingStatus.PROCESSING, + txHash: faker.string.hexadecimal({ length: 64 }), + signerAddress: faker.finance.ethereumAddress(), + submittedAt: Date.now(), + signerNonce: 0, + txType: PendingTxType.CUSTOM_TX, + }, + ['0x234']: { + chainId: '1', + safeAddress, + nonce: 1, + data: faker.string.hexadecimal({ length: 64 }), + to: faker.finance.ethereumAddress(), + status: PendingStatus.PROCESSING, + txHash: faker.string.hexadecimal({ length: 64 }), + signerAddress: faker.finance.ethereumAddress(), + submittedAt: Date.now(), + signerNonce: 0, + txType: PendingTxType.CUSTOM_TX, + }, + ['0x345']: { + chainId: '100', + safeAddress, + nonce: 0, + data: faker.string.hexadecimal({ length: 64 }), + to: faker.finance.ethereumAddress(), + status: PendingStatus.PROCESSING, + txHash: faker.string.hexadecimal({ length: 64 }), + signerAddress: faker.finance.ethereumAddress(), + submittedAt: Date.now(), + signerNonce: 0, + txType: PendingTxType.CUSTOM_TX, + }, + }, + safeInfo: { + data: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ chainId: '1' }) + .build(), + loading: false, + }, + }, + }) + expect(screen.getByText('Clear 2 transactions')).toBeInTheDocument() + act(() => { + fireEvent.click(screen.getByText('Clear 2 transactions')) + }) + expect(screen.getByText('No pending transactions')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/settings/ClearPendingTxs/index.tsx b/apps/web/src/components/settings/ClearPendingTxs/index.tsx new file mode 100644 index 0000000000..6bf120282c --- /dev/null +++ b/apps/web/src/components/settings/ClearPendingTxs/index.tsx @@ -0,0 +1,45 @@ +import { usePendingTxIds } from '@/hooks/usePendingTxs' +import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics' +import { useAppDispatch } from '@/store' +import { clearPendingTx } from '@/store/pendingTxsSlice' +import { Stack, Typography, Box, Button, Alert } from '@mui/material' +import { maybePlural } from '@safe-global/utils/utils/formatters' +import { useCallback } from 'react' + +export const ClearPendingTxs = () => { + const pendingTxIds = usePendingTxIds() + const pendingTxCount = pendingTxIds.length + const dispatch = useAppDispatch() + + const clearPendingTxs = useCallback(() => { + pendingTxIds.forEach((txId) => { + dispatch(clearPendingTx({ txId })) + }) + trackEvent({ ...SETTINGS_EVENTS.DATA.CLEAR_PENDING_TXS, label: pendingTxCount }) + }, [dispatch, pendingTxCount, pendingTxIds]) + return ( + <Stack spacing={2}> + <Typography>Clear this Safe Account's pending transactions.</Typography> + <Alert severity="warning"> + <Typography> + This action does not delete any transactions but only resets their local state. It does not stop any pending + transactions from executing. If you want to cancel an execution, you have to do so in your connected wallet. + </Typography> + </Alert> + <Box> + {pendingTxCount > 0 ? ( + <Button + variant="text" + color="error" + onClick={clearPendingTxs} + sx={{ backgroundColor: ({ palette }) => palette.error.background }} + > + Clear {pendingTxCount} transaction{maybePlural(pendingTxCount)} + </Button> + ) : ( + <Typography variant="body2">No pending transactions</Typography> + )} + </Box> + </Stack> + ) +} diff --git a/apps/web/src/components/settings/ContractVersion/index.tsx b/apps/web/src/components/settings/ContractVersion/index.tsx index 2879edab1d..7039622e9d 100644 --- a/apps/web/src/components/settings/ContractVersion/index.tsx +++ b/apps/web/src/components/settings/ContractVersion/index.tsx @@ -1,7 +1,7 @@ import { useContext, useMemo } from 'react' import { SvgIcon, Typography, Alert, AlertTitle, Skeleton, Button } from '@mui/material' import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import type { MasterCopy } from '@/hooks/useMasterCopies' import { MasterCopyDeployer, useMasterCopies } from '@/hooks/useMasterCopies' import useSafeInfo from '@/hooks/useSafeInfo' @@ -11,8 +11,9 @@ import { TxModalContext } from '@/components/tx-flow' import { UpdateSafeFlow } from '@/components/tx-flow/flows' import ExternalLink from '@/components/common/ExternalLink' import CheckWallet from '@/components/common/CheckWallet' -import { getLatestSafeVersion } from '@/utils/chains' import { useCurrentChain } from '@/hooks/useChains' +import { UnsupportedMastercopyWarning } from '@/features/multichain/components/UnsupportedMastercopyWarning/UnsupportedMasterCopyWarning' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' export const ContractVersion = () => { const { setTxFlow } = useContext(TxModalContext) @@ -29,7 +30,7 @@ export const ContractVersion = () => { const showUpdateDialog = safeMasterCopy?.deployer === MasterCopyDeployer.GNOSIS && needsUpdate const isLatestVersion = safe.version && !showUpdateDialog - const latestSafeVersion = getLatestSafeVersion(currentChain, true) + const latestSafeVersion = getLatestSafeVersion(currentChain) return ( <> @@ -52,7 +53,7 @@ export const ContractVersion = () => { )} </Typography> - {safeLoaded && safe.version && showUpdateDialog && ( + {safeLoaded && safe.version && showUpdateDialog ? ( <Alert sx={{ mt: 2, borderRadius: '2px', borderColor: '#B0FFC9' }} icon={<SvgIcon component={InfoIcon} inheritViewBox color="secondary" />} @@ -75,6 +76,8 @@ export const ContractVersion = () => { )} </CheckWallet> </Alert> + ) : ( + <UnsupportedMastercopyWarning /> )} </> ) diff --git a/apps/web/src/components/settings/DataManagement/FileListCard.tsx b/apps/web/src/components/settings/DataManagement/FileListCard.tsx index a7e90a547e..b2554b0ae7 100644 --- a/apps/web/src/components/settings/DataManagement/FileListCard.tsx +++ b/apps/web/src/components/settings/DataManagement/FileListCard.tsx @@ -10,11 +10,11 @@ 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 { UndeployedSafesState } from '@/features/counterfactual/store/undeployedSafesSlice' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import css from './styles.module.css' import type { VisitedSafesState } from '@/store/visitedSafesSlice' +import type { UndeployedSafesState } from '@safe-global/utils/features/counterfactual/store/types' const getItemSecondaryText = ( chains: ChainInfo[], diff --git a/apps/web/src/components/settings/DataManagement/index.tsx b/apps/web/src/components/settings/DataManagement/index.tsx index b662241365..3cb910ee18 100644 --- a/apps/web/src/components/settings/DataManagement/index.tsx +++ b/apps/web/src/components/settings/DataManagement/index.tsx @@ -18,6 +18,7 @@ import { selectAllVisitedSafes, visitedSafesSlice } from '@/store/visitedSafesSl import css from './styles.module.css' import Track from '@/components/common/Track' import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' +import { ClearPendingTxs } from '../ClearPendingTxs' const getExportFileName = () => { const today = new Date().toISOString().slice(0, 10) @@ -111,7 +112,7 @@ const DataManagement = () => { </Grid> </Paper> - <Paper sx={{ p: 4 }}> + <Paper sx={{ p: 4, mb: 2 }}> <Grid container spacing={3}> <Grid item sm={4} xs={12}> <Typography variant="h4" fontWeight={700}> @@ -133,6 +134,20 @@ const DataManagement = () => { )} </Grid> </Paper> + + <Paper sx={{ p: 4 }}> + <Grid container spacing={3}> + <Grid item sm={4} xs={12}> + <Typography variant="h4" fontWeight={700}> + Pending transactions + </Typography> + </Grid> + + <Grid data-testid="clear-pending-tx-section" item container xs> + <ClearPendingTxs /> + </Grid> + </Grid> + </Paper> </> ) } diff --git a/apps/web/src/components/settings/DataManagement/useGlobalImportFileParser.ts b/apps/web/src/components/settings/DataManagement/useGlobalImportFileParser.ts index fb08394422..65c7bb9d72 100644 --- a/apps/web/src/components/settings/DataManagement/useGlobalImportFileParser.ts +++ b/apps/web/src/components/settings/DataManagement/useGlobalImportFileParser.ts @@ -1,16 +1,16 @@ import { logError } from '@/services/exceptions' -import ErrorCodes from '@/services/exceptions/ErrorCodes' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' import { migrateAddedSafes } from '@/services/ls-migration/addedSafes' import { migrateAddressBook } from '@/services/ls-migration/addressBook' -import { isChecksummedAddress } from '@/utils/addresses' +import { isChecksummedAddress } from '@safe-global/utils/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 type { UndeployedSafesState } from '@/features/counterfactual/store/undeployedSafesSlice' import { useMemo } from 'react' import type { VisitedSafesState } from '@/store/visitedSafesSlice' +import type { UndeployedSafesState } from '@safe-global/utils/features/counterfactual/store/types' export const enum SAFE_EXPORT_VERSION { V1 = '1.0', diff --git a/apps/web/src/components/settings/EnvironmentVariables/index.tsx b/apps/web/src/components/settings/EnvironmentVariables/index.tsx index 479da4ce10..c200fabc23 100644 --- a/apps/web/src/components/settings/EnvironmentVariables/index.tsx +++ b/apps/web/src/components/settings/EnvironmentVariables/index.tsx @@ -4,12 +4,12 @@ import InputAdornment from '@mui/material/InputAdornment' import RotateLeftIcon from '@mui/icons-material/RotateLeft' import { useAppDispatch, useAppSelector } from '@/store' 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' import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics' import InfoIcon from '@/public/images/notifications/info.svg' import ExternalLink from '@/components/common/ExternalLink' +import { TENDERLY_SIMULATE_ENDPOINT_URL } from '@safe-global/utils/config/constants' export enum EnvVariablesField { rpc = 'rpc', diff --git a/apps/web/src/components/settings/FallbackHandler/index.tsx b/apps/web/src/components/settings/FallbackHandler/index.tsx index 700358e798..2e06fea80e 100644 --- a/apps/web/src/components/settings/FallbackHandler/index.tsx +++ b/apps/web/src/components/settings/FallbackHandler/index.tsx @@ -1,15 +1,18 @@ import NextLink from 'next/link' -import { Typography, Box, Grid, Paper, Link, Alert } from '@mui/material' +import { Typography, Box, Grid, Paper, Link } from '@mui/material' import semverSatisfies from 'semver/functions/satisfies' import type { ReactElement } from 'react' +import classnames from 'classnames' import EthHashInfo from '@/components/common/EthHashInfo' import useSafeInfo from '@/hooks/useSafeInfo' -import { BRAND_NAME, HelpCenterArticle } from '@/config/constants' +import { BRAND_NAME } from '@/config/constants' import ExternalLink from '@/components/common/ExternalLink' import { useTxBuilderApp } from '@/hooks/safe-apps/useTxBuilderApp' import { useCompatibilityFallbackHandlerDeployments } from '@/hooks/useCompatibilityFallbackHandlerDeployments' -import { useIsOfficialFallbackHandler } from '@/hooks/useIsOfficialFallbackHandler' +import { useHasUntrustedFallbackHandler } from '@/hooks/useHasUntrustedFallbackHandler' +import css from '../TransactionGuards/styles.module.css' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' import { useIsTWAPFallbackHandler } from '@/features/swap/hooks/useIsTWAPFallbackHandler' const FALLBACK_HANDLER_VERSION = '>=1.1.1' @@ -41,8 +44,8 @@ export const FallbackHandlerWarning = ({ export const FallbackHandler = (): ReactElement | null => { const { safe } = useSafeInfo() const fallbackHandlerDeployments = useCompatibilityFallbackHandlerDeployments() - const isOfficial = useIsOfficialFallbackHandler() const isTWAPFallbackHandler = useIsTWAPFallbackHandler() + const isUntrusted = useHasUntrustedFallbackHandler() const supportsFallbackHandler = !!safe.version && semverSatisfies(safe.version, FALLBACK_HANDLER_VERSION) @@ -59,7 +62,7 @@ export const FallbackHandler = (): ReactElement | null => { /> ) : isTWAPFallbackHandler ? ( <>This is CoW's fallback handler. It is needed for this Safe to be able to use the TWAP feature for Swaps.</> - ) : !isOfficial ? ( + ) : isUntrusted ? ( <FallbackHandlerWarning message={ <> @@ -98,18 +101,15 @@ export const FallbackHandler = (): ReactElement | null => { <ExternalLink href={HelpCenterArticle.FALLBACK_HANDLER}>here</ExternalLink> </Typography> - <Alert - severity={!hasFallbackHandler ? 'warning' : isOfficial || isTWAPFallbackHandler ? 'success' : 'info'} - icon={false} - sx={{ mt: 2 }} + <Box + className={classnames(css.guardDisplay, { + [css.warning]: !hasFallbackHandler, + [css.info]: hasFallbackHandler && isUntrusted, + })} + sx={{ display: 'block !important' }} > {warning && ( - <Typography - variant="body2" - sx={{ - mb: hasFallbackHandler ? 1 : 0, - }} - > + <Typography variant="body2" width="100%" mb={hasFallbackHandler ? 1 : 0}> {warning} </Typography> )} @@ -119,12 +119,12 @@ export const FallbackHandler = (): ReactElement | null => { shortAddress={false} name={safe.fallbackHandler.name || fallbackHandlerDeployments?.contractName} address={safe.fallbackHandler.value} - customAvatar={safe.fallbackHandler.logoUri} + customAvatar={safe.fallbackHandler.logoUri || undefined} showCopyButton hasExplorer /> )} - </Alert> + </Box> </Box> </Grid> </Grid> diff --git a/apps/web/src/components/settings/NestedSafesList/index.tsx b/apps/web/src/components/settings/NestedSafesList/index.tsx new file mode 100644 index 0000000000..4786c8a07b --- /dev/null +++ b/apps/web/src/components/settings/NestedSafesList/index.tsx @@ -0,0 +1,126 @@ +import { Paper, Grid2, Typography, Button, SvgIcon, Tooltip, IconButton } from '@mui/material' +import { skipToken } from '@reduxjs/toolkit/query' +import { useContext, useMemo, useState } from 'react' +import type { ReactElement } from 'react' + +import AddIcon from '@/public/images/common/add.svg' +import EditIcon from '@/public/images/common/edit.svg' +import CheckWallet from '@/components/common/CheckWallet' +import EthHashInfo from '@/components/common/EthHashInfo' +import { CreateNestedSafe } from '@/components/tx-flow/flows/CreateNestedSafe' +import EntryDialog from '@/components/address-book/EntryDialog' +import { TxModalContext } from '@/components/tx-flow' +import EnhancedTable from '@/components/common/EnhancedTable' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useGetOwnedSafesQuery } from '@/store/slices' +import { NESTED_SAFE_EVENTS } from '@/services/analytics/events/nested-safes' +import Track from '@/components/common/Track' +import { useHasFeature } from '@/hooks/useChains' + +import tableCss from '@/components/common/EnhancedTable/styles.module.css' +import { FEATURES } from '@safe-global/utils/utils/chains' + +export function NestedSafesList(): ReactElement | null { + const isEnabled = useHasFeature(FEATURES.NESTED_SAFES) + const { setTxFlow } = useContext(TxModalContext) + const [addressToRename, setAddressToRename] = useState<string | null>(null) + + const { safe, safeLoaded, safeAddress } = useSafeInfo() + const { data: nestedSafes } = useGetOwnedSafesQuery( + isEnabled && safeLoaded ? { chainId: safe.chainId, ownerAddress: safeAddress } : skipToken, + ) + + const rows = useMemo(() => { + return nestedSafes?.safes.map((nestedSafe) => { + return { + cells: { + owner: { + rawValue: nestedSafe, + content: ( + <EthHashInfo address={nestedSafe} showCopyButton shortAddress={false} showName={true} hasExplorer /> + ), + }, + actions: { + rawValue: '', + sticky: true, + content: ( + <div className={tableCss.actions}> + <CheckWallet> + {(isOk) => ( + <Track {...NESTED_SAFE_EVENTS.RENAME}> + <Tooltip title={isOk ? 'Rename nested Safe' : undefined}> + <span> + <IconButton onClick={() => setAddressToRename(nestedSafe)} size="small" disabled={!isOk}> + <SvgIcon component={EditIcon} inheritViewBox fontSize="small" color="border" /> + </IconButton> + </span> + </Tooltip> + </Track> + )} + </CheckWallet> + </div> + ), + }, + }, + } + }) + }, [nestedSafes?.safes]) + + if (!isEnabled) { + return null + } + + return ( + <> + <Paper sx={{ padding: 4, mt: 2 }}> + <Grid2 container direction="row" justifyContent="space-between" spacing={3} mb={2}> + <Grid2 size={{ lg: 4, xs: 12 }}> + <Typography variant="h4" fontWeight={700}> + Nested Safes + </Typography> + </Grid2> + + <Grid2 size="grow"> + <Typography mb={3}> + Nested Safes are separate wallets owned by your main Account, perfect for organizing different funds and + projects. + </Typography> + + {nestedSafes?.safes.length === 0 && ( + <Typography mb={3}> + You don't have any Nested Safes yet. Set one up now to better organize your assets + </Typography> + )} + + {safe.deployed && ( + <CheckWallet> + {(isOk) => ( + <Button + onClick={() => setTxFlow(<CreateNestedSafe />)} + variant="text" + startIcon={<SvgIcon component={AddIcon} inheritViewBox fontSize="small" />} + disabled={!isOk} + sx={{ mb: 3 }} + > + Add nested Safe + </Button> + )} + </CheckWallet> + )} + + {rows && rows.length > 0 && <EnhancedTable rows={rows} headCells={[]} />} + </Grid2> + </Grid2> + </Paper> + + {addressToRename && ( + <EntryDialog + handleClose={() => setAddressToRename(null)} + defaultValues={{ name: '', address: addressToRename }} + chainIds={[safe.chainId]} + disableAddressInput + /> + )} + </> + ) +} diff --git a/apps/web/src/components/settings/ProposersList/index.tsx b/apps/web/src/components/settings/ProposersList/index.tsx index 27042a0040..97cbc82aaa 100644 --- a/apps/web/src/components/settings/ProposersList/index.tsx +++ b/apps/web/src/components/settings/ProposersList/index.tsx @@ -10,12 +10,12 @@ import { useHasFeature } from '@/hooks/useChains' import useProposers from '@/hooks/useProposers' import AddIcon from '@/public/images/common/add.svg' import { SETTINGS_EVENTS } from '@/services/analytics' -import { FEATURES } from '@/utils/chains' import { Box, Button, Grid, Paper, SvgIcon, Typography } from '@mui/material' import EthHashInfo from '@/components/common/EthHashInfo' import ExternalLink from '@/components/common/ExternalLink' -import { HelpCenterArticle } from '@/config/constants' import React, { useMemo, useState } from 'react' +import { FEATURES } from '@safe-global/utils/utils/chains' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' const headCells = [ { @@ -85,10 +85,6 @@ const ProposersList = () => { <Paper sx={{ mt: 2 }}> <Box data-testid="proposer-section" display="flex" flexDirection="column" gap={2}> <Grid container spacing={3}> - <Grid item lg={4} xs={12}> - <Typography variant="h4" fontWeight={700}></Typography> - </Grid> - <Grid item xs> <Typography fontWeight="bold" mb={2}> Proposers <Chip label="New" sx={{ backgroundColor: 'secondary.light', color: 'static.main' }} /> diff --git a/apps/web/src/components/settings/PushNotifications/GlobalPushNotifications.tsx b/apps/web/src/components/settings/PushNotifications/GlobalPushNotifications.tsx index c08de91b25..da61745bc2 100644 --- a/apps/web/src/components/settings/PushNotifications/GlobalPushNotifications.tsx +++ b/apps/web/src/components/settings/PushNotifications/GlobalPushNotifications.tsx @@ -1,4 +1,4 @@ -import { selectUndeployedSafes, type UndeployedSafesState } from '@/features/counterfactual/store/undeployedSafesSlice' +import { selectUndeployedSafes } from '@/features/counterfactual/store/undeployedSafesSlice' import { Box, Grid, @@ -23,7 +23,7 @@ import type { AllOwnedSafes } from '@safe-global/safe-gateway-typescript-sdk' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import EthHashInfo from '@/components/common/EthHashInfo' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import useChains from '@/hooks/useChains' import { useAppSelector } from '@/store' import { useNotificationPreferences } from './hooks/useNotificationPreferences' @@ -34,14 +34,15 @@ import { requestNotificationPermission } from './logic' import type { NotifiableSafes } from './logic' import type { PushNotificationPreferences } from '@/services/push-notifications/preferences' import CheckWalletWithPermission from '@/components/common/CheckWalletWithPermission' -import { Permission } from '@/permissions/types' +import { Permission } from '@/permissions/config' import css from './styles.module.css' import useAllOwnedSafes from '@/features/myAccounts/hooks/useAllOwnedSafes' import useWallet from '@/hooks/wallets/useWallet' import { selectAllAddedSafes, type AddedSafesState } from '@/store/addedSafesSlice' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' import { useNotificationsRenewal } from './hooks/useNotificationsRenewal' +import type { UndeployedSafesState } from '@safe-global/utils/features/counterfactual/store/types' // UI logic diff --git a/apps/web/src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts b/apps/web/src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts index 734d7d5d8e..ab97984ca5 100644 --- a/apps/web/src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts +++ b/apps/web/src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts @@ -1,5 +1,4 @@ -import type { UndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' -import type { AllOwnedSafes, ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { _mergeNotifiableSafes, @@ -17,6 +16,8 @@ import { _transformAddedSafes, } from '../GlobalPushNotifications' import type { AddedSafesState } from '@/store/addedSafesSlice' +import type { UndeployedSafe } from '@safe-global/utils/features/counterfactual/store/types' +import type { OwnersGetAllSafesByOwnerV2ApiResponse as AllOwnedSafes } from '@safe-global/store/gateway/AUTO_GENERATED/owners' describe('GlobalPushNotifications', () => { describe('transformAddedSafes', () => { @@ -94,6 +95,38 @@ describe('GlobalPushNotifications', () => { expect(_mergeNotifiableSafes(ownedSafes, addedSafes)).toEqual(expectedNotifiableSafes) }) + + it('should display an empty array of safes for a chain with unowned safes = null ', () => { + const currentSubscriptions = { + '1': ['0x111', '0x222'], + '4': ['0x111'], + } + + const addedSafes = { + '1': { + '0x111': {}, + '0x333': {}, + }, + '4': { + '0x222': {}, + '0x333': {}, + }, + } as unknown as AddedSafesState + + const ownedSafes = { + '1': ['0x111', '0x444'], + '3': null, + '4': null, + } as unknown as AllOwnedSafes + + const expectedNotifiableSafes = { + '1': ['0x111', '0x222', '0x444'], + '3': [], + '4': ['0x111'], + } + + expect(_mergeNotifiableSafes(ownedSafes, addedSafes, currentSubscriptions)).toEqual(expectedNotifiableSafes) + }) }) describe('filterUndeployedSafes', () => { diff --git a/apps/web/src/components/settings/PushNotifications/__tests__/logic.test.ts b/apps/web/src/components/settings/PushNotifications/__tests__/logic.test.ts index 7a103f5b13..e7abd0b6e0 100644 --- a/apps/web/src/components/settings/PushNotifications/__tests__/logic.test.ts +++ b/apps/web/src/components/settings/PushNotifications/__tests__/logic.test.ts @@ -30,12 +30,8 @@ Object.defineProperty(globalThis, 'location', { }, }) -const MM_SIGNATURE = +const SIGNATURE = '0x844ba559793a122c5742e9d922ed1f4650d4efd8ea35191105ddaee6a604000165c14f56278bda8d52c9400cdaeaf5cdc38d3596264cc5ccd8f03e5619d5d9d41b' -const LEDGER_SIGNATURE = - '0xb1274687aea0d8b8578a3eb6da57979eee0a64225e04445a0858e6f8d0d1b5870cdff961513992d849e47e9b0a8d432019829f1e4958837fd86e034656766a4e00' -const ADJUSTED_LEDGER_SIGNATURE = - '0xb1274687aea0d8b8578a3eb6da57979eee0a64225e04445a0858e6f8d0d1b5870cdff961513992d849e47e9b0a8d432019829f1e4958837fd86e034656766a4e1b' describe('Notifications', () => { let alertMock = jest.fn() @@ -96,26 +92,6 @@ describe('Notifications', () => { }) }) - describe('adjustLegerSignature', () => { - it('should return the same signature if not that of a Ledger', () => { - const adjustedSignature = logic._adjustLedgerSignatureV(MM_SIGNATURE) - - expect(adjustedSignature).toBe(MM_SIGNATURE) - }) - - it('should return an adjusted signature if is that of a Ledger and v is 0 or 1', () => { - const adjustedSignature = logic._adjustLedgerSignatureV(LEDGER_SIGNATURE) - - expect(adjustedSignature).toBe(ADJUSTED_LEDGER_SIGNATURE) - }) - - it('should return the same signature if v is 27 or 28', () => { - const adjustedSignature = logic._adjustLedgerSignatureV(MM_SIGNATURE) - - expect(adjustedSignature).toBe(MM_SIGNATURE) - }) - }) - describe('getRegisterDevicePayload', () => { it('should return the payload with signature', async () => { const token = crypto.randomUUID() @@ -125,7 +101,7 @@ describe('Notifications', () => { jest.spyOn(mockProvider, 'getSigner').mockImplementation(() => Promise.resolve({ - signMessage: jest.fn().mockResolvedValueOnce(MM_SIGNATURE), + signMessage: jest.fn().mockResolvedValueOnce(SIGNATURE), } as unknown as JsonRpcSigner), ) jest.spyOn(web3, 'createWeb3').mockImplementation(() => mockProvider) @@ -155,61 +131,12 @@ describe('Notifications', () => { { chainId: '1', safes: [toBeHex('0x1', 20), toBeHex('0x2', 20)], - signatures: [MM_SIGNATURE], - }, - { - chainId: '2', - safes: [toBeHex('0x1', 20)], - signatures: [MM_SIGNATURE], - }, - ], - }) - }) - - it('should return the payload with a Ledger adjusted signature', async () => { - const token = crypto.randomUUID() - jest.spyOn(firebase, 'getToken').mockImplementation(() => Promise.resolve(token)) - - const mockProvider = new BrowserProvider(MockEip1193Provider) - - jest.spyOn(mockProvider, 'getSigner').mockImplementation(() => - Promise.resolve({ - signMessage: jest.fn().mockResolvedValueOnce(LEDGER_SIGNATURE), - } as unknown as JsonRpcSigner), - ) - jest.spyOn(web3, 'createWeb3').mockImplementation(() => mockProvider) - - const uuid = crypto.randomUUID() - - const payload = await logic.getRegisterDevicePayload({ - safesToRegister: { - ['1']: [toBeHex('0x1', 20), toBeHex('0x2', 20)], - ['2']: [toBeHex('0x1', 20)], - }, - uuid, - wallet: { - label: 'Ledger', - } as ConnectedWallet, - }) - - expect(payload).toStrictEqual({ - uuid, - cloudMessagingToken: token, - buildNumber: '0', - bundle: 'safe', - deviceType: DeviceType.WEB, - version: packageJson.version, - timestamp: expect.any(String), - safeRegistrations: [ - { - chainId: '1', - safes: [toBeHex('0x1', 20), toBeHex('0x2', 20)], - signatures: [ADJUSTED_LEDGER_SIGNATURE], + signatures: [SIGNATURE], }, { chainId: '2', safes: [toBeHex('0x1', 20)], - signatures: [ADJUSTED_LEDGER_SIGNATURE], + signatures: [SIGNATURE], }, ], }) diff --git a/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationsTokenVersion.test.ts b/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationsTokenVersion.test.ts index cf8cd2fa5a..5ad8c108e8 100644 --- a/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationsTokenVersion.test.ts +++ b/apps/web/src/components/settings/PushNotifications/hooks/__tests__/useNotificationsTokenVersion.test.ts @@ -5,7 +5,8 @@ import * as useSafeInfoHook from '@/hooks/useSafeInfo' import * as localStorage from '@/services/local-storage/useLocalStorage' import { NotificationsTokenVersion } from '@/services/push-notifications/preferences' import { renderHook } from '@testing-library/react' -import { FEATURES } from '@/utils/chains' + +import { FEATURES } from '@safe-global/utils/utils/chains' const { V1, V2 } = NotificationsTokenVersion diff --git a/apps/web/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts index 68bd489fb0..9ae90c6168 100644 --- a/apps/web/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts +++ b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts @@ -8,14 +8,14 @@ import { import { useCallback, useEffect, useMemo } from 'react' import { WebhookType } from '@/service-workers/firebase-messaging/webhook-types' -import ExternalStore from '@/services/ExternalStore' +import ExternalStore from '@safe-global/utils/services/ExternalStore' import { createPushNotificationPrefsIndexedDb, createPushNotificationUuidIndexedDb, getPushNotificationPrefsKey, } from '@/services/push-notifications/preferences' import { logError } from '@/services/exceptions' -import ErrorCodes from '@/services/exceptions/ErrorCodes' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' import type { PushNotificationPreferences, PushNotificationPrefsKey } from '@/services/push-notifications/preferences' import type { NotifiableSafes } from '../logic' diff --git a/apps/web/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts index 16528df21b..770168a727 100644 --- a/apps/web/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts +++ b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts @@ -8,7 +8,7 @@ import { trackEvent } from '@/services/analytics' import { PUSH_NOTIFICATION_EVENTS } from '@/services/analytics/events/push-notifications' import { getRegisterDevicePayload } from '../logic' import { logError } from '@/services/exceptions' -import ErrorCodes from '@/services/exceptions/ErrorCodes' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' import useWallet from '@/hooks/wallets/useWallet' import type { NotifiableSafes } from '../logic' import { NotificationsTokenVersion } from '@/services/push-notifications/preferences' diff --git a/apps/web/src/components/settings/PushNotifications/hooks/useNotificationTracking.ts b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationTracking.ts index 0a6b37d238..3873b9049f 100644 --- a/apps/web/src/components/settings/PushNotifications/hooks/useNotificationTracking.ts +++ b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationTracking.ts @@ -8,12 +8,13 @@ import { } from '@/services/push-notifications/tracking' import { trackEvent } from '@/services/analytics' import { PUSH_NOTIFICATION_EVENTS } from '@/services/analytics/events/push-notifications' -import ErrorCodes from '@/services/exceptions/ErrorCodes' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' import { logError } from '@/services/exceptions' import type { NotificationTracking, NotificationTrackingKey } from '@/services/push-notifications/tracking' import type { WebhookType } from '@/service-workers/firebase-messaging/webhook-types' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' + +import { FEATURES } from '@safe-global/utils/utils/chains' const trackNotificationEvents = ( chainId: string, diff --git a/apps/web/src/components/settings/PushNotifications/hooks/useNotificationsTokenVersion.ts b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationsTokenVersion.ts index 1e605d133f..dc9c53409d 100644 --- a/apps/web/src/components/settings/PushNotifications/hooks/useNotificationsTokenVersion.ts +++ b/apps/web/src/components/settings/PushNotifications/hooks/useNotificationsTokenVersion.ts @@ -3,7 +3,8 @@ import useLocalStorage from '@/services/local-storage/useLocalStorage' import type { NotificationsTokenVersion } from '@/services/push-notifications/preferences' import type { NotifiableSafes } from '../logic' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' + +import { FEATURES } from '@safe-global/utils/utils/chains' export const NOTIFICATIONS_TOKEN_VERSION_KEY = 'notificationsTokenVersion' diff --git a/apps/web/src/components/settings/PushNotifications/index.tsx b/apps/web/src/components/settings/PushNotifications/index.tsx index 902c594856..ea9cf62657 100644 --- a/apps/web/src/components/settings/PushNotifications/index.tsx +++ b/apps/web/src/components/settings/PushNotifications/index.tsx @@ -23,18 +23,19 @@ import { useNotificationRegistrations } from './hooks/useNotificationRegistratio import { useNotificationPreferences } from './hooks/useNotificationPreferences' import { GlobalPushNotifications } from './GlobalPushNotifications' import useIsSafeOwner from '@/hooks/useIsSafeOwner' -import { HelpCenterArticle, IS_DEV } from '@/config/constants' +import { IS_DEV } from '@/config/constants' import { trackEvent } from '@/services/analytics' import { PUSH_NOTIFICATION_EVENTS } from '@/services/analytics/events/push-notifications' import { AppRoutes } from '@/config/routes' import CheckWalletWithPermission from '@/components/common/CheckWalletWithPermission' import { useIsMac } from '@/hooks/useIsMac' import ExternalLink from '@/components/common/ExternalLink' -import { Permission } from '@/permissions/types' +import { Permission } from '@/permissions/config' import css from './styles.module.css' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' import NotificationRenewal from '@/components/notification-center/NotificationRenewal' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' export const PushNotifications = (): ReactElement => { const { safe, safeLoaded } = useSafeInfo() diff --git a/apps/web/src/components/settings/PushNotifications/logic.ts b/apps/web/src/components/settings/PushNotifications/logic.ts index 12185b4086..10cf8eec5a 100644 --- a/apps/web/src/components/settings/PushNotifications/logic.ts +++ b/apps/web/src/components/settings/PushNotifications/logic.ts @@ -1,5 +1,4 @@ import { getBytes, keccak256, toUtf8Bytes, type BrowserProvider } from 'ethers' -import { joinSignature, splitSignature } from '@/utils/ethers-utils' import { getToken, getMessaging } from 'firebase/messaging' import { DeviceType } from '@safe-global/safe-gateway-typescript-sdk' import type { RegisterNotificationsRequest } from '@safe-global/safe-gateway-typescript-sdk' @@ -7,9 +6,8 @@ import type { RegisterNotificationsRequest } from '@safe-global/safe-gateway-typ import { FIREBASE_VAPID_KEY, initializeFirebaseApp } from '@/services/push-notifications/firebase' import packageJson from '../../../../package.json' import { logError } from '@/services/exceptions' -import ErrorCodes from '@/services/exceptions/ErrorCodes' -import { checksumAddress } from '@/utils/addresses' -import { isLedger } from '@/utils/wallets' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' +import { checksumAddress } from '@safe-global/utils/utils/addresses' import { createWeb3 } from '@/hooks/wallets/web3' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' @@ -34,35 +32,18 @@ export const requestNotificationPermission = async (): Promise<boolean> => { return permission === 'granted' } -// Ledger produces vrs signatures with a canonical v value of {0,1} -// Ethereum's ecrecover call only accepts a non-standard v value of {27,28}. - -// @see https://github.com/ethereum/go-ethereum/issues/19751 -export const _adjustLedgerSignatureV = (signature: string): string => { - const split = splitSignature(signature) - - // @ts-ignore - if (split.v === 0 || split.v === 1) { - split.v += 27 - } - - return joinSignature(split) -} - const getSafeRegistrationSignature = async ({ safeAddresses, web3, timestamp, uuid, token, - isLedger, }: { safeAddresses: Array<string> web3: BrowserProvider timestamp: string uuid: string token: string - isLedger: boolean }) => { const MESSAGE_PREFIX = 'gnosis-safe' @@ -75,13 +56,8 @@ const getSafeRegistrationSignature = async ({ const message = MESSAGE_PREFIX + timestamp + uuid + token + safeAddresses.sort().join('') const hashedMessage = keccak256(toUtf8Bytes(message)) - const signature = await (await web3.getSigner()).signMessage(getBytes(hashedMessage)) - - if (!isLedger) { - return signature - } - - return _adjustLedgerSignatureV(signature) + const signer = await web3.getSigner() + return await signer.signMessage(getBytes(hashedMessage)) } export type NotifiableSafes = { [chainId: string]: Array<string> } @@ -110,7 +86,6 @@ export const getRegisterDevicePayload = async ({ }) const web3 = createWeb3(wallet.provider) - const isLedgerWallet = isLedger(wallet) // If uuid is not provided a new device will be created. // If a uuid for an existing Safe is provided the FirebaseDevice will be updated with all the new data provided. @@ -133,7 +108,6 @@ export const getRegisterDevicePayload = async ({ uuid, timestamp, token, - isLedger: isLedgerWallet, }) safeRegistrations.push({ diff --git a/apps/web/src/components/settings/RequiredConfirmations/index.tsx b/apps/web/src/components/settings/RequiredConfirmations/index.tsx index 88c17bb1a6..a7fae9152b 100644 --- a/apps/web/src/components/settings/RequiredConfirmations/index.tsx +++ b/apps/web/src/components/settings/RequiredConfirmations/index.tsx @@ -5,7 +5,7 @@ import { ChangeThresholdFlow } from '@/components/tx-flow/flows' import CheckWallet from '@/components/common/CheckWallet' import { useContext } from 'react' import { TxModalContext } from '@/components/tx-flow' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' export const RequiredConfirmation = ({ threshold, owners }: { threshold: number; owners: number }) => { const { setTxFlow } = useContext(TxModalContext) diff --git a/apps/web/src/components/settings/SafeAppsSigningMethod/index.tsx b/apps/web/src/components/settings/SafeAppsSigningMethod/index.tsx index c4b25059ab..bfcd13c5db 100644 --- a/apps/web/src/components/settings/SafeAppsSigningMethod/index.tsx +++ b/apps/web/src/components/settings/SafeAppsSigningMethod/index.tsx @@ -3,7 +3,8 @@ 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' -import { BRAND_NAME, HelpCenterArticle } from '@/config/constants' +import { BRAND_NAME } from '@/config/constants' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' export const SafeAppsSigningMethod = () => { const onChainSigning = useAppSelector(selectOnChainSigning) diff --git a/apps/web/src/components/settings/SafeModules/index.tsx b/apps/web/src/components/settings/SafeModules/index.tsx index 443894826f..04c85504a1 100644 --- a/apps/web/src/components/settings/SafeModules/index.tsx +++ b/apps/web/src/components/settings/SafeModules/index.tsx @@ -81,7 +81,7 @@ const SafeModules = () => { <Typography> 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{' '} - <ExternalLink href="https://docs.safe.global/safe-core-protocol/plugins">here</ExternalLink> + <ExternalLink href="https://help.safe.global/en/articles/40827-what-is-a-module">here</ExternalLink> </Typography> {safeModules.length === 0 ? ( <NoModules /> @@ -91,7 +91,7 @@ const SafeModules = () => { key={module.value} chainId={safe.chainId} moduleAddress={module.value} - name={module.name} + name={module.name || undefined} /> )) )} diff --git a/apps/web/src/components/settings/SettingsHeader/index.test.tsx b/apps/web/src/components/settings/SettingsHeader/index.test.tsx index 32574c41fb..a094783b2e 100644 --- a/apps/web/src/components/settings/SettingsHeader/index.test.tsx +++ b/apps/web/src/components/settings/SettingsHeader/index.test.tsx @@ -4,8 +4,8 @@ import * as safeAddress from '@/hooks/useSafeAddress' import { render } from '@/tests/test-utils' import { faker } from '@faker-js/faker' -import { FEATURES } from '@/utils/chains' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { FEATURES } from '@safe-global/utils/utils/chains' describe('SettingsHeader', () => { beforeEach(() => { diff --git a/apps/web/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx b/apps/web/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx index d9f18a3b47..020c93cfd7 100644 --- a/apps/web/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx +++ b/apps/web/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx @@ -1,8 +1,8 @@ import EnhancedTable from '@/components/common/EnhancedTable' import DeleteIcon from '@/public/images/common/delete.svg' -import { safeFormatUnits } from '@/utils/formatters' +import { safeFormatUnits } from '@safe-global/utils/utils/formatters' import { Box, IconButton, Skeleton, SvgIcon, Typography } from '@mui/material' -import { relativeTime } from '@/utils/date' +import { relativeTime } from '@safe-global/utils/utils/date' import EthHashInfo from '@/components/common/EthHashInfo' import { useContext, useMemo } from 'react' import type { SpendingLimitState } from '@/store/spendingLimitsSlice' diff --git a/apps/web/src/components/settings/SpendingLimits/index.tsx b/apps/web/src/components/settings/SpendingLimits/index.tsx index 9f26cd8b61..a871526731 100644 --- a/apps/web/src/components/settings/SpendingLimits/index.tsx +++ b/apps/web/src/components/settings/SpendingLimits/index.tsx @@ -4,13 +4,13 @@ import { NoSpendingLimits } from '@/components/settings/SpendingLimits/NoSpendin import { SpendingLimitsTable } from '@/components/settings/SpendingLimits/SpendingLimitsTable' import { useSelector } from 'react-redux' import { selectSpendingLimits, selectSpendingLimitsLoading } from '@/store/spendingLimitsSlice' -import { FEATURES } from '@/utils/chains' import { useHasFeature } from '@/hooks/useChains' import { NewSpendingLimitFlow } from '@/components/tx-flow/flows' import { SETTINGS_EVENTS } from '@/services/analytics' import CheckWallet from '@/components/common/CheckWallet' import Track from '@/components/common/Track' import { TxModalContext } from '@/components/tx-flow' +import { FEATURES } from '@safe-global/utils/utils/chains' const SpendingLimits = () => { const { setTxFlow } = useContext(TxModalContext) @@ -26,7 +26,6 @@ const SpendingLimits = () => { spacing={3} sx={{ justifyContent: 'space-between', - mb: 2, }} > <Grid item lg={4} xs={12}> @@ -54,7 +53,7 @@ const SpendingLimits = () => { <Button data-testid="new-spending-limit" onClick={() => setTxFlow(<NewSpendingLimitFlow />)} - sx={{ mt: 2 }} + sx={{ mt: 2, mb: 2 }} variant="contained" disabled={!isOk} size="small" @@ -66,13 +65,15 @@ const SpendingLimits = () => { </CheckWallet> {!spendingLimits.length && !spendingLimitsLoading && <NoSpendingLimits />} + {spendingLimits.length > 0 && ( + <SpendingLimitsTable isLoading={spendingLimitsLoading} spendingLimits={spendingLimits} /> + )} </Box> ) : ( - <Typography>The spending limit module is not yet available on this chain.</Typography> + <Typography>The spending limit feature is not yet available on this chain.</Typography> )} </Grid> </Grid> - <SpendingLimitsTable isLoading={spendingLimitsLoading} spendingLimits={spendingLimits} /> </Paper> ) } diff --git a/apps/web/src/components/settings/TransactionGuards/index.tsx b/apps/web/src/components/settings/TransactionGuards/index.tsx index 4fe1c357fb..f31bbe7614 100644 --- a/apps/web/src/components/settings/TransactionGuards/index.tsx +++ b/apps/web/src/components/settings/TransactionGuards/index.tsx @@ -6,12 +6,12 @@ import css from './styles.module.css' import ExternalLink from '@/components/common/ExternalLink' import { SAFE_FEATURES } from '@safe-global/protocol-kit/dist/src/utils/safeVersions' import { hasSafeFeature } from '@/utils/safe-versions' -import { HelpCenterArticle } from '@/config/constants' import DeleteIcon from '@/public/images/common/delete.svg' import CheckWallet from '@/components/common/CheckWallet' import { useContext } from 'react' import { TxModalContext } from '@/components/tx-flow' import { RemoveGuardFlow } from '@/components/tx-flow/flows' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' const NoTransactionGuard = () => { return ( diff --git a/apps/web/src/components/settings/TransactionGuards/styles.module.css b/apps/web/src/components/settings/TransactionGuards/styles.module.css index fd3c093e54..80b8da440f 100644 --- a/apps/web/src/components/settings/TransactionGuards/styles.module.css +++ b/apps/web/src/components/settings/TransactionGuards/styles.module.css @@ -8,3 +8,13 @@ align-items: center; justify-content: space-between; } + +.guardDisplay.warning { + background-color: var(--color-warning-background); + border-color: var(--color-warning-dark); +} + +.guardDisplay.info { + background-color: var(--color-info-background); + border-color: var(--color-info-dark); +} diff --git a/apps/web/src/components/settings/owner/OwnerList/index.tsx b/apps/web/src/components/settings/owner/OwnerList/index.tsx index 513cae19dc..ffed56fcc0 100644 --- a/apps/web/src/components/settings/owner/OwnerList/index.tsx +++ b/apps/web/src/components/settings/owner/OwnerList/index.tsx @@ -1,14 +1,15 @@ import { jsonToCSV } from 'react-papaparse' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import EthHashInfo from '@/components/common/EthHashInfo' -import { AddOwnerFlow, ReplaceOwnerFlow, RemoveOwnerFlow } from '@/components/tx-flow/flows' +import { ReplaceOwnerFlow, RemoveOwnerFlow } from '@/components/tx-flow/flows' import useAddressBook from '@/hooks/useAddressBook' import useSafeInfo from '@/hooks/useSafeInfo' import { Box, Grid, Typography, Button, SvgIcon, Tooltip, IconButton } from '@mui/material' import { useContext, useMemo } from 'react' import { EditOwnerDialog } from '../EditOwnerDialog' import EnhancedTable from '@/components/common/EnhancedTable' -import AddIcon from '@/public/images/common/add.svg' +import EditOwnerIcon from '@/public/images/common/edit-owner.svg' +import { ManageSignersFlow } from '@/components/tx-flow/flows/ManagerSigners' import Track from '@/components/common/Track' import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' import CheckWallet from '@/components/common/CheckWallet' @@ -98,18 +99,7 @@ export const OwnerList = () => { }} > <Grid container spacing={3}> - <Grid item lg={4} xs={12}> - <Typography - variant="h4" - sx={{ - fontWeight: 700, - }} - > - Members - </Typography> - </Grid> - - <Grid item xs> + <Grid data-testid="signer-list" item xs> <Typography fontWeight="bold" sx={{ @@ -125,23 +115,23 @@ export const OwnerList = () => { <Box sx={{ - pt: 2, + py: 2, display: 'flex', justifyContent: 'space-between', }} > <CheckWallet> {(isOk) => ( - <Track {...SETTINGS_EVENTS.SETUP.ADD_OWNER}> + <Track {...SETTINGS_EVENTS.SETUP.MANAGE_SIGNERS}> <Button - data-testid="add-owner-btn" - onClick={() => setTxFlow(<AddOwnerFlow />)} + data-testid="manage-signers-btn" + onClick={() => setTxFlow(<ManageSignersFlow />)} variant="text" - startIcon={<SvgIcon component={AddIcon} inheritViewBox fontSize="small" />} + startIcon={<SvgIcon component={EditOwnerIcon} inheritViewBox />} disabled={!isOk} size="compact" > - Add signer + Manage signers </Button> </Track> )} @@ -159,7 +149,10 @@ export const OwnerList = () => { ) } -function exportOwners({ chainId, address, owners }: SafeInfo, addressBook: AddressBook) { +function exportOwners( + { chainId, address, owners }: Pick<SafeState, 'chainId' | 'address' | 'owners'>, + addressBook: AddressBook, +) { const json = owners.map((owner) => { const address = owner.value const name = addressBook[address] || owner.name diff --git a/apps/web/src/components/sidebar/IndexingStatus/index.tsx b/apps/web/src/components/sidebar/IndexingStatus/index.tsx index c80501473b..ffbcfcf978 100644 --- a/apps/web/src/components/sidebar/IndexingStatus/index.tsx +++ b/apps/web/src/components/sidebar/IndexingStatus/index.tsx @@ -1,12 +1,15 @@ -import { Stack, Box, Typography, Tooltip } from '@mui/material' +import { Box, Tooltip, Button, SvgIcon } from '@mui/material' import { formatDistanceToNow } from 'date-fns' import { getIndexingStatus } from '@safe-global/safe-gateway-typescript-sdk' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useChainId from '@/hooks/useChainId' -import ExternalLink from '@/components/common/ExternalLink' import useIntervalCounter from '@/hooks/useIntervalCounter' +import { OpenInNewRounded } from '@mui/icons-material' +import { IS_PRODUCTION } from '@/config/constants' -const STATUS_PAGE = 'https://status.safe.global' +const STATUS_PAGE = IS_PRODUCTION + ? 'https://tx-status-app.safe.protofire.io/?serviceUrl=https://gateway.safe.boba.network' + : 'https://tx-status-app.safe.protofire.io/?serviceUrl=https://gateway.staging.safe.boba.network' const MAX_SYNC_DELAY = 1000 * 60 * 5 // 5 minutes const POLL_INTERVAL = 1000 * 60 // 1 minute @@ -16,6 +19,7 @@ const useIndexingStatus = () => { return useAsync( () => { + if (count === undefined) return return getIndexingStatus(chainId) }, [chainId, count], @@ -63,15 +67,27 @@ const IndexingStatus = () => { return ( <Tooltip title={`Last synced with the blockchain ${time}`} placement="right" arrow> - <Stack direction="row" spacing={2} alignItems="center" px={3} py={1.5}> - <Box width={10} height={10} borderRadius="50%" border={`2px solid var(--color-${status.color}-main)`} /> - - <ExternalLink href={STATUS_PAGE} noIcon flex={1}> - <Typography variant="body2">{status.text}</Typography> - </ExternalLink> - - <ExternalLink href={STATUS_PAGE} sx={{ color: 'text.secondary', transform: 'translateY(3px)' }} /> - </Stack> + <Button + size="small" + href={STATUS_PAGE} + target="_blank" + data-testid="index-status" + startIcon={ + <Box width={16} height={16} borderRadius="50%" border={`2px solid var(--color-${status.color}-main)`} /> + } + endIcon={ + <SvgIcon component={OpenInNewRounded} fontSize="small" inheritViewBox sx={{ color: 'border.main', ml: 1 }} /> + } + sx={{ + fontSize: '12px', + fontWeight: 'normal', + p: 1, + '& .MuiButton-startIcon': { marginLeft: 0 }, + '& .MuiButton-endIcon': { justifySelf: 'flex-end', marginLeft: 'auto' }, + }} + > + {status.text} + </Button> </Tooltip> ) } diff --git a/apps/web/src/components/sidebar/NestedSafeInfo/index.tsx b/apps/web/src/components/sidebar/NestedSafeInfo/index.tsx new file mode 100644 index 0000000000..7715c539de --- /dev/null +++ b/apps/web/src/components/sidebar/NestedSafeInfo/index.tsx @@ -0,0 +1,62 @@ +import { Tooltip, SvgIcon, Typography, List, ListItem, Box, ListItemAvatar, Avatar, ListItemText } from '@mui/material' +import CheckIcon from '@mui/icons-material/Check' +import type { ReactElement } from 'react' + +import NestedSafesIcon from '@/public/images/sidebar/nested-safes-icon.svg' +import NestedSafes from '@/public/images/sidebar/nested-safes.svg' +import InfoIcon from '@/public/images/notifications/info.svg' + +export function NestedSafeInfo(): ReactElement { + return ( + <Box display="flex" flexDirection="column" alignItems="center" pt={1}> + <NestedSafes /> + <Box display="flex" gap={1} py={2}> + <Typography fontWeight={700}>No Nested Safes yet</Typography> + <Tooltip + title="Nested Safes are separate wallets owned by your main Account, perfect for organizing different funds and projects." + placement="top" + arrow + sx={{ ml: 1 }} + > + <span> + <SvgIcon + component={InfoIcon} + inheritViewBox + fontSize="small" + color="border" + sx={{ verticalAlign: 'middle' }} + /> + </span> + </Tooltip> + </Box> + <Box display="flex" gap={2} alignItems="center" pt={1} pb={4}> + <Avatar sx={{ padding: '20px', backgroundColor: 'success.background' }}> + <SvgIcon component={NestedSafesIcon} inheritViewBox color="primary" sx={{ fontSize: 20 }} /> + </Avatar> + <Typography variant="body2" fontWeight={700}> + Nested Safes allow you to: + </Typography> + </Box> + <List sx={{ p: 0, display: 'flex', flexDirection: 'column', gap: 2 }}> + {[ + 'rebuild your organizational structure onchain', + 'explore new DeFi opportunities without exposing your main Account', + 'deploy specialized modules and extend Safe functionality', + ].map((item) => { + return ( + <ListItem key={item} sx={{ p: 0, pl: 1.5, alignItems: 'unset' }}> + <ListItemAvatar sx={{ minWidth: 'unset', mr: 3 }}> + <Avatar sx={{ width: 25, height: 25, backgroundColor: 'success.background' }}> + <CheckIcon fontSize="small" color="success" /> + </Avatar> + </ListItemAvatar> + <ListItemText sx={{ m: 0 }} primaryTypographyProps={{ variant: 'body2' }}> + {item} + </ListItemText> + </ListItem> + ) + })} + </List> + </Box> + ) +} diff --git a/apps/web/src/components/sidebar/NestedSafesButton/index.tsx b/apps/web/src/components/sidebar/NestedSafesButton/index.tsx new file mode 100644 index 0000000000..464d1ca4bb --- /dev/null +++ b/apps/web/src/components/sidebar/NestedSafesButton/index.tsx @@ -0,0 +1,65 @@ +import { Tooltip, IconButton, SvgIcon, Badge, Typography } from '@mui/material' +import { skipToken } from '@reduxjs/toolkit/query' +import { useState } from 'react' +import type { ReactElement } from 'react' + +import NestedSafesIcon from '@/public/images/sidebar/nested-safes-icon.svg' +import { NestedSafesPopover } from '@/components/sidebar/NestedSafesPopover' +import { useGetOwnedSafesQuery } from '@/store/slices' +import { useHasFeature } from '@/hooks/useChains' +import useSafeInfo from '@/hooks/useSafeInfo' + +import headerCss from '@/components/sidebar/SidebarHeader/styles.module.css' +import css from './styles.module.css' +import { FEATURES } from '@safe-global/utils/utils/chains' + +export function NestedSafesButton({ + chainId, + safeAddress, +}: { + chainId: string + safeAddress: string +}): ReactElement | null { + const isEnabled = useHasFeature(FEATURES.NESTED_SAFES) + const { safe } = useSafeInfo() + const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null) + const { data } = useGetOwnedSafesQuery(isEnabled && safeAddress ? { chainId, ownerAddress: safeAddress } : skipToken) + const nestedSafes = data?.safes ?? [] + + if (!isEnabled || !safe.deployed) { + return null + } + + const onClick = (event: React.MouseEvent<HTMLButtonElement>) => { + setAnchorEl(event.currentTarget) + } + const onClose = () => { + setAnchorEl(null) + } + + return ( + <> + <Tooltip title="Nested Safes" placement="top"> + <Badge invisible={nestedSafes.length > 0} variant="dot" className={css.badge}> + <IconButton + className={headerCss.iconButton} + sx={{ + width: 'auto !important', + minWidth: '32px !important', + backgroundColor: anchorEl ? '#f2fecd !important' : undefined, + }} + onClick={onClick} + > + <SvgIcon component={NestedSafesIcon} inheritViewBox color="primary" fontSize="small" /> + {nestedSafes.length > 0 && ( + <Typography component="span" variant="caption" className={css.count}> + {nestedSafes.length} + </Typography> + )} + </IconButton> + </Badge> + </Tooltip> + <NestedSafesPopover anchorEl={anchorEl} onClose={onClose} nestedSafes={nestedSafes} /> + </> + ) +} diff --git a/apps/web/src/components/sidebar/NestedSafesButton/styles.module.css b/apps/web/src/components/sidebar/NestedSafesButton/styles.module.css new file mode 100644 index 0000000000..e7f1cb4f90 --- /dev/null +++ b/apps/web/src/components/sidebar/NestedSafesButton/styles.module.css @@ -0,0 +1,22 @@ +.badge :global .MuiBadge-badge { + border: 1px solid var(--color-background-main); + border-radius: 50%; + box-sizing: content-box; + right: 12px; + top: 8px; + background-color: var(--color-secondary-main); + height: 6px; + min-width: 6px; +} + +.count { + margin-left: calc(var(--space-1) / 2); + background-color: var(--color-success-light); + border-radius: 100%; + width: 18px; + height: 18px; + display: flex; + justify-content: center; + align-items: center; + color: var(--color-static-main); +} diff --git a/apps/web/src/components/sidebar/NestedSafesList/index.tsx b/apps/web/src/components/sidebar/NestedSafesList/index.tsx new file mode 100644 index 0000000000..d67b663f52 --- /dev/null +++ b/apps/web/src/components/sidebar/NestedSafesList/index.tsx @@ -0,0 +1,108 @@ +import Link from 'next/link' +import { ChevronRight } from '@mui/icons-material' +import { List, ListItem, ListItemAvatar, ListItemButton, ListItemText, Typography } from '@mui/material' + +import Track from '@/components/common/Track' +import { NESTED_SAFE_EVENTS, NESTED_SAFE_LABELS } from '@/services/analytics/events/nested-safes' +import { useState, type ReactElement } from 'react' +import Identicon from '@/components/common/Identicon' +import { shortenAddress } from '@safe-global/utils/utils/formatters' +import useAddressBook from '@/hooks/useAddressBook' +import { trackEvent } from '@/services/analytics' +import { AppRoutes } from '@/config/routes' +import { useCurrentChain } from '@/hooks/useChains' + +const MAX_NESTED_SAFES = 5 + +export function NestedSafesList({ + onClose, + nestedSafes, +}: { + onClose: () => void + nestedSafes: Array<string> +}): ReactElement { + const [showAll, setShowAll] = useState(false) + const nestedSafesToShow = showAll ? nestedSafes : nestedSafes.slice(0, MAX_NESTED_SAFES) + + const onShowAll = () => { + setShowAll(true) + } + + return ( + <List sx={{ gap: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}> + {nestedSafesToShow.map((nestedSafe) => { + return <NestedSafeListItem onClose={onClose} nestedSafe={nestedSafe} key={nestedSafe} /> + })} + {nestedSafes.length > MAX_NESTED_SAFES && !showAll && ( + <Track {...NESTED_SAFE_EVENTS.SHOW_ALL}> + <Typography + variant="caption" + color="text.secondary" + textTransform="uppercase" + fontWeight={700} + sx={{ cursor: 'pointer' }} + onClick={onShowAll} + > + Show all Nested Safes + <ChevronRight color="border" sx={{ transform: 'rotate(90deg)', ml: 1 }} fontSize="inherit" /> + </Typography> + </Track> + )} + </List> + ) +} + +function NestedSafeListItem({ onClose, nestedSafe }: { onClose: () => void; nestedSafe: string }): ReactElement { + const chain = useCurrentChain() + const addressBook = useAddressBook() + const name = addressBook[nestedSafe] + + const onClick = () => { + // Note: using the Track element breaks accessibility/styles + trackEvent({ ...NESTED_SAFE_EVENTS.OPEN_NESTED_SAFE, label: NESTED_SAFE_LABELS.list }) + + onClose() + } + + return ( + <ListItem + sx={{ + border: ({ palette }) => `1px solid ${palette.border.light}`, + borderRadius: ({ shape }) => `${shape.borderRadius}px`, + p: 0, + }} + > + <Link + href={{ + pathname: AppRoutes.home, + query: { + safe: `${chain?.shortName}:${nestedSafe}`, + }, + }} + passHref + legacyBehavior + > + <ListItemButton sx={{ p: '11px 12px' }} onClick={onClick}> + <ListItemAvatar sx={{ minWidth: 'unset', pr: 1 }}> + <Identicon address={nestedSafe} size={32} /> + </ListItemAvatar> + <ListItemText + primary={name} + primaryTypographyProps={{ + fontWeight: 700, + sx: { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + }} + secondary={shortenAddress(nestedSafe)} + secondaryTypographyProps={{ color: 'primary.light' }} + sx={{ my: 0 }} + /> + <ChevronRight color="border" /> + </ListItemButton> + </Link> + </ListItem> + ) +} diff --git a/apps/web/src/components/sidebar/NestedSafesPopover/index.tsx b/apps/web/src/components/sidebar/NestedSafesPopover/index.tsx new file mode 100644 index 0000000000..11aab307c1 --- /dev/null +++ b/apps/web/src/components/sidebar/NestedSafesPopover/index.tsx @@ -0,0 +1,108 @@ +import { SvgIcon, Popover, Button, Box, Stack } from '@mui/material' +import { useContext } from 'react' +import type { ReactElement } from 'react' + +import AddIcon from '@/public/images/common/add.svg' +import { ModalDialogTitle } from '@/components/common/ModalDialog' +import { CreateNestedSafe } from '@/components/tx-flow/flows/CreateNestedSafe' +import { TxModalContext } from '@/components/tx-flow' +import { NestedSafesList } from '@/components/sidebar/NestedSafesList' +import { NestedSafeInfo } from '@/components/sidebar/NestedSafeInfo' +import Track from '@/components/common/Track' +import { NESTED_SAFE_EVENTS } from '@/services/analytics/events/nested-safes' +import CheckWallet from '@/components/common/CheckWallet' + +export function NestedSafesPopover({ + anchorEl, + onClose, + nestedSafes, + hideCreationButton = false, +}: { + anchorEl: HTMLElement | null + onClose: () => void + nestedSafes: Array<string> + hideCreationButton?: boolean +}): ReactElement { + const { setTxFlow } = useContext(TxModalContext) + + const onAdd = () => { + setTxFlow(<CreateNestedSafe />) + onClose() + } + + return ( + <Popover + open={!!anchorEl} + anchorEl={anchorEl} + onClose={onClose} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + slotProps={{ + paper: { + sx: { + width: '300px', + maxHeight: '590px', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + }, + }, + }} + > + <ModalDialogTitle + hideChainIndicator + onClose={onClose} + sx={{ borderBottom: ({ palette }) => `1px solid ${palette.border.light}` }} + > + Nested Safes + </ModalDialogTitle> + <Stack + data-testid="nested-safe-list" + p={3} + pt={2} + display="flex" + flexDirection="column" + flex={1} + overflow="hidden" + > + {nestedSafes.length === 0 ? ( + <NestedSafeInfo /> + ) : ( + <Box + sx={{ + overflowX: 'hidden', + overflowY: 'auto', + flex: 1, + }} + > + <NestedSafesList onClose={onClose} nestedSafes={nestedSafes} /> + </Box> + )} + {!hideCreationButton && ( + <Track {...NESTED_SAFE_EVENTS.ADD}> + <CheckWallet> + {(ok) => ( + <Button + data-testid="add-nested-safe-button" + variant="contained" + sx={{ width: '100%', mt: 3 }} + onClick={onAdd} + disabled={!ok} + > + <SvgIcon component={AddIcon} inheritViewBox fontSize="small" /> + Add Nested Safe + </Button> + )} + </CheckWallet> + </Track> + )} + </Stack> + </Popover> + ) +} diff --git a/apps/web/src/components/sidebar/NewTxButton/index.tsx b/apps/web/src/components/sidebar/NewTxButton/index.tsx index 9f953643e7..f77e5fe60c 100644 --- a/apps/web/src/components/sidebar/NewTxButton/index.tsx +++ b/apps/web/src/components/sidebar/NewTxButton/index.tsx @@ -6,7 +6,6 @@ import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import CheckWallet from '@/components/common/CheckWallet' import { TxModalContext } from '@/components/tx-flow' import { NewTxFlow } from '@/components/tx-flow/flows' -import WatchlistAddButton from '../WatchlistAddButton' const NewTxButton = (): ReactElement => { const { setTxFlow } = useContext(TxModalContext) @@ -22,25 +21,21 @@ const NewTxButton = (): ReactElement => { } return ( - <CheckWallet allowSpendingLimit noTooltip> - {(isOk) => - isOk ? ( - <Button - data-testid="new-tx-btn" - onClick={onClick} - variant="contained" - size="small" - disabled={!isOk} - fullWidth - disableElevation - sx={{ py: 1.3 }} - > - New transaction - </Button> - ) : ( - <WatchlistAddButton /> - ) - } + <CheckWallet allowSpendingLimit> + {(isOk) => ( + <Button + data-testid="new-tx-btn" + onClick={onClick} + variant="contained" + size="small" + disabled={!isOk} + fullWidth + disableElevation + sx={{ py: 1.3 }} + > + New transaction + </Button> + )} </CheckWallet> ) } diff --git a/apps/web/src/components/sidebar/SafeListContextMenu/index.tsx b/apps/web/src/components/sidebar/SafeListContextMenu/index.tsx index d715110463..850f92f735 100644 --- a/apps/web/src/components/sidebar/SafeListContextMenu/index.tsx +++ b/apps/web/src/components/sidebar/SafeListContextMenu/index.tsx @@ -5,27 +5,41 @@ import IconButton from '@mui/material/IconButton' import MoreVertIcon from '@mui/icons-material/MoreVert' import MenuItem from '@mui/material/MenuItem' import ListItemText from '@mui/material/ListItemText' +import { skipToken } from '@reduxjs/toolkit/query' import EntryDialog from '@/components/address-book/EntryDialog' import SafeListRemoveDialog from '@/components/sidebar/SafeListRemoveDialog' +import NestedSafesIcon from '@/public/images/sidebar/nested-safes-icon.svg' import EditIcon from '@/public/images/common/edit.svg' import DeleteIcon from '@/public/images/common/delete.svg' import PlusIcon from '@/public/images/common/plus.svg' import ContextMenu from '@/components/common/ContextMenu' -import { trackEvent, OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' +import { trackEvent, OVERVIEW_EVENTS, OVERVIEW_LABELS, type AnalyticsEvent } from '@/services/analytics' import { SvgIcon } from '@mui/material' import useAddressBook from '@/hooks/useAddressBook' import { AppRoutes } from '@/config/routes' import router from 'next/router' import { CreateSafeOnNewChain } from '@/features/multichain/components/CreateSafeOnNewChain' +import { useGetOwnedSafesQuery } from '@/store/slices' +import { NestedSafesPopover } from '../NestedSafesPopover' +import { NESTED_SAFE_EVENTS, NESTED_SAFE_LABELS } from '@/services/analytics/events/nested-safes' +import { useHasFeature } from '@/hooks/useChains' + +import { FEATURES } from '@safe-global/utils/utils/chains' enum ModalType { + NESTED_SAFES = 'nested_safes', RENAME = 'rename', REMOVE = 'remove', ADD_CHAIN = 'add_chain', } -const defaultOpen = { [ModalType.RENAME]: false, [ModalType.REMOVE]: false, [ModalType.ADD_CHAIN]: false } +const defaultOpen = { + [ModalType.NESTED_SAFES]: false, + [ModalType.RENAME]: false, + [ModalType.REMOVE]: false, + [ModalType.ADD_CHAIN]: false, +} const SafeListContextMenu = ({ name, @@ -34,6 +48,7 @@ const SafeListContextMenu = ({ addNetwork, rename, undeployedSafe, + onClose, }: { name: string address: string @@ -41,11 +56,15 @@ const SafeListContextMenu = ({ addNetwork: boolean rename: boolean undeployedSafe: boolean + onClose?: () => void }): ReactElement => { + const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null) + const isNestedSafesEnabled = useHasFeature(FEATURES.NESTED_SAFES) + const { data: nestedSafes } = useGetOwnedSafesQuery( + isNestedSafesEnabled && address && anchorEl ? { chainId, ownerAddress: address } : skipToken, + ) const addressBook = useAddressBook() const hasName = address in addressBook - - const [anchorEl, setAnchorEl] = useState<HTMLElement | undefined>() const [open, setOpen] = useState<typeof defaultOpen>(defaultOpen) const trackingLabel = @@ -56,17 +75,17 @@ const SafeListContextMenu = ({ } const handleCloseContextMenu = () => { - setAnchorEl(undefined) + setAnchorEl(null) } - const handleOpenModal = - (type: keyof typeof open, event: typeof OVERVIEW_EVENTS.SIDEBAR_RENAME | typeof OVERVIEW_EVENTS.SIDEBAR_RENAME) => - () => { + const handleOpenModal = (type: keyof typeof open, event: AnalyticsEvent) => () => { + if (type !== ModalType.NESTED_SAFES) { handleCloseContextMenu() - setOpen((prev) => ({ ...prev, [type]: true })) - - trackEvent({ ...event, label: trackingLabel }) } + setOpen((prev) => ({ ...prev, [type]: true })) + + trackEvent({ ...event, label: trackingLabel }) + } const handleCloseModal = () => { setOpen(defaultOpen) @@ -78,6 +97,20 @@ const SafeListContextMenu = ({ <MoreVertIcon sx={({ palette }) => ({ color: palette.border.main })} /> </IconButton> <ContextMenu anchorEl={anchorEl} open={!!anchorEl} onClose={handleCloseContextMenu}> + {isNestedSafesEnabled && !undeployedSafe && nestedSafes?.safes && nestedSafes.safes.length > 0 && ( + <MenuItem + onClick={handleOpenModal(ModalType.NESTED_SAFES, { + ...NESTED_SAFE_EVENTS.OPEN_LIST, + label: NESTED_SAFE_LABELS.sidebar, + })} + > + <ListItemIcon> + <SvgIcon component={NestedSafesIcon} inheritViewBox fontSize="small" color="success" /> + </ListItemIcon> + <ListItemText data-testid="nested-safes-btn">Nested Safes</ListItemText> + </MenuItem> + )} + {rename && ( <MenuItem onClick={handleOpenModal(ModalType.RENAME, OVERVIEW_EVENTS.SIDEBAR_RENAME)}> <ListItemIcon> @@ -106,6 +139,18 @@ const SafeListContextMenu = ({ )} </ContextMenu> + {open[ModalType.NESTED_SAFES] && ( + <NestedSafesPopover + anchorEl={anchorEl} + onClose={() => { + handleCloseModal() + onClose?.() + }} + nestedSafes={nestedSafes?.safes ?? []} + hideCreationButton + /> + )} + {open[ModalType.RENAME] && ( <EntryDialog handleClose={handleCloseModal} diff --git a/apps/web/src/components/sidebar/Sidebar/index.tsx b/apps/web/src/components/sidebar/Sidebar/index.tsx index 0423611e99..26956fddc9 100644 --- a/apps/web/src/components/sidebar/Sidebar/index.tsx +++ b/apps/web/src/components/sidebar/Sidebar/index.tsx @@ -6,11 +6,11 @@ import ChainIndicator from '@/components/common/ChainIndicator' import SidebarHeader from '@/components/sidebar/SidebarHeader' import SidebarNavigation from '@/components/sidebar/SidebarNavigation' import SidebarFooter from '@/components/sidebar/SidebarFooter' -import IndexingStatus from '@/components/sidebar/IndexingStatus' import css from './styles.module.css' import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' import MyAccounts from '@/features/myAccounts' +import IndexingStatus from '../IndexingStatus' const Sidebar = (): ReactElement => { const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false) @@ -38,8 +38,6 @@ const Sidebar = (): ReactElement => { {/* Address, balance, copy button, etc */} <SidebarHeader /> - <Divider /> - {/* Nav menu */} <SidebarNavigation /> @@ -51,7 +49,6 @@ const Sidebar = (): ReactElement => { <Divider flexItem /> - {/* What's new + Need help? */} <SidebarFooter /> <Divider flexItem /> diff --git a/apps/web/src/components/sidebar/Sidebar/styles.module.css b/apps/web/src/components/sidebar/Sidebar/styles.module.css index b90513e727..a6e397486b 100644 --- a/apps/web/src/components/sidebar/Sidebar/styles.module.css +++ b/apps/web/src/components/sidebar/Sidebar/styles.module.css @@ -5,9 +5,6 @@ overflow: hidden; flex-direction: column; background-color: var(--color-background-paper); -} - -.container { width: 230px; } diff --git a/apps/web/src/components/sidebar/SidebarFooter/index.tsx b/apps/web/src/components/sidebar/SidebarFooter/index.tsx index 158b6d8586..1c75b84f85 100644 --- a/apps/web/src/components/sidebar/SidebarFooter/index.tsx +++ b/apps/web/src/components/sidebar/SidebarFooter/index.tsx @@ -1,21 +1,14 @@ import type { ReactElement } from 'react' import { useEffect } from 'react' -import { - SidebarList, - SidebarListItemButton, - SidebarListItemIcon, - SidebarListItemText, -} from '@/components/sidebar/SidebarList' +import { SidebarListItemButton, SidebarListItemIcon, SidebarListItemText } from '@/components/sidebar/SidebarList' import { loadBeamer } from '@/services/beamer' import { useAppSelector } from '@/store' import { CookieAndTermType, hasConsentFor } from '@/store/cookiesAndTermsSlice' -import { Link, ListItem, SvgIcon, Typography } from '@mui/material' +import { Box, Divider, Link, ListItem, SvgIcon, Typography, useTheme } from '@mui/material' import DebugToggle from '../DebugToggle' import { HELP_CENTER_URL, IS_PRODUCTION, NEW_SUGGESTION_FORM } from '@/config/constants' import { useCurrentChain } from '@/hooks/useChains' -import Track from '@/components/common/Track' -import { OVERVIEW_EVENTS } from '@/services/analytics' import HelpCenterIcon from '@/public/images/sidebar/help-center.svg' import darkPalette from '@/components/theme/darkPalette' import SuggestionIcon from '@/public/images/sidebar/lightbulb_icon.svg' @@ -24,6 +17,7 @@ import ProtofireLogo from '@/public/images/protofire-logo.svg' const SidebarFooter = (): ReactElement => { const chain = useCurrentChain() const hasBeamerConsent = useAppSelector((state) => hasConsentFor(state, CookieAndTermType.UPDATES)) + const theme = useTheme() useEffect(() => { // Initialise Beamer when consent was previously given @@ -39,41 +33,58 @@ const SidebarFooter = (): ReactElement => { // } return ( - <SidebarList> + <> {!IS_PRODUCTION && ( - <ListItem disablePadding> - <DebugToggle /> - </ListItem> + <> + <ListItem disablePadding> + <DebugToggle /> + </ListItem> + + <Divider flexItem /> + </> )} - <Track {...OVERVIEW_EVENTS.HELP_CENTER}> - <ListItem disablePadding> - <a target="_blank" rel="noopener noreferrer" href={HELP_CENTER_URL} style={{ width: '100%' }}> - <SidebarListItemButton> - <SidebarListItemIcon color="primary"> - <HelpCenterIcon /> - </SidebarListItemIcon> - <SidebarListItemText data-testid="list-item-need-help" bold> - Need help? - </SidebarListItemText> - </SidebarListItemButton> - </a> - </ListItem> - </Track>{' '} - <Track {...OVERVIEW_EVENTS.SUGGESTIONS}> - <ListItem disablePadding> - <a target="_blank" rel="noopener noreferrer" href={NEW_SUGGESTION_FORM} style={{ width: '100%' }}> - <SidebarListItemButton style={{ backgroundColor: '#12FF80', color: 'black' }}> - <SidebarListItemIcon color="primary"> + <ListItem style={{ padding: 'var(--space-1)' }}> + <a target="_blank" rel="noopener noreferrer" href={HELP_CENTER_URL} style={{ width: '100%' }}> + <SidebarListItemButton> + <SidebarListItemIcon color="primary"> + <HelpCenterIcon /> + </SidebarListItemIcon> + <SidebarListItemText data-testid="list-item-need-help" bold> + Need help? + </SidebarListItemText> + </SidebarListItemButton> + </a> + </ListItem> + + <ListItem style={{ padding: '0 var(--space-1) 0' }}> + <a target="_blank" rel="noopener noreferrer" href={NEW_SUGGESTION_FORM} style={{ width: '100%' }}> + <SidebarListItemButton + style={{ + color: 'black', + backgroundColor: + theme.palette.mode === 'dark' ? theme.palette.primary.main : theme.palette.secondary.main, + }} + > + <SidebarListItemIcon> + <Box + sx={{ + '& svg': { + '& path': () => ({ + fill: 'black !important', + }), + }, + }} + > <SuggestionIcon /> - </SidebarListItemIcon> - <SidebarListItemText bold>New Features Suggestion?</SidebarListItemText> - </SidebarListItemButton> - </a> - </ListItem> - </Track> + </Box> + </SidebarListItemIcon> + <SidebarListItemText bold>New Features Suggestion?</SidebarListItemText> + </SidebarListItemButton> + </a> + </ListItem> <ListItem> <SidebarListItemText> - <Typography variant="caption"> + <Typography variant="caption" sx={{ mx: 'auto', textAlign: 'center' }}> Supported by{' '} <SvgIcon component={ProtofireLogo} @@ -87,7 +98,7 @@ const SidebarFooter = (): ReactElement => { </Typography> </SidebarListItemText> </ListItem> - </SidebarList> + </> ) } diff --git a/apps/web/src/components/sidebar/SidebarHeader/index.tsx b/apps/web/src/components/sidebar/SidebarHeader/index.tsx index 869733670e..60c6d367f6 100644 --- a/apps/web/src/components/sidebar/SidebarHeader/index.tsx +++ b/apps/web/src/components/sidebar/SidebarHeader/index.tsx @@ -18,7 +18,7 @@ import LinkIconBold from '@/public/images/sidebar/link-bold.svg' import { selectSettings } from '@/store/settingsSlice' import { useCurrentChain } from '@/hooks/useChains' -import { getBlockExplorerLink } from '@/utils/chains' +import { getBlockExplorerLink } from '@safe-global/utils/utils/chains' import EthHashInfo from '@/components/common/EthHashInfo' import QrCodeButton from '../QrCodeButton' import Track from '@/components/common/Track' @@ -31,6 +31,8 @@ import ExplorerButton from '@/components/common/ExplorerButton' import CopyTooltip from '@/components/common/CopyTooltip' import FiatValue from '@/components/common/FiatValue' import { useAddressResolver } from '@/hooks/useAddressResolver' +import { NestedSafesButton } from '@/components/sidebar/NestedSafesButton' +import { NESTED_SAFE_EVENTS, NESTED_SAFE_LABELS } from '@/services/analytics/events/nested-safes' const SafeHeader = (): ReactElement => { const { balances } = useVisibleBalances() @@ -108,6 +110,10 @@ const SafeHeader = (): ReactElement => { <ExplorerButton {...blockExplorerLink} className={css.iconButton} icon={LinkIconBold} /> </Track> + <Track {...NESTED_SAFE_EVENTS.OPEN_LIST} label={NESTED_SAFE_LABELS.header}> + <NestedSafesButton chainId={safe.chainId} safeAddress={safe.address.value} /> + </Track> + <CounterfactualStatusButton /> <EnvHintButton /> diff --git a/apps/web/src/components/sidebar/SidebarHeader/styles.module.css b/apps/web/src/components/sidebar/SidebarHeader/styles.module.css index f5cf985566..17ae1ac7e6 100644 --- a/apps/web/src/components/sidebar/SidebarHeader/styles.module.css +++ b/apps/web/src/components/sidebar/SidebarHeader/styles.module.css @@ -1,5 +1,5 @@ .container { - padding: var(--space-2) var(--space-1); + padding: var(--space-2) var(--space-1) 0; } .info { diff --git a/apps/web/src/components/sidebar/SidebarList/index.tsx b/apps/web/src/components/sidebar/SidebarList/index.tsx index ae5e59742f..46312032dd 100644 --- a/apps/web/src/components/sidebar/SidebarList/index.tsx +++ b/apps/web/src/components/sidebar/SidebarList/index.tsx @@ -87,7 +87,7 @@ export const SidebarListItemCounter = ({ count }: { count?: string }): ReactElem backgroundColor: 'warning.light', transform: 'none', fontWeight: 'bold', - padding: '0 var(--space-1)', + padding: '0 4px', fontSize: '11px', }, ml: 3, diff --git a/apps/web/src/components/sidebar/SidebarList/styles.module.css b/apps/web/src/components/sidebar/SidebarList/styles.module.css index 9a68c1492b..c4de6bc6bd 100644 --- a/apps/web/src/components/sidebar/SidebarList/styles.module.css +++ b/apps/web/src/components/sidebar/SidebarList/styles.module.css @@ -1,6 +1,4 @@ .list { - display: grid; - gap: 4px; padding-left: 8px; padding-right: 8px; } @@ -12,8 +10,12 @@ .listItemButton { border-radius: 6px; - padding-top: 6px; - padding-bottom: 6px; + padding-top: var(--space-1); + padding-bottom: var(--space-1); +} + +.listItemButton :global .MuiListItemText-root { + margin: 0; } .list :global .MuiListItemButton-root { diff --git a/apps/web/src/components/sidebar/SidebarNavigation/config.tsx b/apps/web/src/components/sidebar/SidebarNavigation/config.tsx index a1927f83d9..b5daf6d8ca 100644 --- a/apps/web/src/components/sidebar/SidebarNavigation/config.tsx +++ b/apps/web/src/components/sidebar/SidebarNavigation/config.tsx @@ -10,6 +10,7 @@ import SettingsIcon from '@/public/images/sidebar/settings.svg' import BridgeIcon from '@/public/images/common/bridge.svg' import SwapIcon from '@/public/images/common/swap.svg' import StakeIcon from '@/public/images/common/stake.svg' +import EarnIcon from '@/public/images/common/earn.svg' import { SvgIcon } from '@mui/material' import { Chip } from '@/components/common/Chip' @@ -32,22 +33,6 @@ export const navItems: NavItem[] = [ icon: <SvgIcon component={AssetsIcon} inheritViewBox />, href: AppRoutes.balances.index, }, - { - label: 'Bridge', - icon: <SvgIcon component={BridgeIcon} inheritViewBox />, - href: AppRoutes.bridge, - tag: <Chip label="New" sx={{ backgroundColor: 'secondary.light', color: 'static.main' }} />, - }, - { - label: 'Swap', - icon: <SvgIcon component={SwapIcon} inheritViewBox />, - href: AppRoutes.swap, - }, - { - label: 'Stake', - icon: <SvgIcon component={StakeIcon} inheritViewBox />, - href: AppRoutes.stake, - }, { label: 'Transactions', icon: <SvgIcon component={TransactionIcon} inheritViewBox />, @@ -68,6 +53,27 @@ export const navItems: NavItem[] = [ icon: <SvgIcon data-testid="settings-nav-icon" component={SettingsIcon} inheritViewBox />, href: AppRoutes.settings.setup, }, + { + label: 'Swap', + icon: <SvgIcon component={SwapIcon} inheritViewBox />, + href: AppRoutes.swap, + }, + { + label: 'Bridge', + icon: <SvgIcon component={BridgeIcon} inheritViewBox />, + href: AppRoutes.bridge, + }, + { + label: 'Stake', + icon: <SvgIcon component={StakeIcon} inheritViewBox />, + href: AppRoutes.stake, + }, + { + label: 'Earn', + icon: <SvgIcon component={EarnIcon} inheritViewBox />, + href: AppRoutes.earn, + tag: <Chip label="New" sx={{ backgroundColor: 'secondary.light', color: 'static.main' }} />, + }, ] export const transactionNavItems = [ diff --git a/apps/web/src/components/sidebar/SidebarNavigation/index.tsx b/apps/web/src/components/sidebar/SidebarNavigation/index.tsx index d41a50a084..51de74ddc7 100644 --- a/apps/web/src/components/sidebar/SidebarNavigation/index.tsx +++ b/apps/web/src/components/sidebar/SidebarNavigation/index.tsx @@ -1,6 +1,6 @@ import React, { useContext, useMemo, type ReactElement } from 'react' import { useRouter } from 'next/router' -import { ListItemButton } from '@mui/material' +import { Divider, ListItemButton } from '@mui/material' import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' import { @@ -22,19 +22,28 @@ import { GeoblockingContext } from '@/components/common/GeoblockingProvider' import { STAKE_EVENTS, STAKE_LABELS } from '@/services/analytics/events/stake' import { Tooltip } from '@mui/material' import { BRIDGE_EVENTS, BRIDGE_LABELS } from '@/services/analytics/events/bridge' +import { EARN_EVENTS, EARN_LABELS } from '@/services/analytics/events/earn' +import { isNonCriticalUpdate } from '@safe-global/utils/utils/chains' const getSubdirectory = (pathname: string): string => { return pathname.split('/')[1] } -const geoBlockedRoutes = [AppRoutes.bridge, AppRoutes.swap, AppRoutes.stake] +const geoBlockedRoutes = [AppRoutes.bridge, AppRoutes.swap, AppRoutes.stake, AppRoutes.earn] -const undeployedSafeBlockedRoutes = [AppRoutes.bridge, AppRoutes.swap, AppRoutes.stake, AppRoutes.apps.index] +const undeployedSafeBlockedRoutes = [ + AppRoutes.bridge, + AppRoutes.swap, + AppRoutes.stake, + AppRoutes.apps.index, + AppRoutes.earn, +] const customSidebarEvents: { [key: string]: { event: any; label: string } } = { [AppRoutes.bridge]: { event: BRIDGE_EVENTS.OPEN_BRIDGE, label: BRIDGE_LABELS.sidebar }, [AppRoutes.swap]: { event: SWAP_EVENTS.OPEN_SWAPS, label: SWAP_LABELS.sidebar }, [AppRoutes.stake]: { event: STAKE_EVENTS.OPEN_STAKE, label: STAKE_LABELS.sidebar }, + [AppRoutes.earn]: { event: EARN_EVENTS.OPEN_EARN_PAGE, label: EARN_LABELS.sidebar }, } const Navigation = (): ReactElement => { @@ -64,7 +73,9 @@ const Navigation = (): ReactElement => { const getBadge = (item: NavItem) => { // Indicate whether the current Safe needs an upgrade if (item.href === AppRoutes.settings.setup) { - return safe.implementationVersionState === ImplementationVersionState.OUTDATED + return ( + safe.implementationVersionState === ImplementationVersionState.OUTDATED && !isNonCriticalUpdate(safe.version) + ) } } @@ -89,11 +100,18 @@ const Navigation = (): ReactElement => { const isSelected = currentSubdirectory === getSubdirectory(item.href) const isDisabled = item.disabled || !enabledNavItems.includes(item) let ItemTag = item.tag ? item.tag : null + const spaceId = router.query.spaceId + const query = { + safe: router.query.safe, + ...(spaceId && { spaceId }), + } if (item.href === AppRoutes.transactions.history) { ItemTag = queueSize ? <SidebarListItemCounter count={queueSize} /> : null } + const isSettingsItem = item.href === AppRoutes.settings.setup + return ( <Tooltip title={isDisabled ? 'You need to activate your Safe first.' : ''} @@ -112,7 +130,12 @@ const Navigation = (): ReactElement => { > <SidebarListItemButton selected={isSelected} - href={item.href && { pathname: getRoute(item.href), query: { safe: router.query.safe } }} + href={ + item.href && { + pathname: getRoute(item.href), + query, + } + } disabled={isDisabled} > {item.icon && <SidebarListItemIcon badge={getBadge(item)}>{item.icon}</SidebarListItemIcon>} @@ -124,6 +147,8 @@ const Navigation = (): ReactElement => { </SidebarListItemText> </SidebarListItemButton> </ListItemButton> + + {isSettingsItem && <Divider sx={{ mt: 1, mb: 0.5 }} />} </div> </Tooltip> ) diff --git a/apps/web/src/components/sidebar/WatchlistAddButton/index.tsx b/apps/web/src/components/sidebar/WatchlistAddButton/index.tsx deleted file mode 100644 index f0df8fff3b..0000000000 --- a/apps/web/src/components/sidebar/WatchlistAddButton/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' -import { useRouter } from 'next/router' -import { AppRoutes } from '@/config/routes' -import { useCurrentChain } from '@/hooks/useChains' -import useSafeAddress from '@/hooks/useSafeAddress' -import { Button } from '@mui/material' -import SafeListRemoveDialog from '../SafeListRemoveDialog' -import { useAppSelector } from '@/store' -import { selectAddedSafes } from '@/store/addedSafesSlice' -import { useState } from 'react' -import { VisibilityOutlined } from '@mui/icons-material' -import Track from '@/components/common/Track' - -const WatchlistAddButton = () => { - const [open, setOpen] = useState(false) - const router = useRouter() - const chain = useCurrentChain() - const address = useSafeAddress() - const chainId = chain?.chainId || '' - const addedSafes = useAppSelector((state) => selectAddedSafes(state, chainId)) - const isInWatchlist = !!addedSafes?.[address] - - const onClick = () => { - router.push({ - pathname: AppRoutes.newSafe.load, - query: { - chain: chain?.shortName, - address, - }, - }) - } - - return ( - <> - {isInWatchlist ? ( - <Track {...OVERVIEW_EVENTS.REMOVE_FROM_WATCHLIST} label={OVERVIEW_LABELS.quick_remove}> - <Button - data-testid="remove-watchlist-btn" - onClick={() => setOpen(true)} - variant="outlined" - size="small" - fullWidth - disableElevation - sx={{ py: 1.3, px: 1 }} - > - Remove account - </Button> - </Track> - ) : ( - <Track {...OVERVIEW_EVENTS.ADD_TO_WATCHLIST} label={OVERVIEW_LABELS.quick_add}> - <Button - data-testid="add-watchlist-btn" - onClick={onClick} - variant="outlined" - size="small" - fullWidth - disableElevation - sx={{ py: 1.3 }} - startIcon={<VisibilityOutlined sx={{ verticalAlign: 'middle', marginRight: 1 }} />} - > - Add read-only - </Button> - </Track> - )} - - {open && chainId && ( - <SafeListRemoveDialog handleClose={() => setOpen(false)} address={address} chainId={chainId} /> - )} - </> - ) -} - -export default WatchlistAddButton diff --git a/apps/web/src/components/theme/darkPalette.ts b/apps/web/src/components/theme/darkPalette.ts index 46818fd506..893bc17ee5 100644 --- a/apps/web/src/components/theme/darkPalette.ts +++ b/apps/web/src/components/theme/darkPalette.ts @@ -27,7 +27,7 @@ const darkPalette = { background: '#2F2527', }, success: { - dark: '#028D4C', + dark: '#388E3C', main: '#00B460', light: '#81C784', background: '#1F2920', @@ -49,6 +49,7 @@ const darkPalette = { main: '#121312', paper: '#1C1C1C', light: '#1B2A22', + lightGrey: '#303033', }, backdrop: { main: '#636669', @@ -59,6 +60,7 @@ const darkPalette = { }, static: { main: '#121312', + light: '#636669', }, } diff --git a/apps/web/src/components/theme/lightPalette.ts b/apps/web/src/components/theme/lightPalette.ts index a47daca3f6..661ba05427 100644 --- a/apps/web/src/components/theme/lightPalette.ts +++ b/apps/web/src/components/theme/lightPalette.ts @@ -29,13 +29,13 @@ const lightPalette = { success: { dark: '#028D4C', main: '#00B460', - light: '#72F5B8', + light: '#D3F2E4', background: '#EFFAF1', }, info: { dark: '#52BFDC', main: '#5FDDFF', - light: '#B7F0FF', + light: '#D7F6FF', background: '#EFFCFF', }, warning: { @@ -49,6 +49,7 @@ const lightPalette = { main: '#F4F4F4', paper: '#FFFFFF', light: '#EFFFF4', + lightGrey: '#DDDEE0', }, backdrop: { main: '#636669', @@ -59,6 +60,7 @@ const lightPalette = { }, static: { main: '#121312', + light: '#636669', }, } diff --git a/apps/web/src/components/theme/safeTheme.ts b/apps/web/src/components/theme/safeTheme.ts index b3ec8675b0..90058e5e9f 100644 --- a/apps/web/src/components/theme/safeTheme.ts +++ b/apps/web/src/components/theme/safeTheme.ts @@ -28,6 +28,7 @@ declare module '@mui/material/styles' { export interface TypeBackground { main: string light: string + lightGrey: string } // Custom color properties @@ -54,6 +55,8 @@ declare module '@mui/material/Button' { export interface ButtonPropsColorOverrides { background: true + static: true + 'background.paper': true } export interface ButtonPropsVariantOverrides { @@ -104,7 +107,7 @@ const createSafeTheme = (mode: PaletteMode): Theme => { { props: { size: 'compact' }, style: { - padding: '8px 12px', + padding: '8px 16px', }, }, { @@ -113,6 +116,27 @@ const createSafeTheme = (mode: PaletteMode): Theme => { padding: '12px 48px', }, }, + { + props: { color: 'background.paper' }, + style: ({ theme }) => ({ + backgroundColor: theme.palette.background.paper, + color: theme.palette.text.primary, + '&:hover': { + backgroundColor: theme.palette.background.main, + }, + }), + }, + + { + props: { color: 'background' }, + style: ({ theme }) => ({ + backgroundColor: theme.palette.background.main, + color: theme.palette.text.primary, + '&:hover': { + backgroundColor: theme.palette.background.lightGrey, + }, + }), + }, { props: { variant: 'danger' }, style: ({ theme }) => ({ diff --git a/apps/web/src/components/transactions/BulkTxListGroup/index.tsx b/apps/web/src/components/transactions/BulkTxListGroup/index.tsx index 935c2254c2..27ad66dbf1 100644 --- a/apps/web/src/components/transactions/BulkTxListGroup/index.tsx +++ b/apps/web/src/components/transactions/BulkTxListGroup/index.tsx @@ -6,7 +6,7 @@ import ExpandableTransactionItem from '@/components/transactions/TxListItem/Expa import BatchIcon from '@/public/images/common/batch.svg' import css from './styles.module.css' import ExplorerButton from '@/components/common/ExplorerButton' -import { getBlockExplorerLink } from '@/utils/chains' +import { getBlockExplorerLink } from '@safe-global/utils/utils/chains' import { useCurrentChain } from '@/hooks/useChains' import { getOrderClass } from '@/features/swap/helpers/utils' diff --git a/apps/web/src/components/transactions/GroupedTxListItems/index.tsx b/apps/web/src/components/transactions/GroupedTxListItems/index.tsx index a58334385a..43b087204b 100644 --- a/apps/web/src/components/transactions/GroupedTxListItems/index.tsx +++ b/apps/web/src/components/transactions/GroupedTxListItems/index.tsx @@ -7,7 +7,8 @@ import ExpandableTransactionItem from '@/components/transactions/TxListItem/Expa import css from './styles.module.css' import { ReplaceTxHoverContext, ReplaceTxHoverProvider } from './ReplaceTxHoverProvider' import ExternalLink from '@/components/common/ExternalLink' -import { HelpCenterArticle } from '@/config/constants' + +import { HelpCenterArticle } from '@safe-global/utils/config/constants' const Disclaimer = () => ( <Typography> diff --git a/apps/web/src/components/transactions/HexEncodedData/HexEncodedData.test.tsx b/apps/web/src/components/transactions/HexEncodedData/HexEncodedData.test.tsx index 6aa788bcd7..f0a949cfa8 100644 --- a/apps/web/src/components/transactions/HexEncodedData/HexEncodedData.test.tsx +++ b/apps/web/src/components/transactions/HexEncodedData/HexEncodedData.test.tsx @@ -1,8 +1,11 @@ -import { render } from '@/tests/test-utils' +import { fireEvent, render, screen } from '@/tests/test-utils' import { HexEncodedData } from '.' const hexData = '0xed2ad31ed00088fc64d00c49774b2fe3fb7fd7db1c2a714700892607b9f77dc1' +const longHexData = + '0xb460af94123400000000000000000000000000000000000000000000000000000000000186a00000000000000000000000009a1148b5d6a2d34ca46111379d0fd1352a0ade4a0000000000000000000000009a1148b5d6a2d34ca46111379d0fd1352a0ade4a' + describe('HexEncodedData', () => { it('should render the default component markup', () => { const result = render(<HexEncodedData hexData={hexData} title="Data (hex-encoded)" />) @@ -10,12 +13,9 @@ describe('HexEncodedData', () => { const tooltipComponent = result.getByLabelText( 'The first 4 bytes determine the contract method that is being called', ) - const copyButton = result.getByTestId('copy-btn-icon') - expect(showMoreButton).toBeInTheDocument() expect(showMoreButton).toHaveTextContent('Show more') expect(tooltipComponent).toBeInTheDocument() - expect(copyButton).toBeInTheDocument() expect(result.container).toMatchSnapshot() }) @@ -36,4 +36,35 @@ describe('HexEncodedData', () => { expect(result.container).toMatchSnapshot() }) + + it('should show the full data when expanded', () => { + render(<HexEncodedData hexData={longHexData} limit={20} title="Data (hex-encoded)" />) + + // Initially should show shortened data + const initialData = screen.getByTestId('tx-hexData') + expect(initialData).toHaveTextContent(`${longHexData.slice(0, 20)}… Show more`) + + // Click show more + const showMoreButton = screen.getByTestId('show-more') + fireEvent.click(showMoreButton) + + // Should now show full data + expect(initialData).toHaveTextContent(longHexData) + + // Check that we have tree blocks of dimmed zeroes + const zeroesBlocks = initialData.querySelectorAll('span.zeroes') + expect(zeroesBlocks).toHaveLength(3) + expect(zeroesBlocks[0].textContent).toHaveLength(59) + expect(zeroesBlocks[1].textContent).toHaveLength(25) + expect(zeroesBlocks[2].textContent).toHaveLength(24) + // Show less button should be visible + expect(showMoreButton).toHaveTextContent('Show less') + + // Click show less + fireEvent.click(showMoreButton) + + // Should be back to shortened data + expect(initialData).toHaveTextContent(`${longHexData.slice(0, 20)}… Show more`) + expect(showMoreButton).toHaveTextContent('Show more') + }) }) diff --git a/apps/web/src/components/transactions/HexEncodedData/__snapshots__/HexEncodedData.test.tsx.snap b/apps/web/src/components/transactions/HexEncodedData/__snapshots__/HexEncodedData.test.tsx.snap index 0cb3f80cdf..35a45fa142 100644 --- a/apps/web/src/components/transactions/HexEncodedData/__snapshots__/HexEncodedData.test.tsx.snap +++ b/apps/web/src/components/transactions/HexEncodedData/__snapshots__/HexEncodedData.test.tsx.snap @@ -3,18 +3,18 @@ exports[`HexEncodedData should not cut the text in case the limit option is higher than the provided hexData 1`] = ` <div> <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + class="MuiGrid-root MuiGrid-container css-1yff1ei-MuiGrid-root" > <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + class="MuiGrid-root MuiGrid-item css-ezmk0c-MuiGrid-root" data-testid="tx-row-title" style="word-break: break-word;" > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + <span + class="MuiTypography-root MuiTypography-body2 css-1ew0eu5-MuiTypography-root" > Data (hex-encoded) - </p> + </span> </div> <div class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" @@ -30,29 +30,20 @@ exports[`HexEncodedData should not cut the text in case the limit option is high data-mui-internal-clone-element="true" style="cursor: pointer;" > - <button - aria-label="Copy to clipboard" - class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" - tabindex="0" - type="button" + <span + class="monospace" > - <mock-icon - aria-hidden="" - class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" - data-testid="copy-btn-icon" - focusable="false" - /> - </button> + <b + aria-label="The first 4 bytes determine the contract method that is being called" + class="" + data-mui-internal-clone-element="true" + > + 0xed2ad31e + </b> + d00088fc64d00c49774b2fe3fb7fd7db1c2a714700892607b9f77dc1 + + </span> </span> - <b - aria-label="The first 4 bytes determine the contract method that is being called" - class="" - data-mui-internal-clone-element="true" - > - 0xed2ad31e - </b> - d00088fc64d00c49774b2fe3fb7fd7db1c2a714700892607b9f77dc1 - </div> </div> </div> @@ -62,18 +53,18 @@ exports[`HexEncodedData should not cut the text in case the limit option is high exports[`HexEncodedData should not highlight the data if highlight option is false 1`] = ` <div> <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + class="MuiGrid-root MuiGrid-container css-1yff1ei-MuiGrid-root" > <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + class="MuiGrid-root MuiGrid-item css-ezmk0c-MuiGrid-root" data-testid="tx-row-title" style="word-break: break-word;" > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + <span + class="MuiTypography-root MuiTypography-body2 css-1ew0eu5-MuiTypography-root" > Some arbitrary data - </p> + </span> </div> <div class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" @@ -89,30 +80,13 @@ exports[`HexEncodedData should not highlight the data if highlight option is fal data-mui-internal-clone-element="true" style="cursor: pointer;" > - <button - aria-label="Copy to clipboard" - class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" - tabindex="0" - type="button" + <span + class="monospace" > - <mock-icon - aria-hidden="" - class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" - data-testid="copy-btn-icon" - focusable="false" - /> - </button> + 0x102384763718984309876 + + </span> </span> - 0x10238476... - - <button - class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineAlways MuiLink-button css-5r1yio-MuiTypography-root-MuiLink-root" - data-testid="show-more" - type="button" - > - Show - more - </button> </div> </div> </div> @@ -122,18 +96,18 @@ exports[`HexEncodedData should not highlight the data if highlight option is fal exports[`HexEncodedData should render the default component markup 1`] = ` <div> <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + class="MuiGrid-root MuiGrid-container css-1yff1ei-MuiGrid-root" > <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + class="MuiGrid-root MuiGrid-item css-ezmk0c-MuiGrid-root" data-testid="tx-row-title" style="word-break: break-word;" > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + <span + class="MuiTypography-root MuiTypography-body2 css-1ew0eu5-MuiTypography-root" > Data (hex-encoded) - </p> + </span> </div> <div class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" @@ -149,36 +123,26 @@ exports[`HexEncodedData should render the default component markup 1`] = ` data-mui-internal-clone-element="true" style="cursor: pointer;" > - <button - aria-label="Copy to clipboard" - class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" - tabindex="0" - type="button" + <span + class="monospace" > - <mock-icon - aria-hidden="" - class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" - data-testid="copy-btn-icon" - focusable="false" - /> - </button> + <b + aria-label="The first 4 bytes determine the contract method that is being called" + class="" + data-mui-internal-clone-element="true" + > + 0xed2ad31e + </b> + d00088fc64… + + </span> </span> - <b - aria-label="The first 4 bytes determine the contract method that is being called" - class="" - data-mui-internal-clone-element="true" - > - 0xed2ad31e - </b> - d00088fc64... - <button - class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineAlways MuiLink-button css-5r1yio-MuiTypography-root-MuiLink-root" + class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineAlways MuiLink-button showMore css-izn2ge-MuiTypography-root-MuiLink-root" data-testid="show-more" type="button" > - Show - more + Show more </button> </div> </div> diff --git a/apps/web/src/components/transactions/HexEncodedData/index.tsx b/apps/web/src/components/transactions/HexEncodedData/index.tsx index fe635c5dcf..39c23ba23f 100644 --- a/apps/web/src/components/transactions/HexEncodedData/index.tsx +++ b/apps/web/src/components/transactions/HexEncodedData/index.tsx @@ -1,7 +1,7 @@ -import { shortenText } from '@/utils/formatters' +import { shortenText } from '@safe-global/utils/utils/formatters' import { Box, Link, Tooltip } from '@mui/material' -import type { ReactElement } from 'react' -import { useState } from 'react' +import type { ReactElement, SyntheticEvent } from 'react' +import { Fragment, useState } from 'react' import css from './styles.module.css' import CopyButton from '@/components/common/CopyButton' import FieldsGrid from '@/components/tx/FieldsGrid' @@ -15,11 +15,17 @@ interface Props { const FIRST_BYTES = 10 +const SHOW_MORE = 'Show more' +const SHOW_LESS = 'Show less' + export const HexEncodedData = ({ hexData, title, highlightFirstBytes = true, limit = 20 }: Props): ReactElement => { const [showTxData, setShowTxData] = useState(false) - const showExpandBtn = hexData.length > limit + // Check if + const showExpandBtn = hexData.length > limit + SHOW_MORE.length + 2 // 2 for the space and the ellipsis - const toggleExpanded = () => { + const toggleExpanded = (e: SyntheticEvent) => { + e.stopPropagation() + e.preventDefault() setShowTxData((val) => !val) } @@ -30,25 +36,42 @@ export const HexEncodedData = ({ hexData, title, highlightFirstBytes = true, lim ) : null const restBytes = highlightFirstBytes ? hexData.slice(FIRST_BYTES) : hexData + const dimmedZeroes: ReactElement[] = [] + let index = 0 + const remainder = restBytes.replace(/(.*?)(0{18,})/g, (_, p1, p2) => { + dimmedZeroes.push( + <Fragment key={index++}>{p1}</Fragment>, + <span className={css.zeroes} key={index++}> + {p2} + </span>, + ) + return '' + }) + + dimmedZeroes.push(<Fragment key={index++}>{remainder}</Fragment>) + + const fullData = dimmedZeroes.length ? dimmedZeroes : restBytes + const content = ( <Box data-testid="tx-hexData" className={css.encodedData}> - <CopyButton text={hexData} /> - - <> - {firstBytes} - {showTxData || !showExpandBtn ? restBytes : shortenText(restBytes, limit - FIRST_BYTES)}{' '} - {showExpandBtn && ( - <Link - component="button" - data-testid="show-more" - onClick={toggleExpanded} - type="button" - sx={{ verticalAlign: 'text-top' }} - > - Show {showTxData ? 'less' : 'more'} - </Link> - )} - </> + <CopyButton text={hexData}> + <span className={css.monospace}> + {firstBytes} + {showTxData || !showExpandBtn ? fullData : shortenText(restBytes, limit - FIRST_BYTES)}{' '} + </span> + </CopyButton> + + {showExpandBtn && ( + <Link + component="button" + data-testid="show-more" + onClick={toggleExpanded} + type="button" + className={css.showMore} + > + {showTxData ? SHOW_LESS : SHOW_MORE} + </Link> + )} </Box> ) diff --git a/apps/web/src/components/transactions/HexEncodedData/styles.module.css b/apps/web/src/components/transactions/HexEncodedData/styles.module.css index dacdb7edc3..d63633046e 100644 --- a/apps/web/src/components/transactions/HexEncodedData/styles.module.css +++ b/apps/web/src/components/transactions/HexEncodedData/styles.module.css @@ -1,4 +1,17 @@ .encodedData { line-break: anywhere; word-break: break-all; + line-height: 1.1; +} + +.showMore { + white-space: nowrap; +} + +.monospace { + font-family: monospace; +} + +.zeroes { + opacity: 0.75; } diff --git a/apps/web/src/components/transactions/QueuedTxSimulation/index.tsx b/apps/web/src/components/transactions/QueuedTxSimulation/index.tsx new file mode 100644 index 0000000000..895d91ece7 --- /dev/null +++ b/apps/web/src/components/transactions/QueuedTxSimulation/index.tsx @@ -0,0 +1,142 @@ +import useSafeInfo from '@/hooks/useSafeInfo' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { type SafeInfo, type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { createExistingTx } from '@/services/tx/tx-sender' +import useChainId from '@/hooks/useChainId' +import useAsync from '@safe-global/utils/hooks/useAsync' +import { useSimulation } from '@/components/tx/security/tenderly/useSimulation' +import TenderlyIcon from '@/public/images/transactions/tenderly-small.svg' +import { ButtonBase, CircularProgress, Stack, SvgIcon, Typography } from '@mui/material' +import { useSigner } from '@/hooks/wallets/useWallet' +import ExternalLink from '@/components/common/ExternalLink' +import CheckIcon from '@/public/images/common/check.svg' +import CloseIcon from '@/public/images/common/close.svg' +import { getSimulationStatus, isTxSimulationEnabled } from '@safe-global/utils/components/tx/security/tenderly/utils' +import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' +import { useIsNestedSafeOwner } from '@/hooks/useIsNestedSafeOwner' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import { useMemo } from 'react' +import { useCurrentChain } from '@/hooks/useChains' + +const CompactSimulationButton = ({ + label, + iconComponent, + disabled = false, + onClick, +}: { + label: string + iconComponent: React.ReactNode + disabled?: boolean + onClick?: () => void +}) => { + return ( + <ButtonBase + disabled={disabled} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.5, + flexDirection: 'row', + borderRadius: '8px', + backgroundColor: 'background.main', + padding: '4px 16px', + // This is required as the icon otherwise disappears when the first tx accordion is closed + visibility: 'visible !important', + }} + onClick={onClick} + > + {iconComponent} + <Typography variant="subtitle2" fontWeight={700}> + {label} + </Typography> + </ButtonBase> + ) +} + +const InlineTxSimulation = ({ transaction }: { transaction: TransactionDetails }) => { + const { safe } = useSafeInfo() + const isSafeOwner = useIsSafeOwner() + const isNestedSafeOwner = useIsNestedSafeOwner() + const chainId = useChainId() + const signer = useSigner() + const sdk = useSafeSDK() + + const canSimulate = isSafeOwner || isNestedSafeOwner + + const [safeTransaction, safeTransactionError] = useAsync( + () => (sdk ? createExistingTx(chainId, transaction.txId, transaction) : undefined), + [chainId, transaction, sdk], + ) + + const executionOwner = useMemo( + () => + safe.owners.some((owner) => sameAddress(owner.value, signer?.address)) ? signer?.address : safe.owners[0]?.value, + [safe.owners, signer?.address], + ) + + const simulation = useSimulation() + const { simulationLink, simulateTransaction } = simulation + const status = simulation ? getSimulationStatus(simulation) : undefined + + const handleSimulation = () => { + if (safeTransaction && executionOwner) { + simulateTransaction({ executionOwner, transactions: safeTransaction, safe: safe as SafeInfo }) + } + } + + if (safeTransactionError || !canSimulate || !executionOwner) { + return null + } + + if (status?.isLoading) { + return <CompactSimulationButton label="Simulating" iconComponent={<CircularProgress size={16} />} disabled={true} /> + } + + if (!status?.isFinished) { + return ( + <CompactSimulationButton + label="Simulate" + iconComponent={<SvgIcon component={TenderlyIcon} inheritViewBox sx={{ height: '16px' }} />} + disabled={!safeTransaction} + onClick={handleSimulation} + /> + ) + } + + if (status?.isFinished && !status.isError) { + return ( + <ExternalLink href={simulationLink}> + <Stack direction="row" alignItems="center" gap={0.5}> + <SvgIcon + color={status.isSuccess ? 'success' : 'error'} + component={status.isSuccess ? CheckIcon : CloseIcon} + inheritViewBox + sx={{ height: '16px' }} + /> + {status.isSuccess ? 'Simulation successful' : 'Simulation failed'} + </Stack> + </ExternalLink> + ) + } + + if (status?.isError) { + return ( + <Stack direction="row" alignItems="center" gap={0.5}> + <SvgIcon color="error" component={CloseIcon} inheritViewBox sx={{ height: '16px' }} /> + Error while simulating + </Stack> + ) + } + + return null +} + +export const QueuedTxSimulation = ({ transaction }: { transaction: TransactionDetails }) => { + const chain = useCurrentChain() + + if (!chain || !isTxSimulationEnabled(chain)) { + return null + } + + return <InlineTxSimulation transaction={transaction} /> +} diff --git a/apps/web/src/components/transactions/SafeCreationTx/index.tsx b/apps/web/src/components/transactions/SafeCreationTx/index.tsx index 8cce5e8293..76bce11141 100644 --- a/apps/web/src/components/transactions/SafeCreationTx/index.tsx +++ b/apps/web/src/components/transactions/SafeCreationTx/index.tsx @@ -5,7 +5,7 @@ import css from './styles.module.css' import { InfoDetails } from '@/components/transactions/InfoDetails' import EthHashInfo from '@/components/common/EthHashInfo' import { generateDataRowValue, TxDataRow } from '@/components/transactions/TxDetails/Summary/TxDataRow' -import { dateString } from '@/utils/formatters' +import { dateString } from '@safe-global/utils/utils/formatters' import { isCreationTxInfo } from '@/utils/transaction-guards' import { NOT_AVAILABLE } from '@/components/transactions/TxDetails' diff --git a/apps/web/src/components/transactions/SignedMessagesHelpLink/index.tsx b/apps/web/src/components/transactions/SignedMessagesHelpLink/index.tsx index 6c3cfafe14..f5bcf7bf0c 100644 --- a/apps/web/src/components/transactions/SignedMessagesHelpLink/index.tsx +++ b/apps/web/src/components/transactions/SignedMessagesHelpLink/index.tsx @@ -3,7 +3,8 @@ import InfoIcon from '@/public/images/notifications/info.svg' import ExternalLink from '@/components/common/ExternalLink' import { useAppSelector } from '@/store' import { selectSafeMessages } from '@/store/safeMessagesSlice' -import { HelpCenterArticle } from '@/config/constants' + +import { HelpCenterArticle } from '@safe-global/utils/config/constants' const SignedMessagesHelpLink = () => { const safeMessages = useAppSelector(selectSafeMessages) diff --git a/apps/web/src/components/transactions/SingleTx/index.tsx b/apps/web/src/components/transactions/SingleTx/index.tsx index 034630d3e9..18a8088a06 100644 --- a/apps/web/src/components/transactions/SingleTx/index.tsx +++ b/apps/web/src/components/transactions/SingleTx/index.tsx @@ -3,7 +3,7 @@ import { useRouter } from 'next/router' import useSafeInfo from '@/hooks/useSafeInfo' import type { Label, Transaction, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { LabelValue } from '@safe-global/safe-gateway-typescript-sdk' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import type { ReactElement } from 'react' import { useEffect } from 'react' import { makeTxFromDetails } from '@/utils/transactions' @@ -15,7 +15,7 @@ import GroupLabel from '../GroupLabel' import { isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards' import { useGetTransactionDetailsQuery } from '@/store/api/gateway' import { skipToken } from '@reduxjs/toolkit/query/react' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' const SingleTxGrid = ({ txDetails }: { txDetails: TransactionDetails }): ReactElement => { const tx: Transaction = makeTxFromDetails(txDetails) diff --git a/apps/web/src/components/transactions/TrustedToggle/index.tsx b/apps/web/src/components/transactions/TrustedToggle/index.tsx index dfb513fb7e..d25bf86b65 100644 --- a/apps/web/src/components/transactions/TrustedToggle/index.tsx +++ b/apps/web/src/components/transactions/TrustedToggle/index.tsx @@ -1,9 +1,9 @@ import { useHasFeature } from '@/hooks/useChains' import { useAppDispatch, useAppSelector } from '@/store' import { selectSettings, hideSuspiciousTransactions } from '@/store/settingsSlice' -import { FEATURES } from '@/utils/chains' import madProps from '@/utils/mad-props' import _TrustedToggleButton from './TrustedToggleButton' +import { FEATURES } from '@safe-global/utils/utils/chains' const useOnlyTrusted = () => { const userSettings = useAppSelector(selectSettings) diff --git a/apps/web/src/components/transactions/TxDateLabel/index.tsx b/apps/web/src/components/transactions/TxDateLabel/index.tsx index 1b6e134cbb..e81fbad3fe 100644 --- a/apps/web/src/components/transactions/TxDateLabel/index.tsx +++ b/apps/web/src/components/transactions/TxDateLabel/index.tsx @@ -1,7 +1,8 @@ -import type { DateLabel, SafeMessageDateLabel } from '@safe-global/safe-gateway-typescript-sdk' +import type { DateLabel as SafeMessageDateLabel } from '@safe-global/store/gateway/AUTO_GENERATED/messages' +import type { DateLabel } from '@safe-global/safe-gateway-typescript-sdk' import type { ReactElement } from 'react' -import { formatWithSchema } from '@/utils/date' +import { formatWithSchema } from '@safe-global/utils/utils/date' import css from './styles.module.css' diff --git a/apps/web/src/components/transactions/TxDetails/SafeTxGasForm.tsx b/apps/web/src/components/transactions/TxDetails/SafeTxGasForm.tsx index 319d4fb34f..3b30d54e94 100644 --- a/apps/web/src/components/transactions/TxDetails/SafeTxGasForm.tsx +++ b/apps/web/src/components/transactions/TxDetails/SafeTxGasForm.tsx @@ -4,7 +4,8 @@ import { useForm } from 'react-hook-form' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import NumberField from '@/components/common/NumberField' import useSafeInfo from '@/hooks/useSafeInfo' -import { isLegacyVersion } from '@/hooks/coreSDK/safeCoreSDK' + +import { isLegacyVersion } from '@safe-global/utils/services/contracts/utils' type FormFields = { safeTxGas: string @@ -55,10 +56,10 @@ const Form = ({ onSubmit }: { onSubmit: () => void }) => { } const SafeTxGasForm = () => { - const { safeTx, safeTxGas = 0 } = useContext(SafeTxContext) + const { safeTx, safeTxGas = 0, isReadOnly } = useContext(SafeTxContext) const { safe } = useSafeInfo() const isOldSafe = safe.version && isLegacyVersion(safe.version) - const isEditable = safeTx?.signatures.size === 0 && (Number(safeTxGas) > 0 || isOldSafe) + const isEditable = !isReadOnly && safeTx?.signatures.size === 0 && (Number(safeTxGas) > 0 || isOldSafe) const [editing, setEditing] = useState(false) return ( diff --git a/apps/web/src/components/transactions/TxDetails/Summary/DecoderLinks.tsx b/apps/web/src/components/transactions/TxDetails/Summary/DecoderLinks.tsx new file mode 100644 index 0000000000..f6c55507c6 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/Summary/DecoderLinks.tsx @@ -0,0 +1,13 @@ +import ExternalLink from '@/components/common/ExternalLink' +import { Typography } from '@mui/material' + +const TX_DECODER_URL = 'https://transaction-decoder.pages.dev' + +const DecoderLinks = () => ( + <Typography variant="body2" color="primary.light" mb={3}> + Cross-verify your transaction data with external tool like{' '} + <ExternalLink href={TX_DECODER_URL}>Transaction Decoder</ExternalLink>. + </Typography> +) + +export default DecoderLinks diff --git a/apps/web/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx b/apps/web/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx index be21c59cd8..46803da462 100644 --- a/apps/web/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx @@ -1,37 +1,89 @@ +import { useMemo } from 'react' +import { Stack } from '@mui/material' import { TxDataRow, generateDataRowValue } from '../TxDataRow' -import { type SafeTransactionData, type SafeVersion } from '@safe-global/safe-core-sdk-types' -import useSafeAddress from '@/hooks/useSafeAddress' -import useChainId from '@/hooks/useChainId' +import { type SafeTransactionData, type SafeVersion } from '@safe-global/types-kit' +import { calculateSafeTransactionHash } from '@safe-global/protocol-kit/dist/src/utils' +import useSafeInfo from '@/hooks/useSafeInfo' import { getDomainHash, getSafeTxMessageHash } from '@/utils/safe-hashes' export const SafeTxHashDataRow = ({ - safeTxHash, safeTxData, - safeVersion, + safeTxHash, }: { - safeTxHash: string - safeTxData?: SafeTransactionData - safeVersion: SafeVersion + safeTxData: SafeTransactionData + safeTxHash?: string }) => { - const chainId = useChainId() - const safeAddress = useSafeAddress() - - const domainHash = getDomainHash({ chainId, safeAddress, safeVersion }) - const messageHash = safeTxData ? getSafeTxMessageHash({ safeVersion, safeTxData }) : undefined + const domainHash = useDomainHash() + const messageHash = useMessageHash({ safeTxData }) + const computedSafeTxHash = useSafeTxHash({ safeTxData, safeTxHash }) return ( - <> - <TxDataRow datatestid="tx-safe-hash" title="safeTxHash:"> - {generateDataRowValue(safeTxHash, 'hash')} - </TxDataRow> + <Stack gap={1}> <TxDataRow datatestid="tx-domain-hash" title="Domain hash:"> - {generateDataRowValue(domainHash, 'hash')} + {generateDataRowValue(domainHash ?? '', 'rawData')} </TxDataRow> {messageHash && ( <TxDataRow datatestid="tx-message-hash" title="Message hash:"> - {generateDataRowValue(messageHash, 'hash')} + {generateDataRowValue(messageHash, 'rawData')} </TxDataRow> )} - </> + <TxDataRow datatestid="tx-safe-hash" title="safeTxHash:"> + {generateDataRowValue(computedSafeTxHash ?? '', 'rawData')} + </TxDataRow> + </Stack> ) } + +export function useDomainHash(): string | null { + const { safe, safeAddress } = useSafeInfo() + + return useMemo(() => { + if (!safe.version) { + return null + } + try { + return getDomainHash({ chainId: safe.chainId, safeAddress, safeVersion: safe.version as SafeVersion }) + } catch { + return null + } + }, [safe.chainId, safe.version, safeAddress]) +} + +export function useMessageHash({ safeTxData }: { safeTxData: SafeTransactionData }): string | null { + const { safe } = useSafeInfo() + + return useMemo(() => { + if (!safe.version) { + return null + } + try { + return getSafeTxMessageHash({ safeVersion: safe.version as SafeVersion, safeTxData }) + } catch { + return null + } + }, [safe.version, safeTxData]) +} + +export function useSafeTxHash({ + safeTxData, + safeTxHash, +}: { + safeTxData: SafeTransactionData + safeTxHash?: string +}): string | null { + const { safe, safeAddress } = useSafeInfo() + + return useMemo(() => { + if (safeTxHash) { + return safeTxHash + } + if (!safe.version) { + return null + } + try { + return calculateSafeTransactionHash(safeAddress, safeTxData, safe.version, BigInt(safe.chainId)) + } catch { + return null + } + }, [safeTxData, safe.chainId, safe.version, safeAddress, safeTxHash]) +} diff --git a/apps/web/src/components/transactions/TxDetails/Summary/TxDataRow/index.tsx b/apps/web/src/components/transactions/TxDetails/Summary/TxDataRow/index.tsx index 0ce7dd1fea..18dea5648f 100644 --- a/apps/web/src/components/transactions/TxDetails/Summary/TxDataRow/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/Summary/TxDataRow/index.tsx @@ -2,8 +2,8 @@ import type { ReactElement } from 'react' import type { AddressEx } from '@safe-global/safe-gateway-typescript-sdk' import { HexEncodedData } from '@/components/transactions/HexEncodedData' import { Typography } from '@mui/material' -import EthHashInfo from '@/components/common/EthHashInfo' import { DataRow } from '@/components/common/Table/DataRow' +import NamedAddressInfo from '@/components/common/NamedAddressInfo' export const TxDataRow = DataRow @@ -21,19 +21,32 @@ export const generateDataRowValue = ( const customAvatar = addressInfo?.logoUri return ( - <EthHashInfo - address={value} - name={addressInfo?.name} - customAvatar={customAvatar} - showAvatar={!!customAvatar} - hasExplorer={hasExplorer} - showCopyButton - /> + <Typography variant="body2" component="span"> + <NamedAddressInfo + address={value} + name={addressInfo?.name} + customAvatar={customAvatar} + showAvatar={type === 'address'} + avatarSize={20} + showPrefix={false} + shortAddress={type !== 'address'} + hasExplorer={hasExplorer} + highlight4bytes + /> + </Typography> ) case 'rawData': case 'bytes': - return <HexEncodedData highlightFirstBytes={false} limit={60} hexData={value} /> + return ( + <Typography variant="body2" component="span"> + <HexEncodedData highlightFirstBytes={false} limit={66} hexData={value} /> + </Typography> + ) default: - return <Typography sx={{ wordBreak: 'break-all' }}>{value}</Typography> + return ( + <Typography variant="body2" sx={{ wordBreak: 'break-all' }} component="span"> + {value} + </Typography> + ) } } diff --git a/apps/web/src/components/transactions/TxDetails/Summary/index.test.tsx b/apps/web/src/components/transactions/TxDetails/Summary/index.test.tsx new file mode 100644 index 0000000000..97634af8a5 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/Summary/index.test.tsx @@ -0,0 +1,311 @@ +import { fireEvent, render, within } from '@/tests/test-utils' +import { type SafeTransaction } from '@safe-global/types-kit' +import DecodedTx from '.' +import { waitFor } from '@testing-library/react' +import { createMockTransactionDetails } from '@/tests/transactions' +import { + DetailedExecutionInfoType, + SettingsInfoType, + TransactionInfoType, + TransactionTokenType, + TransferDirection, +} from '@safe-global/safe-gateway-typescript-sdk' +import type { DecodedDataResponse, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' + +jest.mock('@next/third-parties/google') + +const txDetails = createMockTransactionDetails({ + txInfo: { + type: TransactionInfoType.SETTINGS_CHANGE, + humanDescription: 'Add new owner 0xd8dA...6045 with threshold 1', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + }, + ], + }, + settingsInfo: { + type: SettingsInfoType.ADD_OWNER, + owner: { + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + name: 'Nevinha', + logoUri: 'http://something.com', + }, + threshold: 1, + }, + }, + txData: { + hexData: + '0x0d582f13000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000000000000000001', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + }, + ], + }, + to: { + value: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', + name: '', + }, + value: '0', + operation: 0, + trustedDelegateCallTarget: false, + addressInfoIndex: { + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045': { + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + name: 'MetaMultiSigWallet', + }, + }, + }, + detailedExecutionInfo: { + type: DetailedExecutionInfoType.MULTISIG, + submittedAt: 1726064794013, + nonce: 4, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + name: 'MetaMultiSigWallet', + }, + safeTxHash: '0x96a96c11b8d013ff5d7a6ce960b22e961046cfa42eff422ac71c1daf6adef2e0', + signers: [ + { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: '', + }, + ], + confirmationsRequired: 1, + confirmations: [], + rejectors: [], + trusted: false, + proposer: { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: '', + }, + proposedByDelegate: { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: '', + }, + }, +}) +describe('DecodedTx', () => { + it('should render a native transfer', async () => { + const result = render( + <DecodedTx + safeTxData={ + { + to: '0x474e5Ded6b5D078163BFB8F6dBa355C3aA5478C8', + value: '40737664983361196', + data: '0x', + operation: 0, + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: 36, + safeTxGas: '0', + } as SafeTransaction['data'] + } + txInfo={{ + type: TransactionInfoType.TRANSFER, + sender: { + value: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', + }, + recipient: { + value: '0x474e5Ded6b5D078163BFB8F6dBa355C3aA5478C8', + }, + direction: TransferDirection.OUTGOING, + transferInfo: { + type: TransactionTokenType.NATIVE_COIN, + value: '40737664983361196', + }, + }} + txData={{ + hexData: '0x', + dataDecoded: undefined, + to: { + value: '0x474e5Ded6b5D078163BFB8F6dBa355C3aA5478C8', + }, + value: '40737664983361196', + operation: 0, + trustedDelegateCallTarget: true, + addressInfoIndex: undefined, + }} + />, + ) + + await waitFor(() => { + expect(result.queryByText('native transfer')).toBeInTheDocument() + }) + + fireEvent.click(result.getByText('Transaction details')) + + await waitFor(() => { + const dataField = result.queryAllByText('Data').pop() + const valueField = result.queryAllByText('Value').pop() + + expect(dataField).toBeInTheDocument() + if (dataField) { + const value = within(dataField.parentElement!.parentElement!).queryByText('0x') + expect(value).toBeInTheDocument() + } + + expect(valueField).toBeInTheDocument() + if (valueField) { + const value = within(valueField.parentElement!.parentElement!).queryByText('40737664983361196') + expect(value).toBeInTheDocument() + } + expect(result.queryAllByText('SafeTxGas').pop()).toBeInTheDocument() + }) + }) + + it('should render a transfer with custom data details', async () => { + const result = render( + <DecodedTx + safeTxData={ + { + to: '0x3430d04E42a722c5Ae52C5Bffbf1F230C2677600', + value: '1000000', + data: '0x000001ad6abfb9ea000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000098000000000000000000000000000000000000000000000000000000000000008e4ee8f0b86000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000000000000cd00000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000808415565b0000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000000f1bd50a00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000004c000000000000000000000000000000000000000000000000000000000000005c0000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000000000000210000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000002556e6973776170563200000000000000000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000000f21a484000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000f164fc0ec4e93095b804a4795bbe1e041497b92a00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000005cf7a000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000010000000000000000000000000000000000000110000000000000000000000000000000000000000ff8513c6b54542145a1b4cf70000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000072a600000000000000000000000000000000000000000000000000000000000000cd00000000000000000000000000000000000000000000000000000000000000020000000000000000000000007e8485cf11c370519793d1c2d0a77bd139fdac38000000000000000000000000fea53c695fdf95cfb34514d916ac236e620201bd0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000f2ed992000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000066ed25970000000000000000000000000000000000000000000000000000000066ed799dd00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f4', + operation: 0, + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: 58, + safeTxGas: '0', + } as SafeTransaction['data'] + } + txInfo={txDetails.txInfo} + txData={ + { + ...txDetails.txData, + dataDecoded: { + method: '', + } as DecodedDataResponse, + } as TransactionDetails['txData'] + } + />, + ) + + await waitFor(() => { + expect(result.queryByText('Interacted with')).toBeInTheDocument() + expect(result.queryAllByText('Data').pop()).toBeInTheDocument() + }) + + fireEvent.click(result.getByText('Transaction details')) + + await waitFor(() => { + expect(result.queryByText('SafeTxGas')).toBeInTheDocument() + expect(result.queryAllByText('Data').pop()).toBeInTheDocument() + }) + }) + + it('should render an ERC20 transfer', async () => { + const result = render( + <DecodedTx + safeTxData={ + { + to: '0x3430d04E42a722c5Ae52C5Bffbf1F230C2677600', + value: '0', + data: '0xa9059cbb000000000000000000000000474e5ded6b5d078163bfb8f6dba355c3aa5478c80000000000000000000000000000000000000000000000008ac7230489e80000', + operation: 0, + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: 58, + safeTxGas: '0', + } as SafeTransaction['data'] + } + txInfo={txDetails.txInfo} + txData={ + { + ...txDetails.txData, + dataDecoded: { + method: 'transfer', + parameters: [ + { + name: 'to', + type: 'address', + value: '0x474e5Ded6b5D078163BFB8F6dBa355C3aA5478C8', + }, + { + name: 'value', + type: 'uint256', + value: '16745726664999765048', + }, + ], + }, + } as TransactionDetails['txData'] + } + />, + ) + + fireEvent.click(result.getByText('Transaction details')) + + await waitFor(() => { + expect(result.queryAllByText('transfer').pop()).toBeInTheDocument() + expect(result.queryByText('to')).toBeInTheDocument() + expect(result.queryAllByText('address').pop()).toBeInTheDocument() + expect(result.queryByText('value')).toBeInTheDocument() + expect(result.queryAllByText('uint256').pop()).toBeInTheDocument() + expect(result.queryByText('16745726664999765048')).toBeInTheDocument() + }) + }) + + it('should render a function call without parameters', async () => { + const result = render( + <DecodedTx + safeTxData={ + { + to: '0xe91d153e0b41518a2ce8dd3d7944fa863463a97d', + value: '5000000000000', + data: '0xd0e30db0', + operation: 0, + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: 58, + safeTxGas: '0', + } as SafeTransaction['data'] + } + txInfo={txDetails.txInfo} + txData={ + { + ...txDetails.txData, + dataDecoded: { + method: 'deposit', + parameters: [], + }, + } as TransactionDetails['txData'] + } + />, + ) + + fireEvent.click(result.getByText('Transaction details')) + + expect(result.queryAllByText('deposit').pop()).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/transactions/TxDetails/Summary/index.tsx b/apps/web/src/components/transactions/TxDetails/Summary/index.tsx index 10aa2b916d..7d32afae62 100644 --- a/apps/web/src/components/transactions/TxDetails/Summary/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/Summary/index.tsx @@ -1,172 +1,119 @@ -import type { ReactElement } from 'react' -import React, { useMemo, useState } from 'react' -import { Link, Box } from '@mui/material' +import { memo, type ReactElement } from 'react' import { generateDataRowValue, TxDataRow } from '@/components/transactions/TxDetails/Summary/TxDataRow' -import { isCustomTxInfo, isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards' +import { isCustomTxInfo, isMultiSendTxInfo, isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards' import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' -import { Operation } from '@safe-global/safe-gateway-typescript-sdk' -import { dateString } from '@/utils/formatters' -import css from './styles.module.css' -import type { SafeTransaction, SafeTransactionData, SafeVersion } from '@safe-global/safe-core-sdk-types' -import SafeTxGasForm from '../SafeTxGasForm' +import type { SafeTransactionData } from '@safe-global/types-kit' +import { dateString } from '@safe-global/utils/utils/formatters' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { Receipt } from '@/components/tx/ConfirmTxDetails/Receipt' import DecodedData from '../TxData/DecodedData' -import { calculateSafeTransactionHash } from '@safe-global/protocol-kit/dist/src/utils' -import useSafeInfo from '@/hooks/useSafeInfo' -import { SafeTxHashDataRow } from './SafeTxHashDataRow' -import { logError, Errors } from '@/services/exceptions' +import ColorCodedTxAccordion from '@/components/tx/ColorCodedTxAccordion' +import { Box, Divider, Stack, Typography } from '@mui/material' +import DecoderLinks from './DecoderLinks' +import isEqual from 'lodash/isEqual' +import Multisend from '../TxData/DecodedData/Multisend' +import { isMultiSendCalldata } from '@/utils/transaction-calldata' interface Props { - txDetails: TransactionDetails - defaultExpanded?: boolean - hideDecodedData?: boolean + safeTxData?: SafeTransactionData + txData: TransactionDetails['txData'] + txInfo?: TransactionDetails['txInfo'] + txDetails?: TransactionDetails + showMultisend?: boolean + showDecodedData?: boolean } -const Summary = ({ txDetails, defaultExpanded = false, hideDecodedData = false }: Props): ReactElement => { - const { safe } = useSafeInfo() - const [expanded, setExpanded] = useState<boolean>(defaultExpanded) +const Summary = ({ + safeTxData, + txData, + txInfo, + txDetails, + showMultisend = true, + showDecodedData = true, +}: Props): ReactElement => { + const { txHash, executedAt } = txDetails ?? {} + const customTxInfo = txInfo && isCustomTxInfo(txInfo) ? txInfo : undefined + const toInfo = customTxInfo?.to || txData?.addressInfoIndex?.[txData?.to.value] || txData?.to + const showDetails = Boolean(txInfo && txData) - const toggleExpanded = () => { - setExpanded((val) => !val) + let baseGas, gasPrice, gasToken, safeTxGas, refundReceiver, submittedAt, nonce + if (txDetails && isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo)) { + ;({ baseGas, gasPrice, gasToken, safeTxGas, submittedAt, nonce } = txDetails.detailedExecutionInfo) + refundReceiver = txDetails.detailedExecutionInfo.refundReceiver?.value } - const { txHash, detailedExecutionInfo, executedAt, txData } = txDetails - - let safeTxData: SafeTransactionData | undefined = undefined - let submittedAt, confirmations, safeTxHash, baseGas, gasPrice, gasToken, refundReceiver, safeTxGas, nonce - if (isMultisigDetailedExecutionInfo(detailedExecutionInfo)) { - ;({ submittedAt, confirmations, safeTxHash, baseGas, gasPrice, gasToken, safeTxGas, nonce } = detailedExecutionInfo) - refundReceiver = detailedExecutionInfo.refundReceiver?.value - if (txData) { - safeTxData = { - to: txData.to.value, - data: txData.hexData ?? '0x', - value: txData.value ?? '0', - operation: txData.operation as number, - baseGas, - gasPrice, - gasToken, - nonce, - refundReceiver, - safeTxGas, - } - } + safeTxData = safeTxData ?? { + to: txData?.to.value ?? ZERO_ADDRESS, + data: txData?.hexData ?? '0x', + value: txData?.value ?? BigInt(0).toString(), + operation: (txData?.operation as number) ?? 0, + baseGas: baseGas ?? BigInt(0).toString(), + gasPrice: gasPrice ?? BigInt(0).toString(), + gasToken: gasToken ?? ZERO_ADDRESS, + nonce: nonce ?? 0, + refundReceiver: refundReceiver ?? ZERO_ADDRESS, + safeTxGas: safeTxGas ?? BigInt(0).toString(), } - const isCustom = isCustomTxInfo(txDetails.txInfo) + const isMultisend = (txInfo !== undefined && isMultiSendTxInfo(txInfo)) || isMultiSendCalldata(safeTxData.data) + const transactionData = txData ?? txDetails?.txData return ( <> + {showMultisend && isMultisend && ( + <Multisend txData={transactionData} isExecuted={!!txDetails?.executedAt} compact /> + )} + {txHash && ( - <TxDataRow datatestid="tx-hash" title="Transaction hash:"> + <TxDataRow datatestid="tx-hash" title="Transaction hash"> {generateDataRowValue(txHash, 'hash', true)}{' '} </TxDataRow> )} - {safeTxHash && ( - <SafeTxHashDataRow safeTxHash={safeTxHash} safeTxData={safeTxData} safeVersion={safe.version as SafeVersion} /> + + {submittedAt && ( + <TxDataRow datatestid="tx-created-at" title="Created"> + <Typography variant="body2" component="div"> + {dateString(submittedAt)} + </Typography> + </TxDataRow> )} - <TxDataRow datatestid="tx-created-at" title="Created:"> - {submittedAt ? dateString(submittedAt) : null} - </TxDataRow> {executedAt && ( - <TxDataRow datatestid="tx-executed-at" title="Executed:"> - {dateString(executedAt)} + <TxDataRow datatestid="tx-executed-at" title="Executed"> + <Typography variant="body2" component="div"> + {dateString(executedAt)} + </Typography> </TxDataRow> )} - {/* Advanced TxData */} - {txData && ( - <> - {!defaultExpanded && ( - <Link - data-testid="tx-advanced-details" - className={css.buttonExpand} - onClick={toggleExpanded} - component="button" - variant="body1" - > - Advanced details - </Link> - )} + {showDetails && ( + <Box mt={2}> + <ColorCodedTxAccordion txInfo={txInfo} txData={txData}> + <Stack gap={1} divider={<Divider sx={{ mx: -2, my: 1 }} />}> + {showDecodedData && <DecodedData txData={txData} toInfo={toInfo} />} - {expanded && ( - <Box mt={1}> - {!isCustom && !hideDecodedData && ( - <Box borderBottom="1px solid" borderColor="border.light" p={2} mt={1} mb={2} mx={-2}> - <DecodedData txData={txDetails.txData} toInfo={txDetails.txData?.to} /> - </Box> - )} + <Box> + <Typography variant="subtitle2" fontWeight={700} mb={2}> + Advanced details + </Typography> - <TxDataRow datatestid="tx-operation" title="Operation:"> - {`${txData.operation} (${Operation[txData.operation].toLowerCase()})`} - </TxDataRow> - <TxDataRow datatestid="tx-safe-gas" title="safeTxGas:"> - {safeTxGas} - </TxDataRow> - <TxDataRow datatestid="tx-base-gas" title="baseGas:"> - {baseGas} - </TxDataRow> - <TxDataRow datatestid="tx-gas-price" title="gasPrice:"> - {gasPrice} - </TxDataRow> - <TxDataRow datatestid="tx-gas-token" title="gasToken:"> - {generateDataRowValue(gasToken, 'hash', true)} - </TxDataRow> - <TxDataRow datatestid="tx-refund-receiver" title="refundReceiver:"> - {generateDataRowValue(refundReceiver, 'hash', true)} - </TxDataRow> - {confirmations?.map(({ signature }, index) => ( - <TxDataRow datatestid="tx-signature" title={`Signature ${index + 1}:`} key={`signature-${index}:`}> - {generateDataRowValue(signature, 'rawData')} - </TxDataRow> - ))} + <DecoderLinks /> - <Box mt={1}> - <TxDataRow datatestid="tx-raw-data" title="Raw data:"> - {generateDataRowValue(txData.hexData, 'rawData')} - </TxDataRow> + <Receipt + safeTxData={safeTxData} + txData={txData} + txDetails={txDetails} + txInfo={txInfo} + withSignatures + grid + /> </Box> - </Box> - )} - </> + </Stack> + </ColorCodedTxAccordion> + </Box> )} </> ) } -export default Summary - -export const PartialSummary = ({ safeTx }: { safeTx: SafeTransaction }) => { - const txData = safeTx.data - const { safeAddress, safe } = useSafeInfo() - const safeTxHash = useMemo(() => { - if (!safe.version) return - try { - return calculateSafeTransactionHash(safeAddress, safeTx.data, safe.version, BigInt(safe.chainId)) - } catch (e) { - logError(Errors._809, e) - } - }, [safe.chainId, safe.version, safeAddress, safeTx.data]) - return ( - <> - {safeTxHash && ( - <SafeTxHashDataRow safeTxHash={safeTxHash} safeTxData={safeTx.data} safeVersion={safe.version as SafeVersion} /> - )} - <TxDataRow datatestid="tx-safe-gas" title="safeTxGas:"> - <SafeTxGasForm /> - </TxDataRow> - <TxDataRow datatestid="tx-base-gas" title="baseGas:"> - {txData.baseGas} - </TxDataRow> - <TxDataRow datatestid="tx-refund-receiver" title="refundReceiver:"> - {generateDataRowValue(txData.refundReceiver, 'hash', true)} - </TxDataRow> - - <Box mt={1}> - <TxDataRow datatestid="tx-raw-data" title="Raw data:"> - {generateDataRowValue(txData.data, 'rawData')} - </TxDataRow> - </Box> - </> - ) -} +export default memo(Summary, isEqual) diff --git a/apps/web/src/components/transactions/TxDetails/Summary/styles.module.css b/apps/web/src/components/transactions/TxDetails/Summary/styles.module.css deleted file mode 100644 index f6dd3cc651..0000000000 --- a/apps/web/src/components/transactions/TxDetails/Summary/styles.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.buttonExpand { - margin-top: 8px; - padding: 0; -} diff --git a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/MethodCall.tsx b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/MethodCall.tsx index 7d78a51b9b..6e0415de13 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/MethodCall.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/MethodCall.tsx @@ -1,5 +1,4 @@ import NamedAddressInfo from '@/components/common/NamedAddressInfo' -import { Divider } from '@/components/tx/DecodedTx' import { Typography } from '@mui/material' const MethodCall = ({ @@ -50,8 +49,6 @@ const MethodCall = ({ avatarSize={24} /> </Typography> - - <Divider /> </> ) } diff --git a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/MethodDetails/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/MethodDetails/index.tsx index d5ef9bedca..dbe7433a84 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/MethodDetails/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/MethodDetails/index.tsx @@ -1,10 +1,10 @@ -import { HexEncodedData } from '@/components/transactions/HexEncodedData' import type { ReactElement } from 'react' import { generateDataRowValue, TxDataRow } from '@/components/transactions/TxDetails/Summary/TxDataRow' import { isAddress, isArrayParameter, isByte } from '@/utils/transaction-guards' import type { AddressEx, DataDecoded } from '@safe-global/safe-gateway-typescript-sdk' -import { Box, Typography } from '@mui/material' +import { Box, Stack, Typography } from '@mui/material' import { Value } from '@/components/transactions/TxDetails/TxData/DecodedData/ValueArray' +import { HexEncodedData } from '@/components/transactions/HexEncodedData' type MethodDetailsProps = { data: DataDecoded @@ -14,50 +14,35 @@ type MethodDetailsProps = { } } -export const MethodDetails = ({ data, hexData, addressInfoIndex }: MethodDetailsProps): ReactElement => { +export const MethodDetails = ({ data, addressInfoIndex, hexData }: MethodDetailsProps): ReactElement | null => { + const showHexData = data.method === 'fallback' && !data.parameters?.length && hexData if (!data.parameters?.length) { return ( <> - <Typography - sx={{ - color: 'text.secondary', - }} - > + <Typography color="text.secondary" variant="body2"> No parameters </Typography> - - {hexData && <HexEncodedData title="Data (hex-encoded)" hexData={hexData} />} + {showHexData && <HexEncodedData title="Data" hexData={hexData} />} </> ) } return ( - <Box> - <Typography - sx={{ - fontWeight: 'bold', - pb: 1, - }} - > - Parameters - </Typography> + <Stack gap={0.75}> {data.parameters?.map((param, index) => { const isArrayValueParam = isArrayParameter(param.type) || Array.isArray(param.value) const inlineType = isAddress(param.type) ? 'address' : isByte(param.type) ? 'bytes' : undefined const addressEx = typeof param.value === 'string' ? addressInfoIndex?.[param.value] : undefined const title = ( - <> - <Typography component="span">{param.name}</Typography>{' '} - <Typography - component="span" - sx={{ - color: 'text.secondary', - }} - > + <Box mb={-0.75}> + <Typography variant="body2" component="span"> + {param.name} + </Typography>{' '} + <Typography variant="body2" component="span" color="text.secondary"> {param.type} </Typography> - </> + </Box> ) return ( @@ -70,6 +55,6 @@ export const MethodDetails = ({ data, hexData, addressInfoIndex }: MethodDetails </TxDataRow> ) })} - </Box> + </Stack> ) } diff --git a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx index 5110562ff6..1f8c27222c 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/Multisend/index.tsx @@ -1,4 +1,3 @@ -import { HexEncodedData } from '@/components/transactions/HexEncodedData' import { Operation } from '@safe-global/safe-gateway-typescript-sdk' import type { TransactionData } from '@safe-global/safe-gateway-typescript-sdk' import { useState, useEffect } from 'react' @@ -12,6 +11,7 @@ import classnames from 'classnames' type MultisendProps = { txData?: TransactionData compact?: boolean + isExecuted?: boolean } export const MultisendActionsHeader = ({ @@ -44,7 +44,7 @@ export const MultisendActionsHeader = ({ ) } -export const Multisend = ({ txData, compact = false }: MultisendProps): ReactElement | null => { +export const Multisend = ({ txData, compact = false, isExecuted = false }: MultisendProps): ReactElement | null => { const [openMap, setOpenMap] = useState<Record<number, boolean>>() const isOpenMapUndefined = openMap == null @@ -58,19 +58,8 @@ export const Multisend = ({ txData, compact = false }: MultisendProps): ReactEle } }, [multiSendTransactions, isOpenMapUndefined]) - if (!txData) return null + if (!multiSendTransactions) return null - // ? when can a multiSend call take no parameters? - if (!txData.dataDecoded?.parameters) { - if (txData.hexData) { - return <HexEncodedData title="Data (hex encoded)" hexData={txData.hexData} /> - } - return null - } - - if (!multiSendTransactions) { - return null - } return ( <> <MultisendActionsHeader setOpen={setOpenMap} amount={multiSendTransactions.length} compact={compact} /> @@ -99,6 +88,7 @@ export const Multisend = ({ txData, compact = false }: MultisendProps): ReactEle variant={compact ? 'outlined' : 'elevation'} expanded={openMap?.[index] ?? false} onChange={onChange} + isExecuted={isExecuted} /> ) })} diff --git a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.test.tsx b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.test.tsx index 7a6f20a6f0..ac350b891f 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.test.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.test.tsx @@ -3,7 +3,7 @@ import SingleTxDecoded from '.' import { Operation } from '@safe-global/safe-gateway-typescript-sdk' import { faker } from '@faker-js/faker' import { parseUnits } from 'ethers' -import { ERC20__factory } from '@/types/contracts' +import { ERC20__factory } from '@safe-global/utils/types/contracts' describe('SingleTxDecoded', () => { it('should show native transfers', () => { @@ -27,7 +27,7 @@ describe('SingleTxDecoded', () => { />, ) - expect(result.queryByText('native transfer')).not.toBeNull() + expect(result.getByText(`1 ETH`)).not.toBeNull() }) it('should show unknown contract interactions', () => { diff --git a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx index dfe3114835..c3c589f35d 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx @@ -7,9 +7,12 @@ import css from './styles.module.css' import accordionCss from '@/styles/accordion.module.css' import CodeIcon from '@mui/icons-material/Code' import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { getSafeToL2MigrationDeployment } from '@safe-global/safe-deployments' import { useCurrentChain } from '@/hooks/useChains' +import { type TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { InlineTransferTxInfo } from '../../Transfer' +import { useTransferTokenInfo } from './useTransferTokenInfo' type SingleTxDecodedProps = { tx: InternalTransaction @@ -18,9 +21,18 @@ type SingleTxDecodedProps = { variant?: AccordionProps['variant'] expanded?: boolean onChange?: AccordionProps['onChange'] + isExecuted?: boolean } -export const SingleTxDecoded = ({ tx, txData, actionTitle, variant, expanded, onChange }: SingleTxDecodedProps) => { +export const SingleTxDecoded = ({ + tx, + txData, + actionTitle, + variant, + expanded, + onChange, + isExecuted = false, +}: SingleTxDecodedProps) => { const chain = useCurrentChain() const isNativeTransfer = tx.value !== '0' && (!tx.data || isEmptyHexData(tx.data)) const method = tx.dataDecoded?.method || (isNativeTransfer ? 'native transfer' : 'contract interaction') @@ -30,6 +42,11 @@ export const SingleTxDecoded = ({ tx, txData, actionTitle, variant, expanded, on const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() const safeToL2MigrationAddress = chain && safeToL2MigrationDeployment?.networkAddresses[chain.chainId] + const tokenInfoIndex = (txData as TransactionDetails['txData'])?.tokenInfoIndex + + const txDataHex = tx.data ?? '0x' + + const transferTokenInfo = useTransferTokenInfo(txDataHex, tx.value, tx.to, tokenInfoIndex) const singleTxData = { to: { value: tx.to }, @@ -47,16 +64,24 @@ export const SingleTxDecoded = ({ tx, txData, actionTitle, variant, expanded, on <div className={css.summary}> <CodeIcon color="border" fontSize="small" /> <Typography>{actionTitle}</Typography> - <Typography ml="8px"> - {name ? name + ': ' : ''} - <b>{method}</b> - </Typography> + {transferTokenInfo ? ( + <InlineTransferTxInfo + value={transferTokenInfo.transferValue} + tokenInfo={transferTokenInfo.tokenInfo} + recipient={transferTokenInfo.recipient} + /> + ) : ( + <Typography ml="8px"> + {name ? name + ': ' : ''} + <b>{method}</b> + </Typography> + )} </div> </AccordionSummary> <AccordionDetails> <Stack spacing={1}> - <DecodedData txData={singleTxData} toInfo={{ value: tx.to }} /> + <DecodedData txData={singleTxData} toInfo={{ value: tx.to }} isTxExecuted={isExecuted} /> </Stack> </AccordionDetails> </Accordion> diff --git a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/useTransferTokenInfo.test.ts b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/useTransferTokenInfo.test.ts new file mode 100644 index 0000000000..230e9577cc --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/useTransferTokenInfo.test.ts @@ -0,0 +1,86 @@ +import { useTransferTokenInfo } from './useTransferTokenInfo' +import { useNativeTokenInfo } from '@/hooks/useNativeTokenInfo' +import { renderHook } from '@/tests/test-utils' +import { faker } from '@faker-js/faker' +import { ERC20__factory } from '@safe-global/utils/types/contracts' +import { checksumAddress } from '@safe-global/utils/utils/addresses' + +jest.mock('@/hooks/useNativeTokenInfo', () => ({ + useNativeTokenInfo: jest.fn(), +})) + +const ERC20_INTERFACE = ERC20__factory.createInterface() + +describe('useTransferTokenInfo', () => { + const mockNativeTokenInfo = { + type: 'NATIVE_TOKEN', + address: '0x0000000000000000000000000000000000000000', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + logoUri: 'https://example.com/eth.png', + } + + beforeEach(() => { + jest.clearAllMocks() + ;(useNativeTokenInfo as jest.Mock).mockReturnValue(mockNativeTokenInfo) + }) + + it('should return undefined for non-transfer transactions', () => { + const { result } = renderHook(() => useTransferTokenInfo('0x1234', '0', faker.finance.ethereumAddress(), {})) + expect(result.current).toBeUndefined() + }) + + it('should handle ERC20 token transfers', () => { + const tokenAddress = faker.finance.ethereumAddress() + const recipient = faker.finance.ethereumAddress() + const mockTokenInfo = { + type: 'ERC20', + address: tokenAddress, + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + logoUri: 'https://example.com/token.png', + } as const + + const { result } = renderHook(() => + useTransferTokenInfo( + ERC20_INTERFACE.encodeFunctionData('transfer', [recipient, '1000000000000000000']), + '0', + tokenAddress, + { [tokenAddress]: mockTokenInfo }, + ), + ) + + expect(result.current).toEqual({ + recipient: checksumAddress(recipient), + transferValue: 1000000000000000000n, + tokenInfo: mockTokenInfo, + }) + }) + + it('should handle native token transfers', () => { + const recipient = faker.finance.ethereumAddress() + const { result } = renderHook(() => useTransferTokenInfo('0x', '1000000000000000000', recipient, {})) + + expect(result.current).toEqual({ + recipient, + transferValue: '1000000000000000000', + tokenInfo: mockNativeTokenInfo, + }) + }) + + it('should return undefined for invalid ERC20 transfers', () => { + const tokenAddress = faker.finance.ethereumAddress() + const { result } = renderHook(() => useTransferTokenInfo('0xa9059cbb', '0', tokenAddress, {})) + + expect(result.current).toBeUndefined() + }) + + it('should return undefined for zero value native transfers', () => { + const recipient = faker.finance.ethereumAddress() + const { result } = renderHook(() => useTransferTokenInfo('0x', '0', recipient, {})) + + expect(result.current).toBeUndefined() + }) +}) diff --git a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/useTransferTokenInfo.ts b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/useTransferTokenInfo.ts new file mode 100644 index 0000000000..55997aa4cd --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/useTransferTokenInfo.ts @@ -0,0 +1,24 @@ +import { + type Erc20Token, + type NativeToken, + type TransactionDetails, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useNativeTokenInfo } from '@/hooks/useNativeTokenInfo' +import { useTxTokenInfo } from '@safe-global/utils/hooks/useTxTokenInfo' + +export const useTransferTokenInfo = ( + data: string | undefined, + value: string | undefined, + to: string, + tokenInfoIndex?: NonNullable<TransactionDetails['txData']>['tokenInfoIndex'], +): + | { + recipient: string + transferValue: string + tokenInfo: Erc20Token | NativeToken + } + | undefined => { + const nativeTokenInfo = useNativeTokenInfo() + + return useTxTokenInfo(data, value, to, nativeTokenInfo, tokenInfoIndex) +} diff --git a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/ValueArray.test.tsx b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/ValueArray.test.tsx index 2b26374177..432f60a0ce 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/ValueArray.test.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/ValueArray/ValueArray.test.tsx @@ -1,13 +1,15 @@ import { Value } from '.' -import { render } from '@/tests/test-utils' +import { render, waitFor } from '@/tests/test-utils' describe('ValueArray', () => { - it('should render Snapshot Proposal', () => { + it('should render Snapshot Proposal', async () => { const result = render(<Value type="string[]" value='[\n "Yes",\n "No"\n]' method="Proposal" />) - expect(result.queryByText('[', { exact: false })).toBeInTheDocument() - expect(result.queryByText('Yes', { exact: false })).toBeInTheDocument() - expect(result.queryByText('No', { exact: false })).toBeInTheDocument() - expect(result.queryByText(']', { exact: false })).toBeInTheDocument() + await waitFor(() => { + expect(result.queryByText('[', { exact: false })).toBeInTheDocument() + expect(result.queryByText('Yes', { exact: false })).toBeInTheDocument() + expect(result.queryByText('No', { exact: false })).toBeInTheDocument() + expect(result.queryByText(']', { exact: false })).toBeInTheDocument() + }) }) }) diff --git a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/index.test.tsx b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/index.test.tsx index 6005fefa78..3875b50b52 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/index.test.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/index.test.tsx @@ -11,7 +11,7 @@ describe('DecodedData', () => { it('shows an Interact with block if there is no txData but toInfo', () => { const { getByText } = render(<DecodedData txData={undefined} toInfo={{ value: '0x123' }} />) - expect(getByText('Interact with:')).toBeInTheDocument() + expect(getByText('Interact with')).toBeInTheDocument() }) it('shows Hex encoded data if there are no parameters', () => { @@ -33,7 +33,6 @@ describe('DecodedData', () => { const { getByText } = render(<DecodedData txData={mockTxData} toInfo={{ value: '0x123' }} />) expect(getByText('No parameters')).toBeInTheDocument() - expect(getByText('Data (hex-encoded)')).toBeInTheDocument() }) it('does not show Hex encoded data if there is none', () => { @@ -51,10 +50,9 @@ describe('DecodedData', () => { trustedDelegateCallTarget: false, } - const { getByText, queryByText } = render(<DecodedData txData={mockTxData} toInfo={{ value: '0x123' }} />) + const { getByText } = render(<DecodedData txData={mockTxData} toInfo={{ value: '0x123' }} />) expect(getByText('No parameters')).toBeInTheDocument() - expect(queryByText('Data (hex-encoded)')).not.toBeInTheDocument() }) it('only shows Hex encoded data if no decodedData exists', () => { @@ -69,9 +67,8 @@ describe('DecodedData', () => { trustedDelegateCallTarget: false, } - const { getByText, queryByText } = render(<DecodedData txData={mockTxData} toInfo={{ value: '0x123' }} />) + const { queryByText } = render(<DecodedData txData={mockTxData} toInfo={{ value: '0x123' }} />) expect(queryByText('No parameters')).not.toBeInTheDocument() - expect(getByText('Data (hex-encoded)')).toBeInTheDocument() }) }) diff --git a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx index 64103d5209..479c6396f3 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx @@ -1,26 +1,25 @@ import type { ReactElement } from 'react' -import { Stack } from '@mui/material' -import { type AddressEx, TokenType, type TransactionDetails, Operation } from '@safe-global/safe-gateway-typescript-sdk' +import { Stack, Typography } from '@mui/material' +import { type AddressEx, type TransactionDetails, Operation } from '@safe-global/safe-gateway-typescript-sdk' import { HexEncodedData } from '@/components/transactions/HexEncodedData' import { MethodDetails } from '@/components/transactions/TxDetails/TxData/DecodedData/MethodDetails' -import { useCurrentChain } from '@/hooks/useChains' import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock' -import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import SendToBlock from '@/components/tx/SendToBlock' import MethodCall from './MethodCall' -import useSafeAddress from '@/hooks/useSafeAddress' -import { sameAddress } from '@/utils/addresses' -import { DelegateCallWarning } from '@/components/transactions/Warning' +import { useNativeTokenInfo } from '@/hooks/useNativeTokenInfo' +import { DelegateCallWarning, UntrustedFallbackHandlerWarning } from '@/components/transactions/Warning' +import { useSetsUntrustedFallbackHandler } from '@/components/tx/confirmation-views/SettingsChange/UntrustedFallbackHandlerTxAlert' interface Props { txData: TransactionDetails['txData'] toInfo?: AddressEx + isTxExecuted?: boolean } -export const DecodedData = ({ txData, toInfo }: Props): ReactElement | null => { - const safeAddress = useSafeAddress() - const chainInfo = useCurrentChain() +export const DecodedData = ({ txData, toInfo, isTxExecuted = false }: Props): ReactElement | null => { + const nativeTokenInfo = useNativeTokenInfo() + const setsUntrustedFallbackHandler = useSetsUntrustedFallbackHandler(txData) // nothing to render if (!txData) { @@ -28,7 +27,7 @@ export const DecodedData = ({ txData, toInfo }: Props): ReactElement | null => { return ( <SendToBlock - title="Interact with:" + title="Interact with" address={toInfo.value} name={toInfo.name} customAvatar={toInfo.logoUri} @@ -39,49 +38,32 @@ export const DecodedData = ({ txData, toInfo }: Props): ReactElement | null => { const amountInWei = txData.value ?? '0' const isDelegateCall = txData.operation === Operation.DELEGATE - const toAddress = toInfo?.value || txData.to.value + const toAddress = toInfo?.value || txData.to?.value const method = txData.dataDecoded?.method || '' const addressInfo = txData.addressInfoIndex?.[toAddress] - const name = sameAddress(toAddress, safeAddress) - ? 'this Safe Account' - : addressInfo?.name || toInfo?.name || txData.to.name - const avatar = addressInfo?.logoUri || toInfo?.logoUri || txData.to.logoUri - - let decodedData = <></> - if (txData.dataDecoded) { - decodedData = ( - <MethodDetails data={txData.dataDecoded} hexData={txData.hexData} addressInfoIndex={txData.addressInfoIndex} /> - ) - } else if (txData.hexData) { - // When no decoded data, display raw hex data - decodedData = <HexEncodedData title="Data (hex-encoded)" hexData={txData.hexData} /> - } + const name = addressInfo?.name || toInfo?.name || txData.to?.name + const avatar = addressInfo?.logoUri || toInfo?.logoUri || txData.to?.logoUri return ( <Stack spacing={2}> + {setsUntrustedFallbackHandler && <UntrustedFallbackHandlerWarning isTxExecuted={isTxExecuted} />} {isDelegateCall && <DelegateCallWarning showWarning={!txData.trustedDelegateCallTarget} />} {method ? ( <MethodCall contractAddress={toAddress} contractName={name} contractLogo={avatar} method={method} /> ) : ( - <SendToBlock address={toAddress} name={name} title="Interacted with:" avatarSize={26} customAvatar={avatar} /> + <SendToBlock address={toAddress} name={name} title="Interacted with" avatarSize={20} customAvatar={avatar} /> )} - {amountInWei !== '0' && ( - <SendAmountBlock - title="Value:" - amountInWei={amountInWei} - tokenInfo={{ - type: TokenType.NATIVE_TOKEN, - address: ZERO_ADDRESS, - decimals: chainInfo?.nativeCurrency.decimals ?? 18, - symbol: chainInfo?.nativeCurrency.symbol ?? 'ETH', - logoUri: chainInfo?.nativeCurrency.logoUri, - }} - /> - )} + {amountInWei !== '0' && <SendAmountBlock title="Value" amountInWei={amountInWei} tokenInfo={nativeTokenInfo} />} - {decodedData} + {txData.dataDecoded ? ( + <MethodDetails data={txData.dataDecoded} hexData={txData.hexData} addressInfoIndex={txData.addressInfoIndex} /> + ) : txData.hexData ? ( + <Typography data-testid="hexData" variant="body2" component="div"> + <HexEncodedData title="Data" hexData={txData.hexData} /> + </Typography> + ) : null} </Stack> ) } diff --git a/apps/web/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx index 60b0d53d31..7c7903a8f2 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx @@ -1,21 +1,25 @@ -import DecodedTx from '@/components/tx/DecodedTx' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { useCurrentChain } from '@/hooks/useChains' import useSafeInfo from '@/hooks/useSafeInfo' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' -import { getMultiSendContractDeployment } from '@/services/contracts/deployments' +import { getMultiSendContractDeployment } from '@safe-global/utils/services/contracts/deployments' import { createTx } from '@/services/tx/tx-sender/create' -import { Safe__factory } from '@/types/contracts' +import { Safe__factory } from '@safe-global/utils/types/contracts' import { type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import DecodedData from '../DecodedData' import ErrorMessage from '@/components/tx/ErrorMessage' import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' import { MigrateToL2Information } from '@/components/tx/confirmation-views/MigrateToL2Information' import { Box } from '@mui/material' -import { isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards' +import { isCustomTxInfo, isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards' import useTxPreview from '@/components/tx/confirmation-views/useTxPreview' +import Summary from '../../Summary' -export const MigrationToL2TxData = ({ txDetails }: { txDetails: TransactionDetails }) => { +export const MigrationToL2TxData = ({ + txDetails: { txData, txInfo, txHash, detailedExecutionInfo }, +}: { + txDetails: TransactionDetails +}) => { const readOnlyProvider = useWeb3ReadOnly() const chain = useCurrentChain() const { safe } = useSafeInfo() @@ -23,10 +27,10 @@ export const MigrationToL2TxData = ({ txDetails }: { txDetails: TransactionDetai // Reconstruct real tx const [realSafeTx, realSafeTxError, realSafeTxLoading] = useAsync(async () => { // Fetch tx receipt from backend - if (!txDetails.txHash || !chain || !sdk) { + if (!txHash || !chain || !sdk) { return undefined } - const txResult = await readOnlyProvider?.getTransaction(txDetails.txHash) + const txResult = await readOnlyProvider?.getTransaction(txHash) const txData = txResult?.data // Search for a Safe Tx to MultiSend contract @@ -59,28 +63,26 @@ export const MigrationToL2TxData = ({ txDetails }: { txDetails: TransactionDetai gasToken: execTxArgs[7].toString(), refundReceiver: execTxArgs[8], }, - isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) - ? txDetails.detailedExecutionInfo.nonce - : undefined, + isMultisigDetailedExecutionInfo(detailedExecutionInfo) ? detailedExecutionInfo.nonce : undefined, ) } - }, [txDetails.txHash, txDetails.detailedExecutionInfo, chain, sdk, readOnlyProvider, safe.version]) + }, [txHash, detailedExecutionInfo, chain, sdk, readOnlyProvider, safe.version]) const decodedDataUnavailable = !realSafeTx && !realSafeTxLoading const [txPreview, txPreviewError] = useTxPreview(realSafeTx?.data) return ( <Box> - <MigrateToL2Information variant="history" txData={txDetails.txData} /> + <MigrateToL2Information variant="history" txData={txData} /> {realSafeTxError ? ( <ErrorMessage>{realSafeTxError.message}</ErrorMessage> ) : txPreviewError ? ( <ErrorMessage>{txPreviewError.message}</ErrorMessage> ) : decodedDataUnavailable ? ( - <DecodedData txData={txDetails.txData} /> + <DecodedData txData={txData} toInfo={txInfo && isCustomTxInfo(txInfo) ? txInfo.to : txData?.to} /> ) : ( - txPreview && <DecodedTx {...txPreview} tx={realSafeTx} /> + txPreview && <Summary {...txPreview} safeTxData={realSafeTx?.data} /> )} </Box> ) diff --git a/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/ExecTransaction/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/ExecTransaction/index.tsx index 77b45cff56..fcadec6c3a 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/ExecTransaction/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/ExecTransaction/index.tsx @@ -1,17 +1,19 @@ -import { Safe__factory } from '@/types/contracts' -import { Skeleton } from '@mui/material' +import { Safe__factory } from '@safe-global/utils/types/contracts' +import { Box, Skeleton, Stack } from '@mui/material' import { type TransactionData } from '@safe-global/safe-gateway-typescript-sdk' import ErrorMessage from '@/components/tx/ErrorMessage' -import DecodedTx from '@/components/tx/DecodedTx' import Link from 'next/link' import { useCurrentChain } from '@/hooks/useChains' import { AppRoutes } from '@/config/routes' import { useMemo } from 'react' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction } from '@safe-global/types-kit' import ExternalLink from '@/components/common/ExternalLink' import { NestedTransaction } from '../NestedTransaction' import useTxPreview from '@/components/tx/confirmation-views/useTxPreview' +import TxData from '../..' +import { TxSimulation, TxSimulationMessage } from '@/components/tx/security/tenderly' +import useSafeAddress from '@/hooks/useSafeAddress' const safeInterface = Safe__factory.createInterface() @@ -49,6 +51,7 @@ export const ExecTransaction = ({ isConfirmationView?: boolean }) => { const chain = useCurrentChain() + const safeAddress = useSafeAddress() const childSafeTx = useMemo<SafeTransaction | undefined>( () => (data?.hexData ? extractTransactionData(data.hexData) : undefined), @@ -68,7 +71,7 @@ export const ExecTransaction = ({ ) const decodedNestedTxDataBlock = txPreview ? ( - <DecodedTx {...txPreview} tx={childSafeTx} showMethodCall showAdvancedDetails={false} /> + <TxData txData={txPreview.txData} txInfo={txPreview.txInfo} trusted imitation={false} /> ) : null return ( @@ -77,17 +80,32 @@ export const ExecTransaction = ({ <> {decodedNestedTxDataBlock} + {isConfirmationView && childSafeTx && data?.to.value && ( + <Stack spacing={2}> + <TxSimulation + disabled={false} + transactions={childSafeTx} + title="Simulate nested transaction" + executionOwner={safeAddress} + nestedSafe={data?.to.value} + /> + <TxSimulationMessage isNested /> + </Stack> + )} + {chain && data && ( - <Link - href={{ - pathname: AppRoutes.transactions.history, - query: { safe: `${chain.shortName}:${data.to.value}` }, - }} - passHref - legacyBehavior - > - <ExternalLink>Open Safe</ExternalLink> - </Link> + <Box> + <Link + href={{ + pathname: AppRoutes.transactions.history, + query: { safe: `${chain.shortName}:${data.to.value}` }, + }} + passHref + legacyBehavior + > + <ExternalLink>Open Safe</ExternalLink> + </Link> + </Box> )} </> ) : error ? ( diff --git a/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/NestedTransaction.tsx b/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/NestedTransaction.tsx index 35f7d6ca1d..3ce3de6873 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/NestedTransaction.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/NestedTransaction.tsx @@ -1,12 +1,19 @@ -import { Stack, SvgIcon, Typography } from '@mui/material' +import { Card, CardContent, CardHeader, cardHeaderClasses, Stack, SvgIcon, Typography } from '@mui/material' import { type TransactionData } from '@safe-global/safe-gateway-typescript-sdk' -import { Divider } from '@/components/tx/DecodedTx' +import { Divider } from '@/components/tx/ColorCodedTxAccordion' import NestedTransactionIcon from '@/public/images/transactions/nestedTx.svg' import { type ReactElement } from 'react' import MethodCall from '../DecodedData/MethodCall' import { MethodDetails } from '../DecodedData/MethodDetails' +import ExternalLink from '@/components/common/ExternalLink' +import Track from '@/components/common/Track' +import Link from 'next/link' +import { MODALS_EVENTS } from '@/services/analytics' +import { AppRoutes } from '@/config/routes' +import { useSignedHash } from './useSignedHash' +import { useCurrentChain } from '@/hooks/useChains' export const NestedTransaction = ({ txData, @@ -17,6 +24,8 @@ export const NestedTransaction = ({ children: ReactElement isConfirmationView?: boolean }) => { + const chain = useCurrentChain() + const signedHash = useSignedHash(txData) return ( <Stack spacing={2}> {!isConfirmationView && txData?.dataDecoded && ( @@ -27,12 +36,49 @@ export const NestedTransaction = ({ </> )} - <Stack spacing={2}> - <Typography variant="h5" display="flex" alignItems="center" gap={1}> - <SvgIcon component={NestedTransactionIcon} inheritViewBox fontSize="small" /> Nested transaction: - </Typography> - {children} - </Stack> + <Card variant="outlined" sx={{ backgroundColor: 'background.main' }}> + <CardHeader + sx={{ + borderBottom: '1px solid', + borderColor: 'border.light', + [`& .${cardHeaderClasses.action}`]: { + marginTop: 0, + marginBottom: 0, + marginRight: 0, + }, + }} + avatar={<SvgIcon component={NestedTransactionIcon} inheritViewBox fontSize="small" />} + action={ + chain && + txData && + signedHash && ( + <Track {...MODALS_EVENTS.OPEN_NESTED_TX}> + <Link + href={{ + pathname: AppRoutes.transactions.tx, + query: { + safe: `${chain?.shortName}:${txData.to.value}`, + id: signedHash, + }, + }} + passHref + legacyBehavior + > + <ExternalLink color="text.secondary"> + <Typography variant="body2" fontWeight={700}> + Open + </Typography> + </ExternalLink> + </Link> + </Track> + ) + } + title={<Typography variant="h5">Nested transaction</Typography>} + /> + <CardContent> + <Stack spacing={4}>{children}</Stack> + </CardContent> + </Card> </Stack> ) } diff --git a/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/OnChainConfirmation/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/OnChainConfirmation/index.tsx index 33e91b5289..783f2dc8ad 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/OnChainConfirmation/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/OnChainConfirmation/index.tsx @@ -1,25 +1,17 @@ import useChainId from '@/hooks/useChainId' -import { Safe__factory } from '@/types/contracts' -import { Skeleton } from '@mui/material' +import { Skeleton, Stack } from '@mui/material' import { type TransactionData } from '@safe-global/safe-gateway-typescript-sdk' import ErrorMessage from '@/components/tx/ErrorMessage' - -import Link from 'next/link' -import { useCurrentChain } from '@/hooks/useChains' -import { AppRoutes } from '@/config/routes' import { useGetTransactionDetailsQuery } from '@/store/api/gateway' import { useMemo } from 'react' import { skipToken } from '@reduxjs/toolkit/query' -import ExternalLink from '@/components/common/ExternalLink' import { NestedTransaction } from '../NestedTransaction' import TxData from '../..' -import { isMultiSendTxInfo, isOrderTxInfo } from '@/utils/transaction-guards' -import { ErrorBoundary } from '@sentry/react' -import Multisend from '../../DecodedData/Multisend' -import { MODALS_EVENTS } from '@/services/analytics' -import Track from '@/components/common/Track' - -const safeInterface = Safe__factory.createInterface() +import { TxSimulation, TxSimulationMessage } from '@/components/tx/security/tenderly' +import type { SafeTransaction } from '@safe-global/types-kit' +import extractTxInfo from '@/services/tx/extractTxInfo' +import { useSignedHash } from '../useSignedHash' +import useSafeAddress from '@/hooks/useSafeAddress' export const OnChainConfirmation = ({ data, @@ -28,16 +20,9 @@ export const OnChainConfirmation = ({ data?: TransactionData isConfirmationView?: boolean }) => { - const chain = useCurrentChain() const chainId = useChainId() - const signedHash = useMemo(() => { - const params = data?.hexData ? safeInterface.decodeFunctionData('approveHash', data?.hexData) : undefined - if (!params || params.length !== 1 || typeof params[0] !== 'string') { - return - } - - return params[0] - }, [data?.hexData]) + const signedHash = useSignedHash(data) + const safeAddress = useSafeAddress() const { data: nestedTxDetails, error: txDetailsError } = useGetTransactionDetailsQuery( signedHash @@ -48,6 +33,20 @@ export const OnChainConfirmation = ({ : skipToken, ) + const nestedTx = useMemo<SafeTransaction | undefined>( + () => + nestedTxDetails + ? { + addSignature: () => {}, + encodedSignatures: () => '', + getSignature: () => undefined, + data: extractTxInfo(nestedTxDetails).txParams, + signatures: new Map(), + } + : undefined, + [nestedTxDetails], + ) + return ( <NestedTransaction txData={data} isConfirmationView={isConfirmationView}> {nestedTxDetails ? ( @@ -60,28 +59,17 @@ export const OnChainConfirmation = ({ imitation={false} /> - {(isMultiSendTxInfo(nestedTxDetails.txInfo) || isOrderTxInfo(nestedTxDetails.txInfo)) && ( - <ErrorBoundary fallback={<div>Error parsing data</div>}> - <Multisend txData={nestedTxDetails.txData} /> - </ErrorBoundary> - )} - - {chain && data && ( - <Track {...MODALS_EVENTS.OPEN_NESTED_TX}> - <Link - href={{ - pathname: AppRoutes.transactions.tx, - query: { - safe: `${chain?.shortName}:${data?.to.value}`, - id: nestedTxDetails.txId, - }, - }} - passHref - legacyBehavior - > - <ExternalLink>Open nested transaction</ExternalLink> - </Link> - </Track> + {isConfirmationView && ( + <Stack spacing={2}> + <TxSimulation + disabled={false} + transactions={nestedTx} + title="Simulate nested transaction" + executionOwner={safeAddress} + nestedSafe={nestedTxDetails.safeAddress} + /> + <TxSimulationMessage isNested /> + </Stack> )} </> ) : txDetailsError ? ( diff --git a/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/useSignedHash.tsx b/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/useSignedHash.tsx new file mode 100644 index 0000000000..7deadd870b --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/NestedTransaction/useSignedHash.tsx @@ -0,0 +1,23 @@ +import { Safe__factory } from '@safe-global/utils/types/contracts' +import { type TransactionData } from '@safe-global/safe-gateway-typescript-sdk' +import { useMemo } from 'react' +import { isOnChainConfirmationTxData } from '@/utils/transaction-guards' + +const safeInterface = Safe__factory.createInterface() + +export const useSignedHash = (txData?: TransactionData) => { + const signedHash = useMemo(() => { + if (!isOnChainConfirmationTxData(txData)) { + return + } + + const params = txData?.hexData ? safeInterface.decodeFunctionData('approveHash', txData?.hexData) : undefined + if (!params || params.length !== 1 || typeof params[0] !== 'string') { + return + } + + return params[0] + }, [txData]) + + return signedHash +} diff --git a/apps/web/src/components/transactions/TxDetails/TxData/Rejection/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/Rejection/index.tsx index f1c8f66036..937fac72fb 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/Rejection/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/Rejection/index.tsx @@ -3,7 +3,8 @@ import { NOT_AVAILABLE } from '@/components/transactions/TxDetails' import type { MultisigExecutionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { Box, Typography } from '@mui/material' import React from 'react' -import { HelpCenterArticle } from '@/config/constants' + +import { HelpCenterArticle } from '@safe-global/utils/config/constants' interface Props { nonce?: MultisigExecutionDetails['nonce'] diff --git a/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.tsx index f79a826d07..f18906a37e 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.tsx @@ -5,6 +5,7 @@ import EthHashInfo from '@/components/common/EthHashInfo' import { InfoDetails } from '@/components/transactions/InfoDetails' import { ThresholdWarning } from '@/components/transactions/Warning' import { UntrustedFallbackHandlerWarning } from '@/components/transactions/Warning' +import { useHasUntrustedFallbackHandler } from '@/hooks/useHasUntrustedFallbackHandler' type SettingsChangeTxInfoProps = { settingsInfo: SettingsChange['settingsInfo'] @@ -21,6 +22,10 @@ export const SettingsChangeTxInfo = ({ settingsInfo, isTxExecuted = false, }: SettingsChangeTxInfoProps): ReactElement | null => { + const isUntrustedFallbackHandler = useHasUntrustedFallbackHandler( + settingsInfo?.type === SettingsInfoType.SET_FALLBACK_HANDLER ? settingsInfo.handler.value : undefined, + ) + if (!settingsInfo) { return null } @@ -37,7 +42,7 @@ export const SettingsChangeTxInfo = ({ {...addressInfoProps} /> </InfoDetails> - <UntrustedFallbackHandlerWarning fallbackHandler={settingsInfo.handler.value} isTxExecuted={isTxExecuted} /> + {isUntrustedFallbackHandler && <UntrustedFallbackHandlerWarning isTxExecuted={isTxExecuted} />} </> ) } diff --git a/apps/web/src/components/transactions/TxDetails/TxData/SpendingLimits/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/SpendingLimits/index.tsx index 035a3ead85..c6970144e6 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/SpendingLimits/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/SpendingLimits/index.tsx @@ -1,19 +1,19 @@ import type { ReactElement } from 'react' import React, { useMemo } from 'react' import type { Custom, TransactionData } from '@safe-global/safe-gateway-typescript-sdk' -import { Box, Typography } from '@mui/material' +import { Stack, Typography } from '@mui/material' import EthHashInfo from '@/components/common/EthHashInfo' import TokenIcon from '@/components/common/TokenIcon' import SpendingLimitLabel from '@/components/common/SpendingLimitLabel' import { useCurrentChain } from '@/hooks/useChains' import { selectTokens } from '@/store/balancesSlice' import { useAppSelector } from '@/store' -import { sameAddress } from '@/utils/addresses' -import { formatVisualAmount } from '@/utils/formatters' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' import type { SpendingLimitMethods } from '@/utils/transaction-guards' import { isSetAllowance } from '@/utils/transaction-guards' -import css from './styles.module.css' import chains from '@/config/chains' +import TxDetailsRow from '@/components/tx/ConfirmTxDetails/TxDetailsRow' type SpendingLimitsProps = { txData?: TransactionData @@ -42,12 +42,12 @@ export const SpendingLimits = ({ txData, txInfo, type }: SpendingLimitsProps): R if (!txData) return null return ( - <Box className={css.container}> + <Stack spacing={1}> <Typography> <b>{`${isSetAllowanceMethod ? 'Modify' : 'Delete'} spending limit:`}</b> </Typography> - <Box className={css.group}> - <Typography sx={({ palette }) => ({ color: palette.primary.light })}>Beneficiary</Typography> + + <TxDetailsRow label="Beneficiary" grid> <EthHashInfo address={(beneficiary as string) || txTo?.value || '0x'} name={txTo.name} @@ -56,39 +56,35 @@ export const SpendingLimits = ({ txData, txInfo, type }: SpendingLimitsProps): R showCopyButton hasExplorer /> - </Box> - <Box className={css.group}> - <Typography sx={({ palette }) => ({ color: palette.primary.light })}> - {isSetAllowanceMethod ? (tokenInfo ? 'Amount' : 'Raw Amount (in decimals)') : 'Token'} - </Typography> - <Box className={css.inline}> - {tokenInfo && ( - <> - <TokenIcon logoUri={tokenInfo.logoUri} size={32} tokenSymbol={tokenInfo.symbol} /> - <Typography>{tokenInfo.symbol}</Typography> - </> - )} + </TxDetailsRow> + + <TxDetailsRow label={isSetAllowanceMethod ? (tokenInfo ? 'Amount' : 'Raw Amount (in decimals)') : 'Token'} grid> + {tokenInfo && ( + <> + <TokenIcon logoUri={tokenInfo.logoUri} size={32} tokenSymbol={tokenInfo.symbol} /> + <Typography>{tokenInfo.symbol}</Typography> + </> + )} + + {isSetAllowanceMethod && ( + <> + {tokenInfo ? ( + <Typography> + {formatVisualAmount(amount as string, tokenInfo.decimals)} {tokenInfo.symbol} + </Typography> + ) : ( + <Typography>{amount}</Typography> + )} + </> + )} + </TxDetailsRow> - {isSetAllowanceMethod && ( - <> - {tokenInfo ? ( - <Typography> - {formatVisualAmount(amount as string, tokenInfo.decimals)} {tokenInfo.symbol} - </Typography> - ) : ( - <Typography>{amount}</Typography> - )} - </> - )} - </Box> - </Box> {isSetAllowanceMethod && ( - <Box className={css.group}> - <Typography sx={({ palette }) => ({ color: palette.primary.light })}>Reset time</Typography> + <TxDetailsRow label="Reset time" grid> <SpendingLimitLabel label={resetTimeLabel || 'One-time spending limit'} isOneTime={!resetTimeLabel} /> - </Box> + </TxDetailsRow> )} - </Box> + </Stack> ) } diff --git a/apps/web/src/components/transactions/TxDetails/TxData/SpendingLimits/styles.module.css b/apps/web/src/components/transactions/TxDetails/TxData/SpendingLimits/styles.module.css deleted file mode 100644 index 7368a67908..0000000000 --- a/apps/web/src/components/transactions/TxDetails/TxData/SpendingLimits/styles.module.css +++ /dev/null @@ -1,17 +0,0 @@ -.container { - display: flex; - flex-direction: column; - gap: 8px; -} - -.group { - display: flex; - flex-direction: column; - gap: 4px; -} - -.inline { - display: flex; - align-items: center; - gap: 8px; -} diff --git a/apps/web/src/components/transactions/TxDetails/TxData/Transfer/TransferActions.tsx b/apps/web/src/components/transactions/TxDetails/TxData/Transfer/TransferActions.tsx index dd16a19430..508176cc72 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/Transfer/TransferActions.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/Transfer/TransferActions.tsx @@ -13,7 +13,7 @@ import type { Transfer } from '@safe-global/safe-gateway-typescript-sdk' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { isERC20Transfer, isNativeTokenTransfer, isOutgoingTransfer } from '@/utils/transaction-guards' import { trackEvent, TX_LIST_EVENTS } from '@/services/analytics' -import { safeFormatUnits } from '@/utils/formatters' +import { safeFormatUnits } from '@safe-global/utils/utils/formatters' import CheckWallet from '@/components/common/CheckWallet' import { TxModalContext } from '@/components/tx-flow' @@ -87,7 +87,7 @@ const TransferActions = ({ <MenuItem onClick={() => { handleCloseContextMenu() - setTxFlow(<TokenTransferFlow recipient={recipient} tokenAddress={tokenAddress} amount={amount} />) + setTxFlow(<TokenTransferFlow recipients={[{ recipient, tokenAddress, amount }]} />) }} disabled={!isOk} > diff --git a/apps/web/src/components/transactions/TxDetails/TxData/Transfer/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/Transfer/index.tsx index 9971388af1..b8884eb3a5 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/Transfer/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/Transfer/index.tsx @@ -1,14 +1,16 @@ -import EthHashInfo from '@/components/common/EthHashInfo' +import NamedAddressInfo from '@/components/common/NamedAddressInfo' import { TransferTx } from '@/components/transactions/TxInfo' import { isTxQueued } from '@/utils/transaction-guards' import type { TransactionStatus, Transfer } from '@safe-global/safe-gateway-typescript-sdk' import { TransferDirection } from '@safe-global/safe-gateway-typescript-sdk' -import { Box, Typography } from '@mui/material' +import { Box, Stack, Typography } from '@mui/material' import React from 'react' import TransferActions from '@/components/transactions/TxDetails/TxData/Transfer/TransferActions' import MaliciousTxWarning from '@/components/transactions/MaliciousTxWarning' import { ImitationTransactionWarning } from '@/components/transactions/ImitationTransactionWarning' +import TokenAmount from '@/components/common/TokenAmount' +import { type NativeToken, type Erc20Token } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' type TransferTxInfoProps = { txInfo: Transfer @@ -22,13 +24,11 @@ const TransferTxInfoMain = ({ txInfo, txStatus, trusted, imitation }: TransferTx return ( <Box display="flex" flexDirection="row" alignItems="center" gap={1}> - <Typography> - {direction === TransferDirection.INCOMING ? 'Received' : isTxQueued(txStatus) ? 'Send' : 'Sent'}{' '} - <b> - <TransferTx info={txInfo} withLogo={false} omitSign preciseAmount /> - </b> - {direction === TransferDirection.INCOMING ? ' from:' : ' to:'} - </Typography> + {direction === TransferDirection.INCOMING ? 'Received' : isTxQueued(txStatus) ? 'Send' : 'Sent'}{' '} + <b> + <TransferTx info={txInfo} omitSign preciseAmount /> + </b> + {direction === TransferDirection.INCOMING ? ' from' : ' to'} {!trusted && !imitation && <MaliciousTxWarning />} </Box> ) @@ -42,7 +42,7 @@ const TransferTxInfo = ({ txInfo, txStatus, trusted, imitation }: TransferTxInfo <TransferTxInfoMain txInfo={txInfo} txStatus={txStatus} trusted={trusted} imitation={imitation} /> <Box display="flex" alignItems="center" width="100%"> - <EthHashInfo + <NamedAddressInfo address={address.value} name={address.name} customAvatar={address.logoUri} @@ -52,11 +52,36 @@ const TransferTxInfo = ({ txInfo, txStatus, trusted, imitation }: TransferTxInfo trusted={trusted && !imitation} > <TransferActions address={address.value} txInfo={txInfo} trusted={trusted} /> - </EthHashInfo> + </NamedAddressInfo> </Box> {imitation && <ImitationTransactionWarning />} </Box> ) } +export const InlineTransferTxInfo = ({ + value, + tokenInfo, + recipient, +}: { + value: string + tokenInfo: Erc20Token | NativeToken + recipient: string +}) => { + return ( + <Stack direction="row" alignItems="center" spacing={1}> + <Typography>Send</Typography> + <TokenAmount + value={value} + decimals={tokenInfo.decimals} + logoUri={tokenInfo.logoUri} + tokenSymbol={tokenInfo.symbol} + iconSize={16} + /> + <Typography>to</Typography> + <NamedAddressInfo address={recipient} copyAddress={false} shortAddress={true} onlyName avatarSize={16} /> + </Stack> + ) +} + export default TransferTxInfo diff --git a/apps/web/src/components/transactions/TxDetails/TxData/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/index.tsx index 2811dc13c6..4c4e927e8a 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/index.tsx @@ -1,29 +1,31 @@ import SettingsChangeTxInfo from '@/components/transactions/TxDetails/TxData/SettingsChange' -import type { SpendingLimitMethods } from '@/utils/transaction-guards' import { + isStakingTxExitInfo, + isBridgeOrderTxInfo, isExecTxData, + isLifiSwapTxInfo, isOnChainConfirmationTxData, isSafeUpdateTxData, isStakingTxWithdrawInfo, -} from '@/utils/transaction-guards' -import { isStakingTxExitInfo } from '@/utils/transaction-guards' -import { + isVaultDepositTxInfo, + isVaultRedeemTxInfo, isCancellationTxInfo, isCustomTxInfo, isMigrateToL2TxData, isMultisigDetailedExecutionInfo, + isMultiSendTxInfo, isOrderTxInfo, isSettingsChangeTxInfo, isSpendingLimitMethod, isStakingTxDepositInfo, isSupportedSpendingLimitAddress, isTransferTxInfo, + type SpendingLimitMethods, } from '@/utils/transaction-guards' import { SpendingLimits } from '@/components/transactions/TxDetails/TxData/SpendingLimits' import { TransactionStatus, type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' -import { type ReactElement } from 'react' +import type { PropsWithChildren, ReactElement } from 'react' import RejectionTxInfo from '@/components/transactions/TxDetails/TxData/Rejection' -import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData' import TransferTxInfo from '@/components/transactions/TxDetails/TxData/Transfer' import useChainId from '@/hooks/useChainId' import { MigrationToL2TxData } from './MigrationToL2TxData' @@ -34,6 +36,13 @@ import StakingTxWithdrawDetails from '@/features/stake/components/StakingTxWithd import { OnChainConfirmation } from './NestedTransaction/OnChainConfirmation' import { ExecTransaction } from './NestedTransaction/ExecTransaction' import SafeUpdate from './SafeUpdate' +import VaultDepositTxDetails from '@/features/earn/components/VaultDepositTxDetails' +import VaultRedeemTxDetails from '@/features/earn/components/VaultRedeemTxDetails' +import DecodedData from './DecodedData' +import { ErrorBoundary } from '@sentry/react' +import Multisend from './DecodedData/Multisend' +import BridgeTransaction from '@/components/tx/confirmation-views/BridgeTransaction' +import { LifiSwapTransaction } from '@/components/tx/confirmation-views/LifiSwapTransaction' const TxData = ({ txInfo, @@ -41,13 +50,14 @@ const TxData = ({ txDetails, trusted, imitation, -}: { + children, +}: PropsWithChildren<{ txInfo: TransactionDetails['txInfo'] txData: TransactionDetails['txData'] txDetails?: TransactionDetails trusted: boolean imitation: boolean -}): ReactElement => { +}>): ReactElement => { const chainId = useChainId() if (isOrderTxInfo(txInfo)) { @@ -66,6 +76,24 @@ const TxData = ({ return <StakingTxWithdrawDetails info={txInfo} /> } + // @ts-ignore: TODO: Fix this type + if (isVaultDepositTxInfo(txInfo)) { + return <VaultDepositTxDetails info={txInfo} /> + } + + // @ts-ignore: TODO: Fix this type + if (isVaultRedeemTxInfo(txInfo)) { + return <VaultRedeemTxDetails info={txInfo} /> + } + + if (isBridgeOrderTxInfo(txInfo)) { + return <BridgeTransaction txInfo={txInfo} /> + } + + if (isLifiSwapTxInfo(txInfo)) { + return <LifiSwapTransaction txInfo={txInfo} isPreview={false} /> + } + if (isTransferTxInfo(txInfo)) { return ( <TransferTxInfo @@ -109,7 +137,19 @@ const TxData = ({ return <SafeUpdate txData={txData} /> } - return <DecodedData txData={txData} toInfo={isCustomTxInfo(txInfo) ? txInfo.to : undefined} /> + return !!children ? ( + <>{children}</> + ) : ( + <> + <DecodedData txData={txData} toInfo={isCustomTxInfo(txInfo) ? txInfo.to : txData?.to} /> + + {(isMultiSendTxInfo(txInfo) || isOrderTxInfo(txInfo)) && ( + <ErrorBoundary fallback={<div>Error parsing data</div>}> + <Multisend txData={txData} isExecuted={!!txDetails?.executedAt} /> + </ErrorBoundary> + )} + </> + ) } export default TxData diff --git a/apps/web/src/components/transactions/TxDetails/index.tsx b/apps/web/src/components/transactions/TxDetails/index.tsx index 64bec82149..3666a673dc 100644 --- a/apps/web/src/components/transactions/TxDetails/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/index.tsx @@ -1,5 +1,5 @@ import useIsExpiredSwap from '@/features/swap/hooks/useIsExpiredSwap' -import React, { type ReactElement, useEffect } from 'react' +import React, { type ReactElement, useEffect, useRef, useState } from 'react' import type { TransactionDetails, TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' import { Box, CircularProgress, Typography } from '@mui/material' @@ -16,12 +16,14 @@ import { isMultisigExecutionInfo, isOpenSwapOrder, isTxQueued, + isCustomTxInfo, + isBridgeOrderTxInfo, + isLifiSwapTxInfo, } from '@/utils/transaction-guards' import { InfoDetails } from '@/components/transactions/InfoDetails' import NamedAddressInfo from '@/components/common/NamedAddressInfo' import css from './styles.module.css' import ErrorMessage from '@/components/tx/ErrorMessage' -import { TxShareButton } from '../TxShareLink/TxShareButton' import { ErrorBoundary } from '@sentry/react' import ExecuteTxButton from '@/components/transactions/ExecuteTxButton' import SignTxButton from '@/components/transactions/SignTxButton' @@ -32,12 +34,14 @@ import useSafeInfo from '@/hooks/useSafeInfo' import useIsPending from '@/hooks/useIsPending' import { isImitation, isTrustedTx } from '@/utils/transactions' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' import { useGetTransactionDetailsQuery } from '@/store/api/gateway' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { POLLING_INTERVAL } from '@/config/constants' import { TxNote } from '@/features/tx-notes' -import { TxShareBlock } from '../TxShareLink/TxShareBlock' +import { TxShareBlock } from '../TxShareLink' +import { FEATURES } from '@safe-global/utils/utils/chains' +import DecodedData from './TxData/DecodedData' +import { QueuedTxSimulation } from '../QueuedTxSimulation' export const NOT_AVAILABLE = 'n/a' @@ -51,14 +55,20 @@ const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement const hasDefaultTokenlist = useHasFeature(FEATURES.DEFAULT_TOKENLIST) const isQueue = isTxQueued(txSummary.txStatus) const awaitingExecution = isAwaitingExecution(txSummary.txStatus) + + // Used to check if the decoded data was rendered inside the TxData component + // If it was, we hide the decoded data in the Summary to avoid showing it twice + const decodedDataRef = useRef(null) + const [isDecodedDataVisible, setIsDecodedDataVisible] = useState(false) + + useEffect(() => { + // If decodedDataRef.current is not null, the decoded data was rendered inside the TxData component + setIsDecodedDataVisible(!!decodedDataRef.current) + }, []) + const isUnsigned = isMultisigExecutionInfo(txSummary.executionInfo) && txSummary.executionInfo.confirmationsSubmitted === 0 - const isTxFromProposer = - isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) && - txDetails.detailedExecutionInfo.trusted && - isUnsigned - const isUntrusted = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) && !txDetails.detailedExecutionInfo.trusted @@ -68,10 +78,9 @@ const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement let proposer, safeTxHash, proposedByDelegate if (isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo)) { - proposer = txDetails.detailedExecutionInfo.proposer?.value safeTxHash = txDetails.detailedExecutionInfo.safeTxHash - // @ts-expect-error TODO: Need to update the types from the new SDK proposedByDelegate = txDetails.detailedExecutionInfo.proposedByDelegate + proposer = proposedByDelegate?.value ?? txDetails.detailedExecutionInfo.proposer?.value } const expiredSwap = useIsExpiredSwap(txSummary.txInfo) @@ -88,20 +97,31 @@ const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement <TxNote txDetails={txDetails} /> </div> - <div className={css.shareLink}> - <TxShareButton txId={txSummary.id} /> - </div> + <div className={css.detailsWrapper}> + {isQueue && ( + <div className={css.inlineSimulation}> + <QueuedTxSimulation transaction={txDetails} /> + </div> + )} - <div className={css.txData}> - <ErrorBoundary fallback={<div>Error parsing data</div>}> - <TxData - txData={txDetails.txData} - txInfo={txDetails.txInfo} - txDetails={txDetails} - trusted={isTrustedTransfer} - imitation={isImitationTransaction} - /> - </ErrorBoundary> + <div className={css.txData}> + <ErrorBoundary fallback={<div>Error parsing data</div>}> + <TxData + txData={txDetails.txData} + txInfo={txDetails.txInfo} + txDetails={txDetails} + trusted={isTrustedTransfer} + imitation={isImitationTransaction} + > + <Box ref={decodedDataRef}> + <DecodedData + txData={txDetails.txData} + toInfo={isCustomTxInfo(txDetails.txInfo) ? txDetails.txInfo.to : txDetails.txData?.to} + /> + </Box> + </TxData> + </ErrorBoundary> + </div> </div> {/* Module information*/} @@ -122,28 +142,39 @@ const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement <div className={css.txSummary}> {isUntrusted && !isPending && <UnsignedWarning />} - <Summary txDetails={txDetails} /> + <ErrorBoundary fallback={<div>Error parsing data</div>}> + <Summary + txDetails={txDetails} + txData={txDetails.txData} + txInfo={txDetails.txInfo} + showMultisend={false} + showDecodedData={!isDecodedDataVisible} + /> + </ErrorBoundary> </div> - {(isMultiSendTxInfo(txDetails.txInfo) || isOrderTxInfo(txDetails.txInfo)) && ( + {(isMultiSendTxInfo(txDetails.txInfo) || + isOrderTxInfo(txDetails.txInfo) || + isBridgeOrderTxInfo(txDetails.txInfo) || + isLifiSwapTxInfo(txDetails.txInfo)) && ( <div className={css.multiSend}> <ErrorBoundary fallback={<div>Error parsing data</div>}> - <Multisend txData={txDetails.txData} /> + <Multisend txData={txDetails.txData} isExecuted={!!txDetails.executedAt} /> </ErrorBoundary> </div> )} </div> {/* Signers */} - {(!isUnsigned || isTxFromProposer) && ( + {(!isUnsigned || proposedByDelegate) && ( <div className={css.txSigners}> <TxSigners txDetails={txDetails} txSummary={txSummary} - isTxFromProposer={isTxFromProposer} - proposer={proposedByDelegate} + isTxFromProposer={Boolean(proposedByDelegate)} + proposer={proposer} /> - {isQueue && <TxShareBlock txId={txDetails.txId} />} + <TxShareBlock txId={txDetails.txId} txHash={txDetails.txHash} /> {isQueue && ( <Box className={css.buttons}> @@ -183,6 +214,7 @@ const TxDetails = ({ { chainId, txId: txSummary.id }, { pollingInterval: isOpenSwapOrder(txSummary.txInfo) ? POLLING_INTERVAL : undefined, + skipPollingIfUnfocused: true, }, ) diff --git a/apps/web/src/components/transactions/TxDetails/styles.module.css b/apps/web/src/components/transactions/TxDetails/styles.module.css index afd3f2c24c..599b1515d6 100644 --- a/apps/web/src/components/transactions/TxDetails/styles.module.css +++ b/apps/web/src/components/transactions/TxDetails/styles.module.css @@ -4,6 +4,10 @@ overflow-x: auto; } +.detailsWrapper { + position: relative; +} + .details { width: 66.6%; display: flex; @@ -18,6 +22,16 @@ margin-bottom: -40px; } +.inlineSimulation { + display: flex; + justify-content: flex-end; + margin: var(--space-2); + position: absolute; + right: 0; + top: 0; + z-index: 1; +} + .txNote { margin: var(--space-1) 0; padding: 0 var(--space-2) var(--space-2); @@ -41,6 +55,10 @@ border-bottom: 1px solid var(--color-border-light); } +.txData:empty { + display: none; +} + .txSummary, .advancedDetails { height: 100%; @@ -52,6 +70,7 @@ flex-direction: column; padding: var(--space-3); border-left: 1px solid var(--color-border-light); + gap: var(--space-2); } .delegateCall .alert { @@ -69,7 +88,6 @@ align-items: center; justify-content: center; gap: var(--space-1); - margin-top: var(--space-2); } .buttons > * { @@ -95,3 +113,9 @@ border-top: 1px solid var(--color-border-light); } } + +@media (max-width: 1350px) { + .inlineSimulation { + position: relative; + } +} diff --git a/apps/web/src/components/transactions/TxFilterForm/index.tsx b/apps/web/src/components/transactions/TxFilterForm/index.tsx index 9fb166b661..a1910512bf 100644 --- a/apps/web/src/components/transactions/TxFilterForm/index.tsx +++ b/apps/web/src/components/transactions/TxFilterForm/index.tsx @@ -13,7 +13,7 @@ import { useMemo, type ReactElement } from 'react' import AddressBookInput from '@/components/common/AddressBookInput' import DatePickerInput from '@/components/common/DatePickerInput' -import { validateAmount } from '@/utils/validation' +import { validateAmount } from '@safe-global/utils/utils/validation' import { trackEvent } from '@/services/analytics' import { TX_LIST_EVENTS } from '@/services/analytics/events/txList' import { txFilter, useTxFilter, TxFilterType, type TxFilter } from '@/utils/tx-history-filter' @@ -22,6 +22,7 @@ import NumberField from '@/components/common/NumberField' import css from './styles.module.css' import inputCss from '@/styles/inputs.module.css' +import AddressInput from '@/components/common/AddressInput' enum TxFilterFormFieldNames { FILTER_TYPE = 'type', @@ -172,13 +173,14 @@ const TxFilterForm = ({ toggleFilter }: { toggleFilter: () => void }): ReactElem }} /> </Grid> + <Grid item xs={12} md={6}> <Controller name={TxFilterFormFieldNames.AMOUNT} control={control} rules={{ validate: (val: TxFilterFormState[TxFilterFormFieldNames.AMOUNT]) => { - if (val.length > 0) { + if (val?.length > 0) { return validateAmount(val) } }, @@ -189,7 +191,7 @@ const TxFilterForm = ({ toggleFilter }: { toggleFilter: () => void }): ReactElem className={inputCss.input} label={ fieldState.error?.message || - `Amount${isMultisigFilter && chain ? ` (only ${chain.nativeCurrency.symbol})` : ''}` + (isIncomingFilter ? 'Amount' : `Amount (only ${chain?.nativeCurrency.symbol || 'ETH'})`) } error={!!fieldState.error} {...field} @@ -203,9 +205,9 @@ const TxFilterForm = ({ toggleFilter }: { toggleFilter: () => void }): ReactElem {isIncomingFilter && ( <Grid item xs={12} md={6}> - <AddressBookInput + <AddressInput data-testid="token-input" - label="Token" + label="Token address" name={TxFilterFormFieldNames.TOKEN_ADDRESS} required={false} fullWidth @@ -229,7 +231,7 @@ const TxFilterForm = ({ toggleFilter }: { toggleFilter: () => void }): ReactElem control={control} rules={{ validate: (val: TxFilterFormState[TxFilterFormFieldNames.NONCE]) => { - if (val.length > 0) { + if (val?.length > 0) { return validateAmount(val) } }, diff --git a/apps/web/src/components/transactions/TxInfo/index.tsx b/apps/web/src/components/transactions/TxInfo/index.tsx index 3d4a9c4f4e..475680a94f 100644 --- a/apps/web/src/components/transactions/TxInfo/index.tsx +++ b/apps/web/src/components/transactions/TxInfo/index.tsx @@ -23,8 +23,10 @@ import { isStakingTxDepositInfo, isStakingTxExitInfo, isStakingTxWithdrawInfo, + isVaultDepositTxInfo, + isVaultRedeemTxInfo, } from '@/utils/transaction-guards' -import { ellipsis, maybePlural, shortenAddress } from '@/utils/formatters' +import { ellipsis, maybePlural, shortenAddress } from '@safe-global/utils/utils/formatters' import { useCurrentChain } from '@/hooks/useChains' import { SwapTx } from '@/features/swap/components/SwapTxInfo/SwapTx' import StakingTxExitInfo from '@/features/stake/components/StakingTxExitInfo' @@ -32,6 +34,8 @@ import StakingTxWithdrawInfo from '@/features/stake/components/StakingTxWithdraw import { Box } from '@mui/material' import css from './styles.module.css' import StakingTxDepositInfo from '@/features/stake/components/StakingTxDepositInfo' +import VaultDepositTxInfo from '@/features/earn/components/VaultDepositTxInfo' +import VaultRedeemTxInfo from '@/features/earn/components/VaultRedeemTxInfo' export const TransferTx = ({ info, @@ -160,6 +164,14 @@ const TxInfo = ({ info, ...rest }: { info: TransactionInfo; omitSign?: boolean; return <StakingTxWithdrawInfo info={info} /> } + if (isVaultDepositTxInfo(info)) { + return <VaultDepositTxInfo txInfo={info} /> + } + + if (isVaultRedeemTxInfo(info)) { + return <VaultRedeemTxInfo txInfo={info} /> + } + if (isCustomTxInfo(info)) { return <CustomTx info={info} /> } diff --git a/apps/web/src/components/transactions/TxListItem/ExpandableTransactionItem.tsx b/apps/web/src/components/transactions/TxListItem/ExpandableTransactionItem.tsx index f94fb2b358..d73530cddf 100644 --- a/apps/web/src/components/transactions/TxListItem/ExpandableTransactionItem.tsx +++ b/apps/web/src/components/transactions/TxListItem/ExpandableTransactionItem.tsx @@ -57,6 +57,7 @@ export const ExpandableTransactionItem = ({ padding: '12px 0', }, }} + component="div" > <TxSummary item={item} isConflictGroup={isConflictGroup} isBulkGroup={isBulkGroup} /> </AccordionSummary> diff --git a/apps/web/src/components/transactions/TxNavigation/index.tsx b/apps/web/src/components/transactions/TxNavigation/index.tsx index e60aba1ca9..c4cb03db07 100644 --- a/apps/web/src/components/transactions/TxNavigation/index.tsx +++ b/apps/web/src/components/transactions/TxNavigation/index.tsx @@ -2,7 +2,8 @@ import NavTabs from '@/components/common/NavTabs' import { transactionNavItems } from '@/components/sidebar/SidebarNavigation/config' import { AppRoutes } from '@/config/routes' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' + +import { FEATURES } from '@safe-global/utils/utils/chains' const TxNavigation = () => { const isEIP1271 = useHasFeature(FEATURES.EIP1271) diff --git a/apps/web/src/components/transactions/TxShareLink/TxShareBlock.tsx b/apps/web/src/components/transactions/TxShareLink/TxShareBlock.tsx deleted file mode 100644 index 95c5a2d3fd..0000000000 --- a/apps/web/src/components/transactions/TxShareLink/TxShareBlock.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Accordion, AccordionDetails, AccordionSummary, Button, Paper, SvgIcon, Typography } from '@mui/material' -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import type { ReactElement } from 'react' - -import ShareIcon from '@/public/images/messages/link.svg' -import { trackEvent, TX_LIST_EVENTS } from '@/services/analytics' -import TxShareLink from '.' - -import css from './styles.module.css' - -export function TxShareBlock({ txId }: { txId: string }): ReactElement | null { - const onExpand = (_: React.SyntheticEvent, expanded: boolean) => { - if (expanded) { - trackEvent(TX_LIST_EVENTS.OPEN_SHARE_BLOCK) - } - } - - return ( - <Paper className={css.wrapper}> - <Accordion className={css.accordion} onChange={onExpand}> - <AccordionSummary expandIcon={<ExpandMoreIcon />} className={css.summary}> - <Typography className={css.header}>Share link with other signers</Typography> - </AccordionSummary> - <AccordionDetails className={css.details}> - If signers have previously subscribed to notifications, they will be notified about signing this transaction. - You can also share the link with them to speed up the process. - </AccordionDetails> - </Accordion> - <div className={css.copy}> - <TxShareLink id={txId} eventLabel="share-block"> - <Button - variant="outlined" - size="compact" - startIcon={<SvgIcon component={ShareIcon} inheritViewBox fontSize="small" className={css.icon} />} - className={css.button} - > - Copy link - </Button> - </TxShareLink> - </div> - </Paper> - ) -} diff --git a/apps/web/src/components/transactions/TxShareLink/TxShareButton.tsx b/apps/web/src/components/transactions/TxShareLink/TxShareButton.tsx index e9d7bf9eb3..cf4839360b 100644 --- a/apps/web/src/components/transactions/TxShareLink/TxShareButton.tsx +++ b/apps/web/src/components/transactions/TxShareLink/TxShareButton.tsx @@ -3,11 +3,12 @@ import React from 'react' import type { ReactElement } from 'react' import ShareIcon from '@/public/images/common/share.svg' -import TxShareLink from '.' +import TxShareLink from './TxShareLink' +import { CopyDeeplinkLabels } from '@/services/analytics' export function TxShareButton({ txId }: { txId: string }): ReactElement { return ( - <TxShareLink id={txId} eventLabel="button"> + <TxShareLink id={txId} eventLabel={CopyDeeplinkLabels.button}> <IconButton data-testid="share-btn" component={Link} aria-label="Share"> <SvgIcon component={ShareIcon} inheritViewBox fontSize="small" color="border" /> </IconButton> diff --git a/apps/web/src/components/transactions/TxShareLink/TxShareLink.tsx b/apps/web/src/components/transactions/TxShareLink/TxShareLink.tsx new file mode 100644 index 0000000000..3a84f2c5f4 --- /dev/null +++ b/apps/web/src/components/transactions/TxShareLink/TxShareLink.tsx @@ -0,0 +1,34 @@ +import type { ReactElement } from 'react' +import { AppRoutes } from '@/config/routes' +import { useRouter } from 'next/router' +import Track from '@/components/common/Track' +import type { CopyDeeplinkLabels } from '@/services/analytics' +import { TX_LIST_EVENTS } from '@/services/analytics' +import React from 'react' +import CopyTooltip from '@/components/common/CopyTooltip' +import useOrigin from '@/hooks/useOrigin' + +const TxShareLink = ({ + id, + children, + eventLabel, +}: { + id: string + children: ReactElement + eventLabel: CopyDeeplinkLabels +}): ReactElement => { + const router = useRouter() + const { safe = '' } = router.query + const href = `${AppRoutes.transactions.tx}?safe=${safe}&id=${id}` + const txUrl = useOrigin() + href + + return ( + <Track {...TX_LIST_EVENTS.COPY_DEEPLINK} label={eventLabel}> + <CopyTooltip text={txUrl} initialToolTipText="Copy the transaction URL"> + {children} + </CopyTooltip> + </Track> + ) +} + +export default TxShareLink diff --git a/apps/web/src/components/transactions/TxShareLink/index.tsx b/apps/web/src/components/transactions/TxShareLink/index.tsx index 6429082729..479fd666f5 100644 --- a/apps/web/src/components/transactions/TxShareLink/index.tsx +++ b/apps/web/src/components/transactions/TxShareLink/index.tsx @@ -1,33 +1,67 @@ -import type { ReactElement } from 'react' -import { AppRoutes } from '@/config/routes' -import { useRouter } from 'next/router' -import Track from '@/components/common/Track' -import { TX_LIST_EVENTS } from '@/services/analytics' -import React from 'react' -import CopyTooltip from '@/components/common/CopyTooltip' -import useOrigin from '@/hooks/useOrigin' - -const TxShareLink = ({ - id, - children, - eventLabel, -}: { - id: string - children: ReactElement - eventLabel: 'button' | 'share-block' -}): ReactElement => { - const router = useRouter() - const { safe = '' } = router.query - const href = `${AppRoutes.transactions.tx}?safe=${safe}&id=${id}` - const txUrl = useOrigin() + href +import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Paper, SvgIcon, Typography } from '@mui/material' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' + +import ShareIcon from '@/public/images/messages/link.svg' +import { CopyDeeplinkLabels, trackEvent, TX_LIST_EVENTS } from '@/services/analytics' +import TxShareLink from './TxShareLink' + +import css from './styles.module.css' +import { getBlockExplorerLink } from '@safe-global/utils/utils/chains' +import { useCurrentChain } from '@/hooks/useChains' +import ExplorerButton from '@/components/common/ExplorerButton' + +function TxShareAccordion({ noExpand = false }: { noExpand: boolean }) { + const onExpand = (_: React.SyntheticEvent, expanded: boolean) => { + if (expanded) { + trackEvent(TX_LIST_EVENTS.OPEN_SHARE_BLOCK) + } + } + + return ( + <Accordion className={css.accordion} onChange={onExpand} disabled={noExpand}> + <AccordionSummary expandIcon={noExpand ? null : <ExpandMoreIcon />} className={css.summary}> + <Typography className={css.header}>Share link{!noExpand && ' with other signers'}</Typography> + </AccordionSummary> + + <AccordionDetails data-testid="share-block-details" className={css.details}> + If signers have previously subscribed to notifications, they will be notified about signing this transaction. + You can also share the link with them to speed up the process. + </AccordionDetails> + </Accordion> + ) +} + +function ExplorerLink({ txHash }: { txHash: string }) { + const chain = useCurrentChain() + const explorerLiknk = chain ? getBlockExplorerLink(chain, txHash) : undefined return ( - <Track {...TX_LIST_EVENTS.COPY_DEEPLINK} label={eventLabel}> - <CopyTooltip text={txUrl} initialToolTipText="Copy the transaction URL"> - {children} - </CopyTooltip> - </Track> + <Button variant="text" size="compact" className={css.button}> + <ExplorerButton {...explorerLiknk} isCompact={false} fontSize="14px" /> + </Button> ) } -export default TxShareLink +export function TxShareBlock({ txId, txHash }: { txId: string; txHash?: string }) { + return ( + <Paper data-testid="share-block" className={css.wrapper}> + <TxShareAccordion noExpand={!!txHash} /> + + <Box p={2} pt={0.5} display="flex" flexWrap="wrap" alignItems="center" gap={1}> + <TxShareLink id={txId} eventLabel={CopyDeeplinkLabels.shareBlock}> + <Button + data-testid="copy-link-btn" + variant="outlined" + size="compact" + startIcon={<SvgIcon component={ShareIcon} inheritViewBox fontSize="small" />} + className={css.button} + > + Copy link + </Button> + </TxShareLink> + + {txHash && <ExplorerLink txHash={txHash} />} + </Box> + </Paper> + ) +} diff --git a/apps/web/src/components/transactions/TxShareLink/styles.module.css b/apps/web/src/components/transactions/TxShareLink/styles.module.css index 17838ed674..3ede08d152 100644 --- a/apps/web/src/components/transactions/TxShareLink/styles.module.css +++ b/apps/web/src/components/transactions/TxShareLink/styles.module.css @@ -1,14 +1,21 @@ .wrapper { border: 1px solid var(--color-border-light); - margin-top: var(--space-2); } .accordion { border: unset; } +.accordion { + background-color: unset !important; +} + +.accordion button { + opacity: 1 !important; +} + .summary { - background: unset !important; + background-color: unset !important; } .header { @@ -20,18 +27,9 @@ padding-bottom: 12px; } -.copy { - padding: var(--space-2); - padding-top: 4px; -} - -.icon { - margin-right: 4px; -} - .button { + padding: var(--space-2); padding-top: 4px; padding-bottom: 4px; - padding-left: var(--space-2); - padding-right: var(--space-2); + white-space: nowrap; } diff --git a/apps/web/src/components/transactions/TxSigners/index.tsx b/apps/web/src/components/transactions/TxSigners/index.tsx index da3ad2705a..5e7a534139 100644 --- a/apps/web/src/components/transactions/TxSigners/index.tsx +++ b/apps/web/src/components/transactions/TxSigners/index.tsx @@ -12,7 +12,6 @@ import { type ListItemIconProps, } from '@mui/material' import type { - AddressEx, DetailedExecutionInfo, TransactionDetails, TransactionSummary, @@ -107,7 +106,7 @@ type TxSignersProps = { txDetails: TransactionDetails txSummary: TransactionSummary isTxFromProposer: boolean - proposer?: AddressEx + proposer?: string } export const TxSigners = ({ @@ -140,7 +139,7 @@ export const TxSigners = ({ const isConfirmed = confirmationsNeeded <= 0 || canExecute return ( - <> + <Box mb={2}> <List data-testid="transaction-actions-list" className={css.signers}> <ListItem> {isCancellationTxInfo(txInfo) ? ( @@ -163,12 +162,12 @@ export const TxSigners = ({ </ListItem> {proposer && ( - <ListItem key={proposer.value} sx={{ py: 0 }}> + <ListItem key={proposer} sx={{ py: 0 }}> <StyledListItemIcon $state={StepState.CONFIRMED}> <Dot /> </StyledListItemIcon> <ListItemText data-testid="signer"> - <EthHashInfo address={proposer.value} hasExplorer showCopyButton /> + <EthHashInfo address={proposer} hasExplorer showCopyButton /> </ListItemText> </ListItem> )} @@ -253,7 +252,7 @@ export const TxSigners = ({ </Box> ) )} - </> + </Box> ) } diff --git a/apps/web/src/components/transactions/TxSummary/index.tsx b/apps/web/src/components/transactions/TxSummary/index.tsx index dcfe22bdb8..5bc72170d8 100644 --- a/apps/web/src/components/transactions/TxSummary/index.tsx +++ b/apps/web/src/components/transactions/TxSummary/index.tsx @@ -17,8 +17,8 @@ import QueueActions from './QueueActions' import useIsPending from '@/hooks/useIsPending' import TxConfirmations from '../TxConfirmations' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' import TxStatusLabel from '@/components/transactions/TxStatusLabel' +import { FEATURES } from '@safe-global/utils/utils/chains' type TxSummaryProps = { isConflictGroup?: boolean diff --git a/apps/web/src/components/transactions/TxType/index.tsx b/apps/web/src/components/transactions/TxType/index.tsx index 2c954f0b09..9fbffd993a 100644 --- a/apps/web/src/components/transactions/TxType/index.tsx +++ b/apps/web/src/components/transactions/TxType/index.tsx @@ -9,6 +9,32 @@ type TxTypeProps = { tx: TransactionSummary } +export const TxTypeIcon = ({ tx }: TxTypeProps) => { + const type = useTransactionType(tx) + + return ( + <Box className={css.txType}> + {isValidElement(type.icon) ? ( + type.icon + ) : typeof type.icon == 'string' ? ( + <SafeAppIconCard + src={type.icon} + alt={type.text} + width={16} + height={16} + fallback="/images/transactions/custom.svg" + /> + ) : null} + </Box> + ) +} + +export const TxTypeText = ({ tx }: TxTypeProps) => { + const type = useTransactionType(tx) + + return type.text +} + const TxType = ({ tx }: TxTypeProps) => { const type = useTransactionType(tx) diff --git a/apps/web/src/components/transactions/Warning/index.tsx b/apps/web/src/components/transactions/Warning/index.tsx index ea345adfa1..6fa9232753 100644 --- a/apps/web/src/components/transactions/Warning/index.tsx +++ b/apps/web/src/components/transactions/Warning/index.tsx @@ -5,11 +5,9 @@ import type { AlertColor } from '@mui/material' import InfoOutlinedIcon from '@/public/images/notifications/info.svg' import css from './styles.module.css' import ExternalLink from '@/components/common/ExternalLink' -import { HelpCenterArticle } from '@/config/constants' -import { maybePlural } from '@/utils/formatters' -import { useIsOfficialFallbackHandler } from '@/hooks/useIsOfficialFallbackHandler' -import { useIsTWAPFallbackHandler } from '@/features/swap/hooks/useIsTWAPFallbackHandler' +import { maybePlural } from '@safe-global/utils/utils/formatters' import { UntrustedFallbackHandlerTxText } from '@/components/tx/confirmation-views/SettingsChange/UntrustedFallbackHandlerTxAlert' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' const Warning = ({ datatestid, @@ -59,28 +57,17 @@ export const DelegateCallWarning = ({ showWarning }: { showWarning: boolean }): } export const UntrustedFallbackHandlerWarning = ({ - fallbackHandler, isTxExecuted = false, }: { - fallbackHandler: string isTxExecuted?: boolean -}): ReactElement | null => { - const isOfficial = useIsOfficialFallbackHandler(fallbackHandler) - const isTWAPFallbackHandler = useIsTWAPFallbackHandler(fallbackHandler) - - if (isOfficial || isTWAPFallbackHandler) { - return null - } - - return ( - <Warning - datatestid="untrusted-fallback-handler-warning" - title={<UntrustedFallbackHandlerTxText isTxExecuted={isTxExecuted} />} - severity="warning" - text="Untrusted fallback handler" - /> - ) -} +}): ReactElement | null => ( + <Warning + datatestid="untrusted-fallback-handler-warning" + title={<UntrustedFallbackHandlerTxText isTxExecuted={isTxExecuted} />} + severity="warning" + text="Untrusted fallback handler" + /> +) export const ApprovalWarning = ({ approvalTxCount }: { approvalTxCount: number }): ReactElement => ( <Warning title="" severity="warning" text={`${approvalTxCount} ERC20 approval${maybePlural(approvalTxCount)}`} /> diff --git a/apps/web/src/components/tx-flow/SafeTxProvider.tsx b/apps/web/src/components/tx-flow/SafeTxProvider.tsx index 8d011529c5..acc8821c3f 100644 --- a/apps/web/src/components/tx-flow/SafeTxProvider.tsx +++ b/apps/web/src/components/tx-flow/SafeTxProvider.tsx @@ -1,21 +1,17 @@ -import { createContext, useState, useEffect, useCallback } from 'react' +import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' +import { createContext, useState, useEffect } from 'react' import type { Dispatch, ReactNode, SetStateAction, ReactElement } from 'react' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction } from '@safe-global/types-kit' import { createTx } from '@/services/tx/tx-sender' import { useRecommendedNonce, useSafeTxGas } from '../tx/SignOrExecuteForm/hooks' import { Errors, logError } from '@/services/exceptions' -import type { EIP712TypedData } from '@safe-global/safe-gateway-typescript-sdk' -import useSafeInfo from '@/hooks/useSafeInfo' -import { useCurrentChain } from '@/hooks/useChains' -import { prependSafeToL2Migration } from '@/utils/safe-migrations' -import { useSelectAvailableSigner } from '@/hooks/wallets/useSelectAvailableSigner' export type SafeTxContextParams = { safeTx?: SafeTransaction setSafeTx: Dispatch<SetStateAction<SafeTransaction | undefined>> - safeMessage?: EIP712TypedData - setSafeMessage: Dispatch<SetStateAction<EIP712TypedData | undefined>> + safeMessage?: TypedData + setSafeMessage: Dispatch<SetStateAction<TypedData | undefined>> safeTxError?: Error setSafeTxError: Dispatch<SetStateAction<Error | undefined>> @@ -32,6 +28,11 @@ export type SafeTxContextParams = { txOrigin?: string setTxOrigin: Dispatch<SetStateAction<string | undefined>> + + isReadOnly: boolean + setIsReadOnly: Dispatch<SetStateAction<boolean>> + isMassPayout?: boolean + setIsMassPayout: Dispatch<SetStateAction<boolean | undefined>> } export const SafeTxContext = createContext<SafeTxContextParams>({ @@ -42,56 +43,40 @@ export const SafeTxContext = createContext<SafeTxContextParams>({ setNonceNeeded: () => {}, setSafeTxGas: () => {}, setTxOrigin: () => {}, + isReadOnly: false, + setIsReadOnly: () => {}, + setIsMassPayout: () => {}, }) const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => { const [safeTx, setSafeTx] = useState<SafeTransaction>() - const [safeMessage, setSafeMessage] = useState<EIP712TypedData>() + const [safeMessage, setSafeMessage] = useState<TypedData>() const [safeTxError, setSafeTxError] = useState<Error>() const [nonce, setNonce] = useState<number>() const [nonceNeeded, setNonceNeeded] = useState<boolean>(true) const [safeTxGas, setSafeTxGas] = useState<string>() const [txOrigin, setTxOrigin] = useState<string>() - - const { safe } = useSafeInfo() - const chain = useCurrentChain() - const selectAvailableSigner = useSelectAvailableSigner() - - const setAndMigrateSafeTx: Dispatch<SetStateAction<SafeTransaction | undefined>> = useCallback( - ( - value: SafeTransaction | undefined | ((prevState: SafeTransaction | undefined) => SafeTransaction | undefined), - ) => { - let safeTx: SafeTransaction | undefined - if (typeof value === 'function') { - safeTx = value(safeTx) - } else { - safeTx = value - } - - prependSafeToL2Migration(safeTx, safe, chain).then(setSafeTx) - - // Select a matching signer when we update the transaction - selectAvailableSigner(safeTx, safe) - }, - [chain, safe, selectAvailableSigner], - ) + const [isReadOnly, setIsReadOnly] = useState<boolean>(false) + const [isMassPayout, setIsMassPayout] = useState<boolean>() // Signed txs cannot be updated - const isSigned = safeTx && safeTx.signatures.size > 0 + const isSigned = Boolean(safeTx && safeTx.signatures.size > 0) // Recommended nonce and safeTxGas const recommendedNonce = useRecommendedNonce() const recommendedSafeTxGas = useSafeTxGas(safeTx) + const canEdit = !isSigned && !isReadOnly + // Priority to external nonce, then to the recommended one - const finalNonce = isSigned ? safeTx?.data.nonce : (nonce ?? recommendedNonce ?? safeTx?.data.nonce) - const finalSafeTxGas = isSigned - ? safeTx?.data.safeTxGas - : (safeTxGas ?? recommendedSafeTxGas ?? safeTx?.data.safeTxGas) + const finalNonce = canEdit ? (nonce ?? recommendedNonce ?? safeTx?.data.nonce) : safeTx?.data.nonce + const finalSafeTxGas = canEdit + ? (safeTxGas ?? recommendedSafeTxGas ?? safeTx?.data.safeTxGas) + : safeTx?.data.safeTxGas // Update the tx when the nonce or safeTxGas change useEffect(() => { - if (isSigned || !safeTx?.data) return + if (!canEdit || !safeTx?.data) return if (safeTx.data.nonce === finalNonce && safeTx.data.safeTxGas === finalSafeTxGas) return setSafeTxError(undefined) @@ -101,7 +86,7 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => setSafeTx(tx) }) .catch(setSafeTxError) - }, [isSigned, finalNonce, finalSafeTxGas, safeTx?.data]) + }, [canEdit, finalNonce, finalSafeTxGas, safeTx?.data]) // Log errors useEffect(() => { @@ -113,7 +98,7 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => value={{ safeTx, safeTxError, - setSafeTx: setAndMigrateSafeTx, + setSafeTx, setSafeTxError, safeMessage, setSafeMessage, @@ -126,6 +111,10 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => recommendedNonce, txOrigin, setTxOrigin, + isReadOnly, + setIsReadOnly, + isMassPayout, + setIsMassPayout, }} > {children} diff --git a/apps/web/src/components/tx-flow/TxFlow.tsx b/apps/web/src/components/tx-flow/TxFlow.tsx new file mode 100644 index 0000000000..ec6da262f8 --- /dev/null +++ b/apps/web/src/components/tx-flow/TxFlow.tsx @@ -0,0 +1,123 @@ +import React, { useCallback, useMemo, type ReactNode } from 'react' +import useTxStepper from './useTxStepper' +import SafeTxProvider from './SafeTxProvider' +import { TxInfoProvider } from './TxInfoProvider' +import { TxSecurityProvider } from '../tx/security/shared/TxSecurityContext' +import TxFlowProvider, { type TxFlowProviderProps, type TxFlowContextType } from './TxFlowProvider' +import { TxFlowContent } from './common/TxFlowContent' +import ReviewTransaction from '../tx/ReviewTransactionV2' +import { ConfirmTxReceipt } from '../tx/ConfirmTxReceipt' +import { TxChecks, TxNote, SignerSelect, Blockaid } from './features' +import { Batching, ComboSubmit, Counterfactual, Execute, ExecuteThroughRole, Propose, Sign } from './actions' +import { SlotProvider } from './slots' +import { useTrackTimeSpent } from '../tx/SignOrExecuteForm/tracking' + +type SubmitCallbackProps = { txId?: string; isExecuted?: boolean } +export type SubmitCallback = (args?: SubmitCallbackProps) => void +export type SubmitCallbackWithData<T> = (args: SubmitCallbackProps & { data?: T }) => void + +type TxFlowProps<T extends unknown> = { + children?: ReactNode[] | ReactNode + initialData?: T + onSubmit?: SubmitCallbackWithData<T> + txId?: TxFlowProviderProps<T>['txId'] + txNonce?: TxFlowProviderProps<T>['txNonce'] + onlyExecute?: TxFlowProviderProps<T>['onlyExecute'] + isExecutable?: TxFlowProviderProps<T>['isExecutable'] + isRejection?: TxFlowProviderProps<T>['isRejection'] + isBatch?: TxFlowProviderProps<T>['isBatch'] + isBatchable?: TxFlowProviderProps<T>['isBatchable'] + ReviewTransactionComponent?: typeof ReviewTransaction + eventCategory?: string +} & TxFlowContextType['txLayoutProps'] + +/** + * TxFlow component is a wrapper for the transaction flow, providing context and state management. + * It uses various providers to manage the transaction state and security context. + * The component also handles the transaction steps and progress. + * It accepts children components to be rendered within the flow. + */ +export const TxFlow = <T extends unknown>({ + children = [], + initialData, + txId, + txNonce, + onSubmit, + onlyExecute, + isExecutable, + isRejection, + isBatch, + isBatchable, + ReviewTransactionComponent = ReviewTransaction, + eventCategory, + ...txLayoutProps +}: TxFlowProps<T>) => { + const { step, data, nextStep, prevStep } = useTxStepper(initialData, eventCategory) + + const childrenArray = Array.isArray(children) ? children : [children] + + const progress = useMemo( + () => Math.round(((step + 1) / (childrenArray.length + 2)) * 100), + [step, childrenArray.length], + ) + + const trackTimeSpent = useTrackTimeSpent() + + const handleFlowSubmit = useCallback<SubmitCallback>( + (props) => { + onSubmit?.({ ...props, data }) + trackTimeSpent() + }, + [onSubmit, data, trackTimeSpent], + ) + + return ( + <SafeTxProvider> + <TxInfoProvider> + <TxSecurityProvider> + <SlotProvider> + <TxFlowProvider + step={step} + data={data} + nextStep={nextStep} + prevStep={prevStep} + progress={progress} + txId={txId} + txNonce={txNonce} + txLayoutProps={txLayoutProps} + onlyExecute={onlyExecute} + isExecutable={isExecutable} + isRejection={isRejection} + isBatch={isBatch} + isBatchable={isBatchable} + > + <TxFlowContent> + {...childrenArray} + + <ReviewTransactionComponent onSubmit={() => nextStep()}> + <TxChecks /> + <TxNote /> + <SignerSelect /> + <Blockaid /> + </ReviewTransactionComponent> + + <ConfirmTxReceipt onSubmit={handleFlowSubmit}> + <Counterfactual /> + <ExecuteThroughRole /> + + <ComboSubmit> + <Sign /> + <Execute /> + <Batching /> + </ComboSubmit> + + <Propose /> + </ConfirmTxReceipt> + </TxFlowContent> + </TxFlowProvider> + </SlotProvider> + </TxSecurityProvider> + </TxInfoProvider> + </SafeTxProvider> + ) +} diff --git a/apps/web/src/components/tx-flow/TxFlowProvider.tsx b/apps/web/src/components/tx-flow/TxFlowProvider.tsx new file mode 100644 index 0000000000..24385bbef5 --- /dev/null +++ b/apps/web/src/components/tx-flow/TxFlowProvider.tsx @@ -0,0 +1,255 @@ +import { createContext, useCallback, useContext, useState } from 'react' +import type { ReactNode, ReactElement, SetStateAction, Dispatch, ComponentType } from 'react' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { useIsWalletProposer } from '@/hooks/useProposers' +import { useImmediatelyExecutable, useValidateNonce } from '@/components/tx/SignOrExecuteForm/hooks' +import { useAppSelector } from '@/store' +import { selectSettings } from '@/store/settingsSlice' +import { + findAllowingRole, + findMostLikelyRole, + type Role, + useRoles, +} from '@/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/hooks' +import { SafeTxContext } from '../tx-flow/SafeTxProvider' +import { useLazyGetTransactionDetailsQuery } from '@/store/slices' +import { trackTxEvents } from '../tx/SignOrExecuteForm/tracking' +import { useSigner } from '@/hooks/wallets/useWallet' +import useChainId from '@/hooks/useChainId' +import useIsCounterfactualSafe from '@/features/counterfactual/hooks/useIsCounterfactualSafe' +import useTxDetails from '@/hooks/useTxDetails' +import type { TransactionDetails, TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' + +export type TxFlowContextType<T extends unknown = any> = { + step: number + progress: number + data?: T + onPrev: () => void + onNext: (data?: T) => void + + txLayoutProps: { + title?: ReactNode + subtitle?: ReactNode + icon?: ComponentType + txSummary?: TransactionSummary + hideNonce?: boolean + fixedNonce?: boolean + hideProgress?: boolean + isReplacement?: boolean + isMessage?: boolean + } + updateTxLayoutProps: (props: TxFlowContextType['txLayoutProps']) => void + trackTxEvent: (txId: string, isExecuted?: boolean, isRoleExecution?: boolean, isProposerCreation?: boolean) => void + + txId?: string + txNonce?: number + isCreation: boolean + isRejection: boolean + onlyExecute: boolean + isProposing: boolean + willExecute: boolean + isExecutable: boolean + canExecute: boolean + shouldExecute: boolean + setShouldExecute: Dispatch<SetStateAction<boolean>> + + isSubmitLoading: boolean + setIsSubmitLoading: Dispatch<SetStateAction<boolean>> + + isSubmitDisabled: boolean + setIsSubmitDisabled: Dispatch<SetStateAction<boolean>> + + submitError?: Error + setSubmitError: Dispatch<SetStateAction<Error | undefined>> + isRejectedByUser: boolean + setIsRejectedByUser: Dispatch<SetStateAction<boolean>> + + willExecuteThroughRole: boolean + canExecuteThroughRole: boolean + txDetails?: TransactionDetails + txDetailsLoading?: boolean + isBatch: boolean + isBatchable: boolean + role?: Role +} + +export const initialContext: TxFlowContextType = { + step: 0, + progress: 0, + data: undefined, + onPrev: () => {}, + onNext: () => {}, + + txLayoutProps: {}, + updateTxLayoutProps: () => {}, + trackTxEvent: () => {}, + + isCreation: false, + isRejection: false, + onlyExecute: false, + isProposing: false, + willExecute: false, + isExecutable: false, + canExecute: false, + shouldExecute: false, + setShouldExecute: () => {}, + + isSubmitLoading: false, + setIsSubmitLoading: () => {}, + + isSubmitDisabled: false, + setIsSubmitDisabled: () => {}, + + submitError: undefined, + setSubmitError: () => {}, + isRejectedByUser: false, + setIsRejectedByUser: () => {}, + + willExecuteThroughRole: false, + canExecuteThroughRole: false, + isBatch: false, + isBatchable: true, +} + +export const TxFlowContext = createContext<TxFlowContextType>(initialContext) + +export type TxFlowProviderProps<T extends unknown> = { + children: ReactNode + step: number + data: T + prevStep: () => void + nextStep: (data: T) => void + progress?: number + txId?: string + txNonce?: TxFlowContextType['txNonce'] + isExecutable?: boolean + onlyExecute?: TxFlowContextType['onlyExecute'] + isRejection?: TxFlowContextType['isRejection'] + txLayoutProps?: TxFlowContextType['txLayoutProps'] + isBatch?: TxFlowContextType['isBatch'] + isBatchable?: TxFlowContextType['isBatchable'] +} + +const TxFlowProvider = <T extends unknown>({ + children, + step, + data, + nextStep, + prevStep, + progress = 0, + txId, + txNonce, + isExecutable = false, + onlyExecute = initialContext.onlyExecute, + txLayoutProps: defaultTxLayoutProps = initialContext.txLayoutProps, + isRejection = initialContext.isRejection, + isBatch = initialContext.isBatch, + isBatchable = initialContext.isBatchable, +}: TxFlowProviderProps<T>): ReactElement => { + const signer = useSigner() + const isSafeOwner = useIsSafeOwner() + const isProposer = useIsWalletProposer() + const chainId = useChainId() + const { safeTx, txOrigin, isMassPayout } = useContext(SafeTxContext) + const isCorrectNonce = useValidateNonce(safeTx) + const { transactionExecution } = useAppSelector(selectSettings) + const [shouldExecute, setShouldExecute] = useState<boolean>(transactionExecution) + const [isSubmitLoading, setIsSubmitLoading] = useState<boolean>(initialContext.isSubmitLoading) + const [isSubmitDisabled, setIsSubmitDisabled] = useState<boolean>(initialContext.isSubmitDisabled) + const [submitError, setSubmitError] = useState<Error | undefined>(initialContext.submitError) + const [isRejectedByUser, setIsRejectedByUser] = useState<boolean>(initialContext.isRejectedByUser) + const [txLayoutProps, setTxLayoutProps] = useState<TxFlowContextType['txLayoutProps']>(defaultTxLayoutProps) + const [trigger] = useLazyGetTransactionDetailsQuery() + const isCounterfactualSafe = useIsCounterfactualSafe() + const [txDetails, , txDetailsLoading] = useTxDetails(txId) + + const isCreation = !txId + const isNewExecutableTx = useImmediatelyExecutable() && isCreation + + const isProposing = !!isProposer && !isSafeOwner && isCreation + + // Check if a Zodiac Roles mod is enabled and if the user is a member of any role that allows the transaction + const roles = useRoles( + !isCounterfactualSafe && isCreation && !(isNewExecutableTx && isSafeOwner) ? safeTx : undefined, + ) + const allowingRole = findAllowingRole(roles) + const mostLikelyRole = findMostLikelyRole(roles) + const canExecuteThroughRole = !!allowingRole || (!!mostLikelyRole && !isSafeOwner) + const preferThroughRole = canExecuteThroughRole && !isSafeOwner // execute through role if a non-owner role member wallet is connected + + // If checkbox is checked and the transaction is executable, execute it, otherwise sign it + const canExecute = isCorrectNonce && (isExecutable || isNewExecutableTx) + const willExecute = (onlyExecute || shouldExecute) && canExecute && !preferThroughRole + const willExecuteThroughRole = + (onlyExecute || shouldExecute) && canExecuteThroughRole && (!canExecute || preferThroughRole) + + const updateTxLayoutProps = useCallback((props: TxFlowContextType['txLayoutProps']) => { + setTxLayoutProps({ ...defaultTxLayoutProps, ...props }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const trackTxEvent = useCallback( + async (txId: string, isExecuted = false, isRoleExecution = false, isProposerCreation = false) => { + const { data: details } = await trigger({ chainId, txId }) + // Track tx event + trackTxEvents( + details, + !!isCreation, + isExecuted, + isRoleExecution, + isProposerCreation, + !!signer?.isSafe, + txOrigin, + isMassPayout, + ) + }, + [chainId, isCreation, trigger, signer?.isSafe, txOrigin, isMassPayout], + ) + + const value = { + step, + progress, + data, + onPrev: prevStep, + onNext: nextStep, + + txLayoutProps, + updateTxLayoutProps, + trackTxEvent, + + txId, + txNonce, + isCreation, + isRejection, + onlyExecute, + isProposing, + isExecutable, + canExecute, + willExecute, + shouldExecute, + setShouldExecute, + + isSubmitLoading, + setIsSubmitLoading, + + isSubmitDisabled: isSubmitDisabled || isSubmitLoading, + setIsSubmitDisabled, + + submitError, + setSubmitError, + isRejectedByUser, + setIsRejectedByUser, + + willExecuteThroughRole, + canExecuteThroughRole, + role: allowingRole || mostLikelyRole, + txDetails, + txDetailsLoading, + isBatch, + isBatchable, + } + + return <TxFlowContext.Provider value={value}>{children}</TxFlowContext.Provider> +} + +export default TxFlowProvider diff --git a/apps/web/src/components/tx-flow/TxFlowStep.tsx b/apps/web/src/components/tx-flow/TxFlowStep.tsx new file mode 100644 index 0000000000..ddc4977d67 --- /dev/null +++ b/apps/web/src/components/tx-flow/TxFlowStep.tsx @@ -0,0 +1,19 @@ +import React, { type ReactNode, useContext, useEffect } from 'react' +import { TxFlowContext, type TxFlowContextType } from '../tx-flow/TxFlowProvider' + +export type TxFlowStepProps = TxFlowContextType['txLayoutProps'] & { children?: ReactNode } + +/** + * TxFlowStep is a component that allows you to set the layout properties for a transaction flow step. + * It uses the TxFlowContext to update the layout properties when the component is mounted. + */ +export const TxFlowStep = ({ children, ...txLayoutProps }: TxFlowStepProps) => { + const { updateTxLayoutProps } = useContext(TxFlowContext) + + useEffect(() => { + updateTxLayoutProps(txLayoutProps) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [txLayoutProps.subtitle, txLayoutProps.title]) + + return <>{children}</> +} diff --git a/apps/web/src/components/tx-flow/TxInfoProvider.tsx b/apps/web/src/components/tx-flow/TxInfoProvider.tsx index 6187942349..16a6927a1d 100644 --- a/apps/web/src/components/tx-flow/TxInfoProvider.tsx +++ b/apps/web/src/components/tx-flow/TxInfoProvider.tsx @@ -1,15 +1,9 @@ import { createContext, type ReactElement } from 'react' -import { useSimulation, type UseSimulationReturn } from '@/components/tx/security/tenderly/useSimulation' -import { FETCH_STATUS, type TenderlySimulation } from '@/components/tx/security/tenderly/types' - -const getCallTraceErrors = (simulation?: TenderlySimulation) => { - if (!simulation || !simulation.simulation.status) { - return [] - } - - return simulation.transaction.call_trace.filter((call) => call.error) -} +import { useSimulation } from '@/components/tx/security/tenderly/useSimulation' +import { FETCH_STATUS } from '@safe-global/utils/components/tx/security/tenderly/types' +import type { UseSimulationReturn } from '@safe-global/utils/components/tx/security/tenderly/useSimulation' +import { getSimulationStatus } from '@safe-global/utils/components/tx/security/tenderly/utils' type SimulationStatus = { isLoading: boolean @@ -19,49 +13,49 @@ type SimulationStatus = { isError: boolean } +const initialSimulation: UseSimulationReturn = { + simulateTransaction: () => {}, + simulation: undefined, + _simulationRequestStatus: FETCH_STATUS.NOT_ASKED, + simulationLink: '', + requestError: undefined, + resetSimulation: () => {}, +} + +const initialStatus: SimulationStatus = { + isLoading: false, + isFinished: false, + isSuccess: false, + isCallTraceError: false, + isError: false, +} + export const TxInfoContext = createContext<{ simulation: UseSimulationReturn status: SimulationStatus + nestedTx: { + simulation: UseSimulationReturn + status: SimulationStatus + } }>({ - simulation: { - simulateTransaction: () => {}, - simulation: undefined, - _simulationRequestStatus: FETCH_STATUS.NOT_ASKED, - simulationLink: '', - requestError: undefined, - resetSimulation: () => {}, - }, - status: { - isLoading: false, - isFinished: false, - isSuccess: false, - isCallTraceError: false, - isError: false, + simulation: initialSimulation, + status: initialStatus, + nestedTx: { + simulation: initialSimulation, + status: initialStatus, }, }) export const TxInfoProvider = ({ children }: { children: ReactElement }) => { const simulation = useSimulation() + const nestedSimulation = useSimulation() - const isLoading = simulation._simulationRequestStatus === FETCH_STATUS.LOADING - - const isFinished = - simulation._simulationRequestStatus === FETCH_STATUS.SUCCESS || - simulation._simulationRequestStatus === FETCH_STATUS.ERROR - - const isSuccess = simulation.simulation?.simulation.status || false - - // Safe can emit failure event even though Tenderly simulation succeeds - const isCallTraceError = isSuccess && getCallTraceErrors(simulation.simulation).length > 0 - const isError = simulation._simulationRequestStatus === FETCH_STATUS.ERROR + const status = getSimulationStatus(simulation) - const status = { - isLoading, - isFinished, - isSuccess, - isCallTraceError, - isError, + const nestedTx = { + simulation: nestedSimulation, + status: getSimulationStatus(nestedSimulation), } - return <TxInfoContext.Provider value={{ simulation, status }}>{children}</TxInfoContext.Provider> + return <TxInfoContext.Provider value={{ simulation, status, nestedTx }}>{children}</TxInfoContext.Provider> } diff --git a/apps/web/src/components/tx/SignOrExecuteForm/BatchButton.tsx b/apps/web/src/components/tx-flow/actions/Batching/BatchButton.tsx similarity index 100% rename from apps/web/src/components/tx/SignOrExecuteForm/BatchButton.tsx rename to apps/web/src/components/tx-flow/actions/Batching/BatchButton.tsx diff --git a/apps/web/src/components/tx-flow/actions/Batching/index.tsx b/apps/web/src/components/tx-flow/actions/Batching/index.tsx new file mode 100644 index 0000000000..5303ccc3a8 --- /dev/null +++ b/apps/web/src/components/tx-flow/actions/Batching/index.tsx @@ -0,0 +1,108 @@ +import { useContext, type SyntheticEvent } from 'react' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { useTxActions } from '@/components/tx/SignOrExecuteForm/hooks' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { isDelegateCall as checkIsDelegateCall } from '@/services/tx/tx-sender/sdk' +import { TxModalContext } from '@/components/tx-flow' +import { TxFlowContext } from '../../TxFlowProvider' +import useIsCounterfactualSafe from '@/features/counterfactual/hooks/useIsCounterfactualSafe' +import { type SlotComponentProps, SlotName, withSlot } from '../../slots' +import { asError } from '@safe-global/utils/services/exceptions/utils' +import { Errors, logError } from '@/services/exceptions' +import SplitMenuButton from '@/components/common/SplitMenuButton' +import { BATCH_EVENTS, trackEvent } from '@/services/analytics' +import { TxCardActions } from '../../common/TxCard' +import { Box, Divider } from '@mui/material' +import commonCss from '@/components/tx-flow/common/styles.module.css' + +const Batching = ({ + onSubmit, + onSubmitSuccess, + options = [], + onChange, + disabled = false, + slotId, +}: SlotComponentProps<SlotName.ComboSubmit>) => { + const { setTxFlow } = useContext(TxModalContext) + const { addToBatch } = useTxActions() + const { safeTx } = useContext(SafeTxContext) + const { isSubmitDisabled, setIsSubmitLoading, isSubmitLoading, setSubmitError, setIsRejectedByUser } = + useContext(TxFlowContext) + + const handleSubmit = async (e: SyntheticEvent) => { + e.preventDefault() + + if (!safeTx) return + + onSubmit?.() + + trackEvent(BATCH_EVENTS.BATCH_APPEND) + + setIsSubmitLoading(true) + setIsRejectedByUser(false) + setSubmitError(undefined) + + try { + await addToBatch(safeTx, origin) + } catch (_err) { + const err = asError(_err) + logError(Errors._819, err) + setSubmitError(err) + + setIsSubmitLoading(false) + return + } + + onSubmitSuccess?.({ isExecuted: false }) + + setIsSubmitLoading(false) + + setTxFlow(undefined) + } + + return ( + <Box> + <Divider className={commonCss.nestedDivider} /> + + <TxCardActions> + <SplitMenuButton + onClick={(_, e) => handleSubmit(e)} + selected={slotId} + onChange={({ id }) => onChange(id)} + options={options} + disabled={isSubmitDisabled || disabled} + loading={isSubmitLoading} + /> + </TxCardActions> + </Box> + ) +} + +const useShouldRegisterSlot = () => { + const isCounterfactualSafe = useIsCounterfactualSafe() + const { isBatch, isProposing, willExecuteThroughRole, isCreation, isBatchable } = useContext(TxFlowContext) + const isOwner = useIsSafeOwner() + const { safeTx } = useContext(SafeTxContext) + const isDelegateCall = safeTx ? checkIsDelegateCall(safeTx) : false + + return ( + isOwner && + isCreation && + !isBatch && + !isCounterfactualSafe && + !willExecuteThroughRole && + !isProposing && + !isDelegateCall && + isBatchable + ) +} + +const BatchingSlot = withSlot({ + Component: Batching, + label: 'Add to batch', + slotName: SlotName.ComboSubmit, + id: 'batching', + useSlotCondition: useShouldRegisterSlot, +}) + +export default BatchingSlot diff --git a/apps/web/src/components/tx-flow/actions/ComboSubmit.tsx b/apps/web/src/components/tx-flow/actions/ComboSubmit.tsx new file mode 100644 index 0000000000..abc516037c --- /dev/null +++ b/apps/web/src/components/tx-flow/actions/ComboSubmit.tsx @@ -0,0 +1,80 @@ +import { useContext, useMemo } from 'react' +import { Slot, type SlotComponentProps, SlotName, useSlot, useSlotIds, withSlot } from '../slots' +import { Box } from '@mui/material' +import WalletRejectionError from '@/components/tx/SignOrExecuteForm/WalletRejectionError' +import ErrorMessage from '@/components/tx/ErrorMessage' +import { TxFlowContext } from '../TxFlowProvider' +import { useValidateTxData } from '@/hooks/useValidateTxData' +import useLocalStorage from '@/services/local-storage/useLocalStorage' + +const COMBO_SUBMIT_ACTION = 'comboSubmitAction' + +export const ComboSubmit = (props: SlotComponentProps<SlotName.Submit>) => { + const { txId, submitError, isRejectedByUser } = useContext(TxFlowContext) + const slotItems = useSlot(SlotName.ComboSubmit) + const slotIds = useSlotIds(SlotName.ComboSubmit) + + const [validationResult, , validationLoading] = useValidateTxData(txId) + const validationError = useMemo( + () => (validationResult !== undefined ? new Error(validationResult) : undefined), + [validationResult], + ) + + const initialSubmitAction = slotIds?.[0] + const options = useMemo(() => slotItems.map(({ label, id }) => ({ label, id })), [slotItems]) + const [submitAction = initialSubmitAction, setSubmitAction] = useLocalStorage<string>(COMBO_SUBMIT_ACTION) + + const slotId = useMemo( + () => (slotIds.includes(submitAction) ? submitAction : initialSubmitAction), + [slotIds, submitAction, initialSubmitAction], + ) + + if (slotIds.length === 0) { + return false + } + + const disabled = validationError !== undefined || validationLoading + + return ( + <> + {submitError && ( + <Box mt={1}> + <ErrorMessage error={submitError}>Error submitting the transaction. Please try again.</ErrorMessage> + </Box> + )} + + {isRejectedByUser && ( + <Box mt={1}> + <WalletRejectionError /> + </Box> + )} + + {validationError !== undefined && ( + <ErrorMessage error={validationError}>Error validating transaction data</ErrorMessage> + )} + + <Slot + name={SlotName.ComboSubmit} + id={slotId} + options={options} + onChange={setSubmitAction} + disabled={disabled} + {...props} + /> + </> + ) +} + +const useShouldRegisterSlot = () => { + const slotIds = useSlotIds(SlotName.ComboSubmit) + return slotIds.length > 0 +} + +const ComboSubmitSlot = withSlot({ + Component: ComboSubmit, + slotName: SlotName.Submit, + id: 'combo-submit', + useSlotCondition: useShouldRegisterSlot, +}) + +export default ComboSubmitSlot diff --git a/apps/web/src/components/tx-flow/actions/Counterfactual.tsx b/apps/web/src/components/tx-flow/actions/Counterfactual.tsx new file mode 100644 index 0000000000..9fe3b3b97f --- /dev/null +++ b/apps/web/src/components/tx-flow/actions/Counterfactual.tsx @@ -0,0 +1,46 @@ +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { useCallback, useContext } from 'react' +import { TxFlowContext } from '../TxFlowProvider' +import CounterfactualForm from '@/features/counterfactual/CounterfactualForm' +import useIsCounterfactualSafe from '@/features/counterfactual/hooks/useIsCounterfactualSafe' +import { type SlotComponentProps, SlotName, withSlot } from '../slots' + +const Counterfactual = ({ onSubmitSuccess }: SlotComponentProps<SlotName.Submit>) => { + const { safeTx, txOrigin } = useContext(SafeTxContext) + const { isCreation, trackTxEvent, isSubmitDisabled } = useContext(TxFlowContext) + + const handleSubmit = useCallback( + async (txId: string, isExecuted = false) => { + onSubmitSuccess?.({ txId, isExecuted }) + trackTxEvent(txId, isExecuted) + }, + [onSubmitSuccess, trackTxEvent], + ) + + return ( + <CounterfactualForm + origin={txOrigin} + disableSubmit={isSubmitDisabled} + isCreation={isCreation} + safeTx={safeTx} + onSubmit={handleSubmit} + onlyExecute + /> + ) +} + +const useShouldRegisterSlot = () => { + const isCounterfactualSafe = useIsCounterfactualSafe() + const { isProposing } = useContext(TxFlowContext) + + return isCounterfactualSafe && !isProposing +} + +const CounterfactualSlot = withSlot({ + Component: Counterfactual, + slotName: SlotName.Submit, + id: 'counterfactual', + useSlotCondition: useShouldRegisterSlot, +}) + +export default CounterfactualSlot diff --git a/apps/web/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx b/apps/web/src/components/tx-flow/actions/Execute/ExecuteForm.tsx similarity index 70% rename from apps/web/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx rename to apps/web/src/components/tx-flow/actions/Execute/ExecuteForm.tsx index 94dc29e3e3..cca3c6631c 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx +++ b/apps/web/src/components/tx-flow/actions/Execute/ExecuteForm.tsx @@ -1,7 +1,7 @@ import useWalletCanPay from '@/hooks/useWalletCanPay' import madProps from '@/utils/mad-props' -import { type ReactElement, type SyntheticEvent, useContext, useMemo, useState } from 'react' -import { CircularProgress, Box, Button, CardActions, Divider } from '@mui/material' +import { type ReactElement, type SyntheticEvent, useContext, useState } from 'react' +import { Box, CardActions, Divider, Tooltip } from '@mui/material' import classNames from 'classnames' import ErrorMessage from '@/components/tx/ErrorMessage' @@ -10,63 +10,68 @@ import { useCurrentChain } from '@/hooks/useChains' import { getTxOptions } from '@/utils/transactions' import useIsValidExecution from '@/hooks/useIsValidExecution' import CheckWallet from '@/components/common/CheckWallet' -import { useIsExecutionLoop, useTxActions } from './hooks' +import { useIsExecutionLoop, useTxActions } from '@/components/tx/SignOrExecuteForm/hooks' import { useRelaysBySafe } from '@/hooks/useRemainingRelays' import useWalletCanRelay from '@/hooks/useWalletCanRelay' -import { ExecutionMethod, ExecutionMethodSelector } from '../ExecutionMethodSelector' +import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector' import { hasRemainingRelays } from '@/utils/relaying' -import type { SignOrExecuteProps } from './SignOrExecuteForm' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction } from '@safe-global/types-kit' import { TxModalContext } from '@/components/tx-flow' import { SuccessScreenFlow } from '@/components/tx-flow/flows' import useGasLimit from '@/hooks/useGasLimit' -import AdvancedParams, { useAdvancedParams } from '../AdvancedParams' -import { asError } from '@/services/exceptions/utils' +import AdvancedParams, { useAdvancedParams } from '@/components/tx/AdvancedParams' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { isWalletRejection } from '@/utils/wallets' import css from './styles.module.css' import commonCss from '@/components/tx-flow/common/styles.module.css' -import { TxSecurityContext } from '../security/shared/TxSecurityContext' +import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import NonOwnerError from '@/components/tx/SignOrExecuteForm/NonOwnerError' -import WalletRejectionError from '@/components/tx/SignOrExecuteForm/WalletRejectionError' -import { useValidateTxData } from '@/hooks/useValidateTxData' +import SplitMenuButton from '@/components/common/SplitMenuButton' +import type { SlotComponentProps, SlotName } from '../../slots' +import { TxFlowContext } from '../../TxFlowProvider' export const ExecuteForm = ({ safeTx, txId, onSubmit, + onSubmitSuccess, + options = [], + onChange, disableSubmit = false, origin, onlyExecute, isCreation, isOwner, isExecutionLoop, + slotId, txActions, + tooltip, txSecurity, -}: SignOrExecuteProps & { +}: SlotComponentProps<SlotName.ComboSubmit> & { + txId?: string + disableSubmit?: boolean + onlyExecute?: boolean + origin?: string isOwner: ReturnType<typeof useIsSafeOwner> isExecutionLoop: ReturnType<typeof useIsExecutionLoop> txActions: ReturnType<typeof useTxActions> txSecurity: ReturnType<typeof useTxSecurityContext> isCreation?: boolean safeTx?: SafeTransaction + tooltip?: string }): ReactElement => { // Form state - const [isSubmittable, setIsSubmittable] = useState<boolean>(true) - const [submitError, setSubmitError] = useState<Error | undefined>() - const [isRejectedByUser, setIsRejectedByUser] = useState<Boolean>(false) - - const [validationResult, , validationLoading] = useValidateTxData(txId) - const validationError = useMemo( - () => (validationResult !== undefined ? new Error(validationResult) : undefined), - [validationResult], - ) + const [isSubmitLoadingLocal, setIsSubmitLoadingLocal] = useState<boolean>(false) // TODO: remove this local state and use only the one from TxFlowContext when tx-flow refactor is done + // Hooks const currentChain = useCurrentChain() const { executeTx } = txActions const { setTxFlow } = useContext(TxModalContext) const { needsRiskConfirmation, isRiskConfirmed, setIsRiskIgnored } = txSecurity + const { isSubmitDisabled, isSubmitLoading, setIsSubmitLoading, setSubmitError, setIsRejectedByUser } = + useContext(TxFlowContext) // We default to relay, but the option is only shown if we canRelay const [executionMethod, setExecutionMethod] = useState(ExecutionMethod.RELAY) @@ -97,12 +102,15 @@ export const ExecuteForm = ({ return } - setIsSubmittable(false) + setIsSubmitLoading(true) + setIsSubmitLoadingLocal(true) setSubmitError(undefined) setIsRejectedByUser(false) const txOptions = getTxOptions(advancedParams, currentChain) + onSubmit?.() + let executedTxId: string try { executedTxId = await executeTx(txOptions, safeTx, txId, origin, willRelay) @@ -114,12 +122,14 @@ export const ExecuteForm = ({ trackError(Errors._804, err) setSubmitError(err) } - setIsSubmittable(true) + + setIsSubmitLoading(false) + setIsSubmitLoadingLocal(false) return } // On success - onSubmit?.(executedTxId, true) + onSubmitSuccess?.({ txId: executedTxId, isExecuted: true }) setTxFlow(<SuccessScreenFlow txId={executedTxId} />, undefined, false) } @@ -131,18 +141,17 @@ export const ExecuteForm = ({ const cannotPropose = !isOwner && !onlyExecute const submitDisabled = !safeTx || - !isSubmittable || + isSubmitDisabled || + isSubmitLoadingLocal || disableSubmit || isExecutionLoop || cannotPropose || - (needsRiskConfirmation && !isRiskConfirmed) || - validationError !== undefined || - validationLoading + (needsRiskConfirmation && !isRiskConfirmed) return ( <> <form onSubmit={handleSubmit}> - <div className={classNames(css.params, { [css.noBottomBorderRadius]: canRelay })}> + <div className={classNames(commonCss.params, { [css.noBottomBorderRadius]: canRelay })}> <AdvancedParams willExecute params={advancedParams} @@ -183,37 +192,24 @@ export const ExecuteForm = ({ ) )} - {submitError && ( - <Box mt={1}> - <ErrorMessage error={submitError}>Error submitting the transaction. Please try again.</ErrorMessage> - </Box> - )} - - {isRejectedByUser && ( - <Box mt={1}> - <WalletRejectionError /> - </Box> - )} - - {validationError !== undefined && ( - <ErrorMessage error={validationError}>Error validating transaction data</ErrorMessage> - )} - <Divider className={commonCss.nestedDivider} sx={{ pt: 3 }} /> <CardActions> {/* Submit button */} <CheckWallet allowNonOwner={onlyExecute} checkNetwork={!submitDisabled}> {(isOk) => ( - <Button - data-testid="execute-form-btn" - variant="contained" - type="submit" - disabled={!isOk || submitDisabled} - sx={{ minWidth: '112px', width: ['100%', '100%', '100%', 'auto'] }} - > - {!isSubmittable ? <CircularProgress size={20} /> : 'Execute'} - </Button> + <Tooltip title={tooltip} placement="top"> + <Box sx={{ minWidth: '112px', width: ['100%', '100%', '100%', 'auto'] }}> + <SplitMenuButton + selected={slotId} + onChange={({ id }) => onChange?.(id)} + options={options} + disabled={!isOk || submitDisabled} + loading={isSubmitLoading || isSubmitLoadingLocal} + tooltip={tooltip} + /> + </Box> + </Tooltip> )} </CheckWallet> </CardActions> diff --git a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx b/apps/web/src/components/tx-flow/actions/Execute/__tests__/ExecuteForm.test.tsx similarity index 88% rename from apps/web/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx rename to apps/web/src/components/tx-flow/actions/Execute/__tests__/ExecuteForm.test.tsx index 7880d256fa..b7c62a9569 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/ExecuteForm.test.tsx +++ b/apps/web/src/components/tx-flow/actions/Execute/__tests__/ExecuteForm.test.tsx @@ -1,9 +1,9 @@ -import { defaultSecurityContextValues } from '@/components/tx/security/shared/TxSecurityContext' -import { type AsyncResult } from '@/hooks/useAsync' +import { defaultSecurityContextValues } from '@safe-global/utils/components/tx/security/shared/utils' +import { type AsyncResult } from '@safe-global/utils/hooks/useAsync' import { createMockSafeTransaction } from '@/tests/transactions' -import { OperationType } from '@safe-global/safe-core-sdk-types' +import { OperationType } from '@safe-global/types-kit' import { type ReactElement } from 'react' -import { ExecuteForm } from '@/components/tx/SignOrExecuteForm/ExecuteForm' +import { ExecuteForm } from '../ExecuteForm' import * as useGasLimit from '@/hooks/useGasLimit' import * as useIsValidExecution from '@/hooks/useIsValidExecution' import * as useWalletCanRelay from '@/hooks/useWalletCanRelay' @@ -43,6 +43,12 @@ describe('ExecuteForm', () => { signProposerTx: jest.fn(), }, txSecurity: defaultSecurityContextValues, + options: [ + { id: 'execute', label: 'Execute' }, + { id: 'sign', label: 'Sign' }, + ], + onChange: jest.fn(), + slotId: 'execute', } beforeEach(() => { @@ -144,35 +150,6 @@ describe('ExecuteForm', () => { ).toBeInTheDocument() }) - it('shows a submit error', async () => { - const mockExecuteTx = jest.fn(() => { - throw new Error('Error submitting the tx') - }) - - const { getByText } = render( - <ExecuteForm - {...defaultProps} - safeTx={safeTransaction} - onSubmit={jest.fn()} - txActions={{ - proposeTx: jest.fn(), - signTx: jest.fn(), - addToBatch: jest.fn(), - executeTx: mockExecuteTx, - signProposerTx: jest.fn(), - }} - />, - ) - - const button = getByText('Execute') - - fireEvent.click(button) - - await waitFor(() => { - expect(getByText('Error submitting the transaction. Please try again.')).toBeInTheDocument() - }) - }) - it('execute the tx when the submit button is clicked', async () => { const mockExecuteTx = jest.fn() diff --git a/apps/web/src/components/tx-flow/actions/Execute/index.tsx b/apps/web/src/components/tx-flow/actions/Execute/index.tsx new file mode 100644 index 0000000000..4c636b71f6 --- /dev/null +++ b/apps/web/src/components/tx-flow/actions/Execute/index.tsx @@ -0,0 +1,72 @@ +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { useCallback, useContext, useEffect } from 'react' +import { TxFlowContext } from '../../TxFlowProvider' +import ExecuteForm from './ExecuteForm' +import useIsCounterfactualSafe from '@/features/counterfactual/hooks/useIsCounterfactualSafe' +import { type SlotComponentProps, SlotName, withSlot } from '../../slots' +import type { SubmitCallback } from '../../TxFlow' + +const Execute = ({ + onSubmit, + onSubmitSuccess, + disabled = false, + onChange, + ...props +}: SlotComponentProps<SlotName.ComboSubmit>) => { + const { safeTx, txOrigin } = useContext(SafeTxContext) + const { txId, isCreation, onlyExecute, isSubmitDisabled, trackTxEvent, setShouldExecute } = useContext(TxFlowContext) + + useEffect(() => { + setShouldExecute(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleSubmit = useCallback<SubmitCallback>( + async ({ txId, isExecuted = false } = {}) => { + onSubmitSuccess?.({ txId, isExecuted }) + trackTxEvent(txId!, isExecuted) + }, + [onSubmitSuccess, trackTxEvent], + ) + + const onChangeSubmitOption = useCallback( + async (option: string) => { + // When changing to another submit option, we update the context to not execute the transaction + setShouldExecute(false) + onChange(option) + }, + [setShouldExecute, onChange], + ) + + return ( + <ExecuteForm + safeTx={safeTx} + txId={txId} + onSubmit={onSubmit} + onSubmitSuccess={handleSubmit} + disableSubmit={isSubmitDisabled || disabled} + origin={txOrigin} + onlyExecute={onlyExecute} + isCreation={isCreation} + onChange={onChangeSubmitOption} + {...props} + /> + ) +} + +const useShouldRegisterSlot = () => { + const isCounterfactualSafe = useIsCounterfactualSafe() + const { canExecute, isProposing } = useContext(TxFlowContext) + + return !isCounterfactualSafe && canExecute && !isProposing +} + +const ExecuteSlot = withSlot({ + Component: Execute, + slotName: SlotName.ComboSubmit, + label: 'Execute', + id: 'execute', + useSlotCondition: useShouldRegisterSlot, +}) + +export default ExecuteSlot diff --git a/apps/web/src/components/tx-flow/actions/Execute/styles.module.css b/apps/web/src/components/tx-flow/actions/Execute/styles.module.css new file mode 100644 index 0000000000..ff7d7c108e --- /dev/null +++ b/apps/web/src/components/tx-flow/actions/Execute/styles.module.css @@ -0,0 +1,10 @@ +.noBottomBorderRadius :global(.MuiPaper-root) { + border-bottom-left-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.noTopBorder > div { + margin-top: -1px; + border-top-left-radius: 0 !important; + border-top-right-radius: 0 !important; +} diff --git a/apps/web/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx b/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx similarity index 97% rename from apps/web/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx rename to apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx index 3563336048..ce53eb8a32 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx +++ b/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/__test__/ExecuteThroughRoleForm.test.tsx @@ -1,5 +1,5 @@ import { createMockSafeTransaction } from '@/tests/transactions' -import { OperationType } from '@safe-global/safe-core-sdk-types' +import { OperationType } from '@safe-global/types-kit' import { type ReactElement } from 'react' import * as zodiacRoles from 'zodiac-roles-deployments' import { fireEvent, render, waitFor, mockWeb3Provider } from '@/tests/test-utils' @@ -12,11 +12,11 @@ import * as txSender from '@/services/tx/tx-sender/dispatch' import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import { type OnboardAPI } from '@web3-onboard/core' import { AbiCoder, ZeroAddress, encodeBytes32String } from 'ethers' -import ExecuteThroughRoleForm from '..' +import ExecuteThroughRoleForm from '../index' import * as hooksModule from '../hooks' -import { FEATURES } from '@/utils/chains' import { chainBuilder } from '@/tests/builders/chains' import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' // Mock fetch Object.defineProperty(window, 'fetch', { diff --git a/apps/web/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/hooks.test.ts b/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/__test__/hooks.test.ts similarity index 98% rename from apps/web/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/hooks.test.ts rename to apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/__test__/hooks.test.ts index 3e01cdb74a..f24c9d4edf 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/__test__/hooks.test.ts +++ b/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/__test__/hooks.test.ts @@ -1,5 +1,5 @@ import { createMockSafeTransaction } from '@/tests/transactions' -import { OperationType } from '@safe-global/safe-core-sdk-types' +import { OperationType } from '@safe-global/types-kit' import * as zodiacRoles from 'zodiac-roles-deployments' import { waitFor, renderHook, mockWeb3Provider } from '@/tests/test-utils' @@ -10,10 +10,10 @@ import * as onboardHooks from '@/hooks/wallets/useOnboard' import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import { type OnboardAPI } from '@web3-onboard/core' import { AbiCoder, ZeroAddress, encodeBytes32String } from 'ethers' -import { FEATURES } from '@/utils/chains' import { chainBuilder } from '@/tests/builders/chains' import { useHasFeature } from '@/hooks/useChains' import { useRoles } from '../hooks' +import { FEATURES } from '@safe-global/utils/utils/chains' const mockChain = chainBuilder() // @ts-expect-error - we are using a local FEATURES enum diff --git a/apps/web/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks.ts b/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/hooks.ts similarity index 97% rename from apps/web/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks.ts rename to apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/hooks.ts index 1fa5166634..feea99e9f2 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks.ts +++ b/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/hooks.ts @@ -1,4 +1,4 @@ -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useSafeInfo from '@/hooks/useSafeInfo' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { Errors, logError } from '@/services/exceptions' @@ -14,20 +14,15 @@ import { ExecutionOptions, Status, } from 'zodiac-roles-deployments' -import { - OperationType, - type Transaction, - type MetaTransactionData, - type SafeTransaction, -} from '@safe-global/safe-core-sdk-types' +import { OperationType, type Transaction, type MetaTransactionData, type SafeTransaction } from '@safe-global/types-kit' import { type JsonRpcProvider } from 'ethers' import { KnownContracts, getModuleInstance } from '@gnosis.pm/zodiac' import useWallet from '@/hooks/wallets/useWallet' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' import { encodeMultiSendData } from '@safe-global/protocol-kit' -import { Multi_send__factory } from '@/types/contracts' +import { Multi_send__factory } from '@safe-global/utils/types/contracts' import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' +import { FEATURES } from '@safe-global/utils/utils/chains' const ROLES_V2_SUPPORTED_CHAINS = Object.keys(chains) const multiSendInterface = Multi_send__factory.createInterface() diff --git a/apps/web/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/index.tsx b/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/index.tsx similarity index 95% rename from apps/web/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/index.tsx rename to apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/index.tsx index b8fcc7a600..091cb12945 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/index.tsx +++ b/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/index.tsx @@ -9,17 +9,17 @@ import { useCurrentChain } from '@/hooks/useChains' import { getTxOptions } from '@/utils/transactions' import CheckWallet from '@/components/common/CheckWallet' -import type { SignOrExecuteProps } from '../SignOrExecuteForm' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm/SignOrExecuteFormV2' +import type { SafeTransaction } from '@safe-global/types-kit' import { TxModalContext } from '@/components/tx-flow' import { SuccessScreenFlow } from '@/components/tx-flow/flows' -import AdvancedParams, { useAdvancedParams } from '../../AdvancedParams' -import { asError } from '@/services/exceptions/utils' +import AdvancedParams, { useAdvancedParams } from '../../../../tx/AdvancedParams' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { isWalletRejection } from '@/utils/wallets' -import css from '../styles.module.css' +import css from './styles.module.css' import commonCss from '@/components/tx-flow/common/styles.module.css' -import { TxSecurityContext } from '../../security/shared/TxSecurityContext' +import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext' import WalletRejectionError from '@/components/tx/SignOrExecuteForm/WalletRejectionError' import { pollModuleTransactionId, useExecuteThroughRole, useGasLimit, useMetaTransactions, type Role } from './hooks' @@ -152,7 +152,7 @@ export const ExecuteThroughRoleForm = ({ other owners. </Typography> - <div className={css.params}> + <div className={commonCss.params}> <AdvancedParams willExecute params={advancedParams} diff --git a/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/styles.module.css b/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/styles.module.css new file mode 100644 index 0000000000..acdc5347be --- /dev/null +++ b/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/styles.module.css @@ -0,0 +1,10 @@ +.roleChip { + background-color: var(--color-background-main); + font-size: 12px; + border-radius: 4px; + padding: 2px 8px; +} + +.zodiac { + color: var(--color-text-primary); +} diff --git a/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/index.tsx b/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/index.tsx new file mode 100644 index 0000000000..f46dd7b2d0 --- /dev/null +++ b/apps/web/src/components/tx-flow/actions/ExecuteThroughRole/index.tsx @@ -0,0 +1,39 @@ +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { useCallback, useContext } from 'react' +import { TxFlowContext } from '../../TxFlowProvider' +import ExecuteThroughRoleForm from './ExecuteThroughRoleForm' +import useIsCounterfactualSafe from '@/features/counterfactual/hooks/useIsCounterfactualSafe' +import { type SlotComponentProps, SlotName, withSlot } from '../../slots' + +const ExecuteThroughRole = ({ onSubmitSuccess }: SlotComponentProps<SlotName.Submit>) => { + const { safeTx } = useContext(SafeTxContext) + const { trackTxEvent, role, isSubmitDisabled } = useContext(TxFlowContext) + + const handleSubmit = useCallback( + async (txId: string, isExecuted = false) => { + onSubmitSuccess?.({ txId, isExecuted }) + trackTxEvent(txId, isExecuted, true) + }, + [onSubmitSuccess, trackTxEvent], + ) + + return ( + <ExecuteThroughRoleForm safeTx={safeTx} disableSubmit={isSubmitDisabled} role={role!} onSubmit={handleSubmit} /> + ) +} + +const useShouldRegisterSlot = () => { + const isCounterfactualSafe = useIsCounterfactualSafe() + const { willExecuteThroughRole } = useContext(TxFlowContext) + + return !isCounterfactualSafe && willExecuteThroughRole +} + +const ExecuteThroughRoleSlot = withSlot({ + Component: ExecuteThroughRole, + slotName: SlotName.Submit, + id: 'executeThroughRole', + useSlotCondition: useShouldRegisterSlot, +}) + +export default ExecuteThroughRoleSlot diff --git a/apps/web/src/components/tx/SignOrExecuteForm/ProposerForm.tsx b/apps/web/src/components/tx-flow/actions/Propose/ProposerForm.tsx similarity index 72% rename from apps/web/src/components/tx/SignOrExecuteForm/ProposerForm.tsx rename to apps/web/src/components/tx-flow/actions/Propose/ProposerForm.tsx index b92f3938fd..2422adee2a 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/ProposerForm.tsx +++ b/apps/web/src/components/tx-flow/actions/Propose/ProposerForm.tsx @@ -1,19 +1,19 @@ import WalletRejectionError from '@/components/tx/SignOrExecuteForm/WalletRejectionError' import { isWalletRejection } from '@/utils/wallets' import { type ReactElement, type SyntheticEvent, useContext, useState } from 'react' -import { Box, Button, CardActions, CircularProgress, Divider, Typography } from '@mui/material' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material' +import type { SafeTransaction } from '@safe-global/types-kit' import CheckWallet from '@/components/common/CheckWallet' import { TxModalContext } from '@/components/tx-flow' import commonCss from '@/components/tx-flow/common/styles.module.css' import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext' import { useTxActions } from '@/components/tx/SignOrExecuteForm/hooks' -import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' +import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm/SignOrExecuteFormV2' import useWallet from '@/hooks/wallets/useWallet' import { Errors, trackError } from '@/services/exceptions' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import madProps from '@/utils/mad-props' -import Stack from '@mui/system/Stack' +import { TxCardActions } from '@/components/tx-flow/common/TxCard' export const ProposerForm = ({ safeTx, @@ -85,30 +85,22 @@ export const ProposerForm = ({ <Divider className={commonCss.nestedDivider} sx={{ pt: 3 }} /> - <CardActions> - <Stack - sx={{ - width: ['100%', '100%', '100%', 'auto'], - }} - direction={{ xs: 'column-reverse', lg: 'row' }} - spacing={{ xs: 2, md: 2 }} - > - {/* Submit button */} - <CheckWallet checkNetwork> - {(isOk) => ( - <Button - data-testid="sign-btn" - variant="contained" - type="submit" - disabled={!isOk || submitDisabled} - sx={{ minWidth: '82px', order: '1', width: ['100%', '100%', '100%', 'auto'] }} - > - {!isSubmittable ? <CircularProgress size={20} /> : 'Propose transaction'} - </Button> - )} - </CheckWallet> - </Stack> - </CardActions> + <TxCardActions> + {/* Submit button */} + <CheckWallet checkNetwork> + {(isOk) => ( + <Button + data-testid="sign-btn" + variant="contained" + type="submit" + disabled={!isOk || submitDisabled} + sx={{ minWidth: '82px', order: '1', width: ['100%', '100%', '100%', 'auto'] }} + > + {!isSubmittable ? <CircularProgress size={20} /> : 'Propose transaction'} + </Button> + )} + </CheckWallet> + </TxCardActions> </form> ) } diff --git a/apps/web/src/components/tx-flow/actions/Propose/index.tsx b/apps/web/src/components/tx-flow/actions/Propose/index.tsx new file mode 100644 index 0000000000..785aab6f11 --- /dev/null +++ b/apps/web/src/components/tx-flow/actions/Propose/index.tsx @@ -0,0 +1,34 @@ +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { useCallback, useContext } from 'react' +import { TxFlowContext } from '../../TxFlowProvider' +import ProposerForm from './ProposerForm' +import { type SlotComponentProps, SlotName, withSlot } from '../../slots' + +const Propose = ({ onSubmitSuccess }: SlotComponentProps<SlotName.Submit>) => { + const { safeTx, txOrigin } = useContext(SafeTxContext) + const { trackTxEvent, isSubmitDisabled } = useContext(TxFlowContext) + + const handleSubmit = useCallback( + async (txId: string, isExecuted = false) => { + onSubmitSuccess?.({ txId, isExecuted }) + trackTxEvent(txId, isExecuted, false, true) + }, + [onSubmitSuccess, trackTxEvent], + ) + + return <ProposerForm safeTx={safeTx} origin={txOrigin} disableSubmit={isSubmitDisabled} onSubmit={handleSubmit} /> +} + +const useShouldRegisterSlot = () => { + const { isProposing } = useContext(TxFlowContext) + return isProposing +} + +const ProposeSlot = withSlot({ + Component: Propose, + slotName: SlotName.Submit, + id: 'propose', + useSlotCondition: useShouldRegisterSlot, +}) + +export default ProposeSlot diff --git a/apps/web/src/components/tx-flow/actions/Sign/SignForm.tsx b/apps/web/src/components/tx-flow/actions/Sign/SignForm.tsx new file mode 100644 index 0000000000..add536ebad --- /dev/null +++ b/apps/web/src/components/tx-flow/actions/Sign/SignForm.tsx @@ -0,0 +1,154 @@ +import madProps from '@/utils/mad-props' +import { type ReactElement, type SyntheticEvent, useContext, useState } from 'react' +import { Box, Divider, Stack } from '@mui/material' +import ErrorMessage from '@/components/tx/ErrorMessage' +import { trackError, Errors } from '@/services/exceptions' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import CheckWallet from '@/components/common/CheckWallet' +import { useAlreadySigned, useTxActions } from '@/components/tx/SignOrExecuteForm/hooks' +import type { SafeTransaction } from '@safe-global/types-kit' +import { TxModalContext } from '@/components/tx-flow' +import commonCss from '@/components/tx-flow/common/styles.module.css' +import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext' +import NonOwnerError from '@/components/tx/SignOrExecuteForm/NonOwnerError' +import { asError } from '@safe-global/utils/services/exceptions/utils' +import { isWalletRejection } from '@/utils/wallets' +import { useSigner } from '@/hooks/wallets/useWallet' +import { NestedTxSuccessScreenFlow } from '@/components/tx-flow/flows' +import { TxFlowContext } from '@/components/tx-flow/TxFlowProvider' +import { TxCardActions } from '@/components/tx-flow/common/TxCard' +import SplitMenuButton from '@/components/common/SplitMenuButton' +import type { SlotComponentProps, SlotName } from '../../slots' + +export const SignForm = ({ + safeTx, + txId, + onSubmit, + onSubmitSuccess, + onChange, + options = [], + disableSubmit = false, + origin, + isOwner, + slotId, + txActions, + txSecurity, + tooltip, +}: SlotComponentProps<SlotName.ComboSubmit> & { + txId?: string + disableSubmit?: boolean + origin?: string + isOwner: ReturnType<typeof useIsSafeOwner> + txActions: ReturnType<typeof useTxActions> + txSecurity: ReturnType<typeof useTxSecurityContext> + safeTx?: SafeTransaction + tooltip?: string +}): ReactElement => { + // Form state + const [isSubmitLoadingLocal, setIsSubmitLoadingLocal] = useState<boolean>(false) // TODO: remove this local state and use only the one from TxFlowContext when tx-flow refactor is done + + // Hooks + const { signTx } = txActions + const { setTxFlow } = useContext(TxModalContext) + const { isSubmitDisabled, isSubmitLoading, setIsSubmitLoading, setSubmitError, setIsRejectedByUser } = + useContext(TxFlowContext) + const { needsRiskConfirmation, isRiskConfirmed, setIsRiskIgnored } = txSecurity + const hasSigned = useAlreadySigned(safeTx) + const signer = useSigner() + + const handleOptionChange = (option: string) => { + onChange?.(option) + } + + // On modal submit + const handleSubmit = async (e: SyntheticEvent) => { + e.preventDefault() + + if (needsRiskConfirmation && !isRiskConfirmed) { + setIsRiskIgnored(true) + return + } + + if (!safeTx) return + + setIsSubmitLoading(true) + setIsSubmitLoadingLocal(true) + + setSubmitError(undefined) + setIsRejectedByUser(false) + + onSubmit?.() + + let resultTxId: string + try { + resultTxId = await signTx(safeTx, txId, origin) + } catch (_err) { + const err = asError(_err) + if (isWalletRejection(err)) { + setIsRejectedByUser(true) + } else { + trackError(Errors._804, err) + setSubmitError(err) + } + setIsSubmitLoading(false) + setIsSubmitLoadingLocal(false) + return + } + + // On successful sign + onSubmitSuccess?.({ txId: resultTxId }) + + if (signer?.isSafe) { + setTxFlow(<NestedTxSuccessScreenFlow txId={resultTxId} />, undefined, false) + } else { + setTxFlow(undefined) + } + } + + const cannotPropose = !isOwner + const submitDisabled = + !safeTx || + isSubmitDisabled || + isSubmitLoadingLocal || + disableSubmit || + cannotPropose || + (needsRiskConfirmation && !isRiskConfirmed) + + return ( + <Stack gap={3}> + {hasSigned && <ErrorMessage level="warning">You have already signed this transaction.</ErrorMessage>} + + {cannotPropose && <NonOwnerError />} + + <Box> + <Divider className={commonCss.nestedDivider} /> + + {/* Submit button */} + <TxCardActions> + <form onSubmit={handleSubmit}> + <CheckWallet checkNetwork={!submitDisabled}> + {(isOk) => ( + <SplitMenuButton + selected={slotId} + onChange={({ id }) => handleOptionChange(id)} + options={options} + disabled={!isOk || submitDisabled} + loading={isSubmitLoading || isSubmitLoadingLocal} + tooltip={isOk ? tooltip : undefined} + /> + )} + </CheckWallet> + </form> + </TxCardActions> + </Box> + </Stack> + ) +} + +const useTxSecurityContext = () => useContext(TxSecurityContext) + +export default madProps(SignForm, { + isOwner: useIsSafeOwner, + txActions: useTxActions, + txSecurity: useTxSecurityContext, +}) diff --git a/apps/web/src/components/tx-flow/actions/Sign/__tests__/SignForm.test.tsx b/apps/web/src/components/tx-flow/actions/Sign/__tests__/SignForm.test.tsx new file mode 100644 index 0000000000..f6ed420dbd --- /dev/null +++ b/apps/web/src/components/tx-flow/actions/Sign/__tests__/SignForm.test.tsx @@ -0,0 +1,179 @@ +import { defaultSecurityContextValues } from '@safe-global/utils/components/tx/security/shared/utils' +import { type ReactElement } from 'react' +import * as hooks from '@/components/tx/SignOrExecuteForm/hooks' +import * as useValidateTxData from '@/hooks/useValidateTxData' +import { SignForm } from '../SignForm' +import { render as renderTestUtils } from '@/tests/test-utils' +import { createMockSafeTransaction } from '@/tests/transactions' +import { OperationType } from '@safe-global/types-kit' +import { fireEvent, waitFor } from '@testing-library/react' +import { initialContext, TxFlowContext, type TxFlowContextType } from '@/components/tx-flow/TxFlowProvider' + +// We assume that CheckWallet always returns true +jest.mock('@/components/common/CheckWallet', () => ({ + __esModule: true, + default({ children }: { children: (ok: boolean) => ReactElement }) { + return children(true) + }, +})) + +const render = (ui: ReactElement, txFlowContext: Partial<TxFlowContextType> = {}) => { + return renderTestUtils( + <TxFlowContext.Provider value={{ ...initialContext, ...txFlowContext }}>{ui}</TxFlowContext.Provider>, + ) +} + +describe('SignForm', () => { + const safeTransaction = createMockSafeTransaction({ + to: '0x1', + data: '0x', + operation: OperationType.Call, + }) + + const defaultProps = { + onSubmit: jest.fn(), + txId: '0x01231', + isOwner: true, + txActions: { + proposeTx: jest.fn(), + signTx: jest.fn(), + addToBatch: jest.fn(), + executeTx: jest.fn(), + signProposerTx: jest.fn(), + }, + txSecurity: defaultSecurityContextValues, + options: [ + { id: 'sign', label: 'Sign' }, + { id: 'execute', label: 'Execute' }, + ], + onChange: jest.fn(), + slotId: 'sign', + } + + beforeEach(() => { + jest.clearAllMocks() + jest.spyOn(useValidateTxData, 'useValidateTxData').mockReturnValue([undefined, undefined, false]) + }) + + it('displays a warning if connected wallet already signed the tx', () => { + jest.spyOn(hooks, 'useAlreadySigned').mockReturnValue(true) + + const { getByText } = render(<SignForm {...defaultProps} />) + + expect(getByText('You have already signed this transaction.')).toBeInTheDocument() + }) + + it('does not display a warning if connected wallet has not signed the tx yet', () => { + jest.spyOn(hooks, 'useAlreadySigned').mockReturnValue(false) + + const { queryByText } = render(<SignForm {...defaultProps} />) + + expect(queryByText('You have already signed this transaction.')).not.toBeInTheDocument() + }) + + it('shows a non-owner error', () => { + jest.spyOn(hooks, 'useAlreadySigned').mockReturnValue(false) + + const { queryByText } = render(<SignForm {...defaultProps} isOwner={false} />) + + expect( + queryByText( + 'You are currently not a signer of this Safe Account and won't be able to submit this transaction.', + ), + ).not.toBeInTheDocument() + }) + + it('signs a transaction', async () => { + const mockSignTx = jest.fn() + + const { getByText } = render( + <SignForm + {...defaultProps} + safeTx={safeTransaction} + txActions={{ + proposeTx: jest.fn(), + signTx: mockSignTx, + addToBatch: jest.fn(), + executeTx: jest.fn(), + signProposerTx: jest.fn(), + }} + />, + ) + + const button = getByText('Sign') + + fireEvent.click(button) + + await waitFor(() => { + expect(mockSignTx).toHaveBeenCalled() + }) + }) + + describe('shows a disabled submit button if', () => { + it('there is no safeTx', () => { + const { getByText } = render(<SignForm {...defaultProps} safeTx={undefined} />) + + const button = getByText('Sign') + + expect(button).toBeInTheDocument() + expect(button).toBeDisabled() + }) + + it('is submit loading', () => { + const { getByTestId } = render(<SignForm {...defaultProps} />, { isSubmitLoading: true }) + + const button = getByTestId('combo-submit-sign') + + expect(button).toBeInTheDocument() + expect(button).toBeDisabled() + }) + + it('passed via props', () => { + const { getByText } = render(<SignForm {...defaultProps} safeTx={safeTransaction} disableSubmit />) + + const button = getByText('Sign') + + expect(button).toBeInTheDocument() + expect(button).toBeDisabled() + }) + + it('connected wallet is not an owner', () => { + const { getByText } = render(<SignForm {...defaultProps} safeTx={safeTransaction} isOwner={false} />) + + const button = getByText('Sign') + + expect(button).toBeInTheDocument() + expect(button).toBeDisabled() + }) + + it('there is a high or critical risk and user has not confirmed it', () => { + const { getByText } = render( + <SignForm + {...defaultProps} + safeTx={safeTransaction} + txSecurity={{ ...defaultSecurityContextValues, needsRiskConfirmation: true, isRiskConfirmed: false }} + />, + ) + + const button = getByText('Sign') + + expect(button).toBeInTheDocument() + expect(button).toBeDisabled() + }) + }) + + it('shows an enabled submit button if there is a high or critical risk and user has confirmed it', () => { + const { getByText } = render( + <SignForm + {...defaultProps} + safeTx={safeTransaction} + txSecurity={{ ...defaultSecurityContextValues, needsRiskConfirmation: true, isRiskConfirmed: true }} + />, + ) + + const button = getByText('Sign') + + expect(button).toBeInTheDocument() + expect(button).not.toBeDisabled() + }) +}) diff --git a/apps/web/src/components/tx-flow/actions/Sign/index.tsx b/apps/web/src/components/tx-flow/actions/Sign/index.tsx new file mode 100644 index 0000000000..1ed0a82e36 --- /dev/null +++ b/apps/web/src/components/tx-flow/actions/Sign/index.tsx @@ -0,0 +1,61 @@ +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { useCallback, useContext } from 'react' +import { TxFlowContext } from '../../TxFlowProvider' +import SignForm from './SignForm' +import useIsCounterfactualSafe from '@/features/counterfactual/hooks/useIsCounterfactualSafe' +import { type SlotComponentProps, SlotName, withSlot } from '../../slots' +import type { SubmitCallback } from '../../TxFlow' +import { useAlreadySigned } from '@/components/tx/SignOrExecuteForm/hooks' +import useSafeInfo from '@/hooks/useSafeInfo' + +export const Sign = ({ + onSubmit, + onSubmitSuccess, + disabled = false, + ...props +}: SlotComponentProps<SlotName.ComboSubmit>) => { + const { safeTx, txOrigin } = useContext(SafeTxContext) + const { txId, trackTxEvent, isSubmitDisabled } = useContext(TxFlowContext) + + const handleSubmitSuccess = useCallback<SubmitCallback>( + async ({ txId, isExecuted = false } = {}) => { + onSubmitSuccess?.({ txId, isExecuted }) + trackTxEvent(txId!, isExecuted) + }, + [onSubmitSuccess, trackTxEvent], + ) + + return ( + <SignForm + disableSubmit={isSubmitDisabled || disabled} + origin={txOrigin} + safeTx={safeTx} + onSubmit={onSubmit} + onSubmitSuccess={handleSubmitSuccess} + txId={txId} + {...props} + /> + ) +} + +const useShouldRegisterSlot = () => { + const { isProposing, willExecuteThroughRole } = useContext(TxFlowContext) + const { safeTx } = useContext(SafeTxContext) + const isCounterfactualSafe = useIsCounterfactualSafe() + const hasSigned = useAlreadySigned(safeTx) + const { safe } = useSafeInfo() + + const isFullySigned = safeTx ? safeTx.signatures.size >= safe.threshold : false + + return !!safeTx && !hasSigned && !isFullySigned && !isCounterfactualSafe && !willExecuteThroughRole && !isProposing +} + +const SignSlot = withSlot({ + Component: Sign, + label: 'Sign', + slotName: SlotName.ComboSubmit, + id: 'sign', + useSlotCondition: useShouldRegisterSlot, +}) + +export default SignSlot diff --git a/apps/web/src/components/tx-flow/actions/index.ts b/apps/web/src/components/tx-flow/actions/index.ts new file mode 100644 index 0000000000..e3070ce31d --- /dev/null +++ b/apps/web/src/components/tx-flow/actions/index.ts @@ -0,0 +1,7 @@ +export { default as Batching } from './Batching' +export { default as ComboSubmit } from './ComboSubmit' +export { default as Counterfactual } from './Counterfactual' +export { default as Execute } from './Execute' +export { default as ExecuteThroughRole } from './ExecuteThroughRole' +export { default as Propose } from './Propose' +export { default as Sign } from './Sign' diff --git a/apps/web/src/components/tx-flow/common/OwnerList/index.tsx b/apps/web/src/components/tx-flow/common/OwnerList/index.tsx index 560c72671d..e14271ab0b 100644 --- a/apps/web/src/components/tx-flow/common/OwnerList/index.tsx +++ b/apps/web/src/components/tx-flow/common/OwnerList/index.tsx @@ -7,14 +7,16 @@ import PlusIcon from '@/public/images/common/plus.svg' import EthHashInfo from '@/components/common/EthHashInfo' import css from './styles.module.css' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' export function OwnerList({ title, + icon, owners, sx, }: { owners: Array<AddressEx> + icon?: React.ElementType title?: string sx?: PaperProps['sx'] }): ReactElement { @@ -25,10 +27,11 @@ export function OwnerList({ color: 'text.secondary', display: 'flex', alignItems: 'center', + fontSize: 'inherit', }} > - <SvgIcon component={PlusIcon} inheritViewBox fontSize="small" sx={{ mr: 1 }} /> - {title ?? `New signer${maybePlural(owners)}`} + <SvgIcon component={icon ?? PlusIcon} inheritViewBox fontSize="small" sx={{ mr: 1 }} /> + {title ?? `Add owner${maybePlural(owners)}`} </Typography> {owners.map((newOwner) => ( <EthHashInfo diff --git a/apps/web/src/components/tx-flow/common/OwnerList/styles.module.css b/apps/web/src/components/tx-flow/common/OwnerList/styles.module.css index dcf6189778..42a1bc2cd6 100644 --- a/apps/web/src/components/tx-flow/common/OwnerList/styles.module.css +++ b/apps/web/src/components/tx-flow/common/OwnerList/styles.module.css @@ -4,4 +4,5 @@ gap: var(--space-1); padding: var(--space-2); background-color: var(--color-success-background); + font-size: 14px; } diff --git a/apps/web/src/components/tx-flow/common/TxButton.tsx b/apps/web/src/components/tx-flow/common/TxButton.tsx index 2288f08571..aeba544219 100644 --- a/apps/web/src/components/tx-flow/common/TxButton.tsx +++ b/apps/web/src/components/tx-flow/common/TxButton.tsx @@ -8,8 +8,6 @@ import Track from '@/components/common/Track' import { MODALS_EVENTS } from '@/services/analytics' import { useContext } from 'react' import { TxModalContext } from '..' -import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' import SwapIcon from '@/public/images/common/swap.svg' import AssetsIcon from '@/public/images/sidebar/assets.svg' import useIsSwapFeatureEnabled from '@/features/swap/hooks/useIsSwapFeatureEnabled' @@ -36,27 +34,6 @@ export const SendTokensButton = ({ onClick, sx }: { onClick: () => void; sx?: Bu ) } -export const SendNFTsButton = () => { - const router = useRouter() - const { setTxFlow } = useContext(TxModalContext) - const isEnabled = useHasFeature(FEATURES.ERC721) - - if (!isEnabled) return null - - const isNftPage = router.pathname === AppRoutes.balances.nfts - const onClick = isNftPage ? () => setTxFlow(undefined) : undefined - - return ( - <Track {...MODALS_EVENTS.SEND_COLLECTIBLE}> - <Link href={{ pathname: AppRoutes.balances.nfts, query: { safe: router.query.safe } }} passHref legacyBehavior> - <Button variant="contained" sx={buttonSx} fullWidth onClick={onClick}> - Send NFTs - </Button> - </Link> - </Track> - ) -} - export const TxBuilderButton = () => { const txBuilder = useTxBuilderApp() const router = useRouter() diff --git a/apps/web/src/components/tx-flow/common/TxCard/index.tsx b/apps/web/src/components/tx-flow/common/TxCard/index.tsx index 1ade97da92..090952220d 100644 --- a/apps/web/src/components/tx-flow/common/TxCard/index.tsx +++ b/apps/web/src/components/tx-flow/common/TxCard/index.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import { Card, CardContent } from '@mui/material' +import { Card, CardActions, CardContent, Stack } from '@mui/material' import css from '../styles.module.css' const sx = { my: 2, border: 0 } @@ -15,3 +15,19 @@ const TxCard = ({ children }: { children: ReactNode }) => { } export default TxCard + +export const TxCardActions = ({ children }: { children: ReactNode }) => { + return ( + <CardActions> + <Stack + sx={{ + width: ['100%', '100%', '100%', 'auto'], + }} + direction={{ xs: 'column-reverse', lg: 'row' }} + spacing={{ xs: 2, md: 2 }} + > + {children} + </Stack> + </CardActions> + ) +} diff --git a/apps/web/src/components/tx-flow/common/TxFlowContent/index.tsx b/apps/web/src/components/tx-flow/common/TxFlowContent/index.tsx new file mode 100644 index 0000000000..b0737720e9 --- /dev/null +++ b/apps/web/src/components/tx-flow/common/TxFlowContent/index.tsx @@ -0,0 +1,143 @@ +import { TxFlowContext } from '../../TxFlowProvider' +import { type ReactNode, useContext, useEffect, useState } from 'react' +import { Box, Container, Grid, Typography, Button, Paper, IconButton, useMediaQuery } from '@mui/material' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import { useTheme } from '@mui/material/styles' +import classnames from 'classnames' +import { ProgressBar } from '@/components/common/ProgressBar' +import css from './styles.module.css' +import SafeLogo from '@/public/images/logo-no-text.svg' +import ChainIndicator from '@/components/common/ChainIndicator' +import SecurityWarnings from '@/components/tx/security/SecurityWarnings' +import TxStatusWidget from '@/components/tx-flow/common/TxStatusWidget' +import { TxLayoutHeader } from '../TxLayout' +import { Slot, SlotName } from '../../slots' + +/** + * TxFlowContent is a component that renders the main content of the transaction flow. + * It uses the TxFlowContext to manage the transaction state and layout properties. + * The component also handles the transaction steps and progress. + * It accepts children components to be rendered within the flow. + */ +export const TxFlowContent = ({ children }: { children?: ReactNode[] | ReactNode }) => { + const { + txLayoutProps: { + title = '', + subtitle, + txSummary, + icon, + fixedNonce, + hideNonce, + hideProgress, + isReplacement, + isMessage, + }, + isBatch, + step, + progress, + onPrev, + } = useContext(TxFlowContext) + + const childrenArray = Array.isArray(children) ? children : [children] + + const [statusVisible, setStatusVisible] = useState<boolean>(true) + + const theme = useTheme() + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')) + const isDesktop = useMediaQuery(theme.breakpoints.down('lg')) + + useEffect(() => { + setStatusVisible(!isSmallScreen) + }, [isSmallScreen]) + + const toggleStatus = () => { + setStatusVisible((prev) => !prev) + } + + return ( + <> + {/* Header status button */} + {!isReplacement && ( + <IconButton className={css.statusButton} aria-label="Transaction status" size="large" onClick={toggleStatus}> + <SafeLogo width={16} height={16} /> + </IconButton> + )} + + <Container className={css.container}> + <Grid + container + sx={{ + gap: 3, + justifyContent: 'center', + }} + > + {/* Main content */} + <Grid item xs={12} md={7}> + <div className={css.titleWrapper}> + <Typography + data-testid="modal-title" + variant="h3" + component="div" + className={css.title} + sx={{ + fontWeight: '700', + }} + > + {title} + </Typography> + + <ChainIndicator inline /> + </div> + + <Paper data-testid="modal-header" className={css.header}> + {!hideProgress && ( + <Box className={css.progressBar}> + <ProgressBar value={progress} /> + </Box> + )} + + <TxLayoutHeader subtitle={subtitle} icon={icon} hideNonce={hideNonce} fixedNonce={fixedNonce} /> + </Paper> + + <div className={css.step}> + {childrenArray[step]} + + {onPrev && step > 0 && ( + <Button + data-testid="modal-back-btn" + variant={isDesktop ? 'text' : 'outlined'} + onClick={onPrev} + className={css.backButton} + startIcon={<ArrowBackIcon fontSize="small" />} + > + Back + </Button> + )} + </div> + </Grid> + + {/* Sidebar */} + {!isReplacement && ( + <Grid item xs={12} md={4} className={classnames(css.widget, { [css.active]: statusVisible })}> + {statusVisible && ( + <TxStatusWidget + isLastStep={step === childrenArray.length - 1} + txSummary={txSummary} + handleClose={() => setStatusVisible(false)} + isBatch={isBatch} + isMessage={isMessage} + /> + )} + + <Slot name={SlotName.Sidebar} /> + + <Box className={css.sticky}> + <SecurityWarnings /> + </Box> + </Grid> + )} + </Grid> + </Container> + </> + ) +} diff --git a/apps/web/src/components/tx-flow/common/TxFlowContent/styles.module.css b/apps/web/src/components/tx-flow/common/TxFlowContent/styles.module.css new file mode 100644 index 0000000000..c4b7b0978e --- /dev/null +++ b/apps/web/src/components/tx-flow/common/TxFlowContent/styles.module.css @@ -0,0 +1,170 @@ +.container { + margin-top: 10px; +} + +.header { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.headerInner { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-3); + border-bottom: 1px solid var(--color-border-light); +} + +.step { + position: relative; +} + +/* Back button */ +.backButton { + position: absolute; + left: var(--space-3); + bottom: var(--space-3); +} + +.step > :global(.MuiCard-root:first-child) { + border-top-right-radius: 0; + border-top-left-radius: 0; + margin-top: 0; +} + +/* Submit button */ +.step :global(.MuiCardActions-root) { + display: flex; + flex-direction: column; + padding: 0; + margin-top: var(--space-3); +} + +.step :global(.MuiCardActions-root) > * { + align-self: flex-end; +} + +.icon { + width: 32px; + height: 32px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + border-radius: 6px; + border: 1px solid var(--color-border-light); + margin-right: var(--space-2); +} + +.icon svg { + height: 16px; + width: auto; +} + +.step :global(.MuiAccordionSummary-content), +.step :global(.MuiAccordionSummary-content) p { + font-weight: bold; + font-size: 14px; +} + +.step :global(.MuiAccordionSummary-expandIconWrapper) { + margin-left: var(--space-2); +} + +.statusButton { + position: absolute; + top: 0; + right: 57px; + color: var(--color-text-primary); + padding: var(--space-2); + border-left: 1px solid var(--color-border-light); + border-right: 1px solid var(--color-border-light); + border-radius: 0; + width: 24px; + height: 24px; + box-sizing: content-box; + display: none; +} + +.sticky { + display: flex; + flex-direction: column; + gap: var(--space-2); + position: sticky; + top: var(--space-2); + margin-top: var(--space-2); +} + +.titleWrapper { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-2); +} + +.widget { + /* Height of transaction type title */ + margin-top: 46px; +} + +@media (max-width: 1199px) { + .backButton { + left: 50%; + transform: translateX(-50%); + } + + .step :global(.MuiCardActions-root) { + margin-bottom: var(--space-8); + } +} + +@media (max-width: 899.95px) { + .widget { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + margin-top: unset; + } + + .widget.active { + z-index: 1; + } + + .widget :global .MuiPaper-root { + height: 100%; + } + + .titleWrapper { + position: absolute; + top: 16px; + left: var(--space-2); + margin-bottom: 0; + width: calc(100% - 145px); + } + + .title { + font-size: 16px; + line-height: 18px; + } + + .container { + padding: 0; + } + + .progressBar { + display: none; + } + + .step :global(.MuiCard-root), + .header { + border-radius: 0; + } + + .statusButton { + display: inline-flex; + } +} diff --git a/apps/web/src/components/tx-flow/common/TxLayout/index.tsx b/apps/web/src/components/tx-flow/common/TxLayout/index.tsx index 72a1e2afe8..d85cc0a9c8 100644 --- a/apps/web/src/components/tx-flow/common/TxLayout/index.tsx +++ b/apps/web/src/components/tx-flow/common/TxLayout/index.tsx @@ -16,12 +16,14 @@ import { TxSecurityProvider } from '@/components/tx/security/shared/TxSecurityCo import ChainIndicator from '@/components/common/ChainIndicator' import SecurityWarnings from '@/components/tx/security/SecurityWarnings' -const TxLayoutHeader = ({ +export const TxLayoutHeader = ({ hideNonce, + fixedNonce, icon, subtitle, }: { hideNonce: TxLayoutProps['hideNonce'] + fixedNonce: TxLayoutProps['fixedNonce'] icon: TxLayoutProps['icon'] subtitle: TxLayoutProps['subtitle'] }) => { @@ -54,11 +56,16 @@ const TxLayoutHeader = ({ {subtitle} </Typography> </Box> - {!hideNonce && safe.deployed && nonceNeeded && <TxNonce />} + {!hideNonce && safe.deployed && nonceNeeded && <TxNonce canEdit={!fixedNonce} />} </Box> ) } +export type TxStep = { + txLayoutProps: Omit<TxLayoutProps, 'children'> + content: ReactElement +} + type TxLayoutProps = { title: ReactNode children: ReactNode @@ -68,6 +75,7 @@ type TxLayoutProps = { txSummary?: TransactionSummary onBack?: () => void hideNonce?: boolean + fixedNonce?: boolean hideProgress?: boolean isBatch?: boolean isReplacement?: boolean @@ -83,6 +91,7 @@ const TxLayout = ({ txSummary, onBack, hideNonce = false, + fixedNonce = false, hideProgress = false, isBatch = false, isReplacement = false, @@ -155,7 +164,7 @@ const TxLayout = ({ </Box> )} - <TxLayoutHeader subtitle={subtitle} icon={icon} hideNonce={hideNonce} /> + <TxLayoutHeader subtitle={subtitle} icon={icon} hideNonce={hideNonce} fixedNonce={fixedNonce} /> </Paper> <div className={css.step}> @@ -180,7 +189,7 @@ const TxLayout = ({ <Grid item xs={12} md={4} className={classnames(css.widget, { [css.active]: statusVisible })}> {statusVisible && ( <TxStatusWidget - step={step} + isLastStep={step === steps.length - 1} txSummary={txSummary} handleClose={() => setStatusVisible(false)} isBatch={isBatch} diff --git a/apps/web/src/components/tx-flow/common/TxLayout/styles.module.css b/apps/web/src/components/tx-flow/common/TxLayout/styles.module.css index 5344a505cb..c4b7b0978e 100644 --- a/apps/web/src/components/tx-flow/common/TxLayout/styles.module.css +++ b/apps/web/src/components/tx-flow/common/TxLayout/styles.module.css @@ -11,8 +11,7 @@ display: flex; justify-content: space-between; align-items: center; - /* Remove height of progress bar */ - padding: calc(var(--space-3) - 6px) var(--space-3) var(--space-3); + padding: var(--space-3); border-bottom: 1px solid var(--color-border-light); } @@ -27,7 +26,7 @@ bottom: var(--space-3); } -.step :global(.MuiCard-root:first-child) { +.step > :global(.MuiCard-root:first-child) { border-top-right-radius: 0; border-top-left-radius: 0; margin-top: 0; @@ -108,15 +107,18 @@ /* Height of transaction type title */ margin-top: 46px; } + @media (max-width: 1199px) { .backButton { left: 50%; transform: translateX(-50%); } + .step :global(.MuiCardActions-root) { margin-bottom: var(--space-8); } } + @media (max-width: 899.95px) { .widget { position: absolute; diff --git a/apps/web/src/components/tx-flow/common/TxNonce/index.tsx b/apps/web/src/components/tx-flow/common/TxNonce/index.tsx index 4fc8915a3b..48cc683ab4 100644 --- a/apps/web/src/components/tx-flow/common/TxNonce/index.tsx +++ b/apps/web/src/components/tx-flow/common/TxNonce/index.tsx @@ -275,8 +275,8 @@ const TxNonceForm = ({ nonce, recommendedNonce }: { nonce: string; recommendedNo const skeletonMinWidth = getFieldMinWidth('') -const TxNonce = () => { - const { nonce, recommendedNonce } = useContext(SafeTxContext) +const TxNonce = ({ canEdit = true }: { canEdit?: boolean } = {}) => { + const { nonce, recommendedNonce, isReadOnly } = useContext(SafeTxContext) return ( <Box data-testid="nonce-fld" display="flex" alignItems="center" gap={1}> @@ -286,8 +286,12 @@ const TxNonce = () => { </Typography> {nonce === undefined || recommendedNonce === undefined ? ( <Skeleton width={skeletonMinWidth} height="38px" /> - ) : ( + ) : canEdit && !isReadOnly ? ( <TxNonceForm nonce={nonce.toString()} recommendedNonce={recommendedNonce.toString()} /> + ) : ( + <Typography ml={-1} fontWeight={700}> + {nonce} + </Typography> )} </Box> ) diff --git a/apps/web/src/components/tx-flow/common/TxStatusWidget/index.tsx b/apps/web/src/components/tx-flow/common/TxStatusWidget/index.tsx index 11847b6ee9..eed4583957 100644 --- a/apps/web/src/components/tx-flow/common/TxStatusWidget/index.tsx +++ b/apps/web/src/components/tx-flow/common/TxStatusWidget/index.tsx @@ -2,7 +2,7 @@ import { useContext } from 'react' import { Divider, IconButton, List, ListItem, ListItemIcon, ListItemText, Paper, Typography } from '@mui/material' import CreatedIcon from '@/public/images/messages/created.svg' import SignedIcon from '@/public/images/messages/signed.svg' -import { type TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' +import { TransactionStatus, type TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' import useSafeInfo from '@/hooks/useSafeInfo' import { isMultisigExecutionInfo, isSignableBy, isConfirmableBy } from '@/utils/transaction-guards' import classnames from 'classnames' @@ -15,17 +15,17 @@ import useIsSafeOwner from '@/hooks/useIsSafeOwner' import { useIsWalletProposer } from '@/hooks/useProposers' const TxStatusWidget = ({ - step, txSummary, handleClose, isBatch = false, isMessage = false, + isLastStep = false, }: { - step: number txSummary?: TransactionSummary handleClose: () => void isBatch?: boolean isMessage?: boolean + isLastStep?: boolean }) => { const wallet = useWallet() const { safe } = useSafeInfo() @@ -34,6 +34,7 @@ const TxStatusWidget = ({ const isSafeOwner = useIsSafeOwner() const isProposer = useIsWalletProposer() const isProposing = isProposer && !isSafeOwner + const isAwaitingExecution = txSummary?.txStatus === TransactionStatus.AWAITING_EXECUTION const { executionInfo = undefined } = txSummary || {} const { confirmationsSubmitted = 0 } = isMultisigExecutionInfo(executionInfo) ? executionInfo : {} @@ -96,7 +97,7 @@ const TxStatusWidget = ({ </ListItemText> </ListItem> - <ListItem className={classnames({ [css.incomplete]: step < 2 })}> + <ListItem className={classnames({ [css.incomplete]: !(isAwaitingExecution && isLastStep) })}> <ListItemIcon> <SignedIcon /> </ListItemIcon> diff --git a/apps/web/src/components/tx-flow/common/TxStatusWidget/styles.module.css b/apps/web/src/components/tx-flow/common/TxStatusWidget/styles.module.css index 3408f2bace..fa323bc18f 100644 --- a/apps/web/src/components/tx-flow/common/TxStatusWidget/styles.module.css +++ b/apps/web/src/components/tx-flow/common/TxStatusWidget/styles.module.css @@ -39,6 +39,10 @@ background-color: var(--color-background-paper); } +[data-theme='light'] .status :global .MuiListItemIcon-root { + color: var(--color-secondary-dark); +} + .incomplete > * { color: var(--color-text-secondary) !important; } diff --git a/apps/web/src/components/tx-flow/common/styles.module.css b/apps/web/src/components/tx-flow/common/styles.module.css index 55fb434f16..ee4e42701a 100644 --- a/apps/web/src/components/tx-flow/common/styles.module.css +++ b/apps/web/src/components/tx-flow/common/styles.module.css @@ -16,3 +16,7 @@ .form > :global(.MuiFormControl-root) { margin-bottom: 28px; } + +.params { + margin-bottom: var(--space-2); +} diff --git a/apps/web/src/components/tx-flow/features/Blockaid.tsx b/apps/web/src/components/tx-flow/features/Blockaid.tsx new file mode 100644 index 0000000000..40c60bfd6e --- /dev/null +++ b/apps/web/src/components/tx-flow/features/Blockaid.tsx @@ -0,0 +1,29 @@ +import { useContext, useEffect } from 'react' +import { TxFlowContext } from '@/components/tx-flow/TxFlowProvider' +import { SlotName, withSlot } from '../slots' +import { FEATURES } from '@/utils/featureToggled' +import { ErrorBoundary } from '@sentry/react' +import { BlockaidWarning } from '@/components/tx/security/blockaid' +import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext' + +const BlockaidSlot = withSlot({ + Component: () => { + const { setIsSubmitDisabled } = useContext(TxFlowContext) + const { needsRiskConfirmation, isRiskConfirmed } = useContext(TxSecurityContext) + + useEffect(() => { + setIsSubmitDisabled(needsRiskConfirmation && !isRiskConfirmed) + }, [needsRiskConfirmation, isRiskConfirmed, setIsSubmitDisabled]) + + return ( + <ErrorBoundary fallback={<div>Error showing scan result</div>}> + <BlockaidWarning /> + </ErrorBoundary> + ) + }, + slotName: SlotName.Footer, + id: 'blockaid', + feature: FEATURES.RISK_MITIGATION, +}) + +export default BlockaidSlot diff --git a/apps/web/src/components/tx-flow/features/ExecuteCheckbox.tsx b/apps/web/src/components/tx-flow/features/ExecuteCheckbox.tsx new file mode 100644 index 0000000000..467edfd9db --- /dev/null +++ b/apps/web/src/components/tx-flow/features/ExecuteCheckbox.tsx @@ -0,0 +1,24 @@ +import { useContext } from 'react' +import { TxFlowContext } from '@/components/tx-flow/TxFlowProvider' +import useIsCounterfactualSafe from '@/features/counterfactual/hooks/useIsCounterfactualSafe' +import { SlotName, withSlot } from '../slots' +import ExecuteCheckbox from '@/components/tx/ExecuteCheckbox' + +const useShouldRegisterSlot = () => { + const { canExecute, onlyExecute, isProposing, canExecuteThroughRole } = useContext(TxFlowContext) + const isCounterfactualSafe = useIsCounterfactualSafe() + + return (canExecute || canExecuteThroughRole) && !onlyExecute && !isCounterfactualSafe && !isProposing +} + +const ExecuteCheckboxSlot = withSlot({ + Component: () => { + const { setShouldExecute } = useContext(TxFlowContext) + return <ExecuteCheckbox onChange={setShouldExecute} /> + }, + slotName: SlotName.Footer, + id: 'executeCheckbox', + useSlotCondition: useShouldRegisterSlot, +}) + +export default ExecuteCheckboxSlot diff --git a/apps/web/src/components/tx-flow/features/SignerSelect/SignerForm/__tests__/index.test.tsx b/apps/web/src/components/tx-flow/features/SignerSelect/SignerForm/__tests__/index.test.tsx new file mode 100644 index 0000000000..9dac3c2220 --- /dev/null +++ b/apps/web/src/components/tx-flow/features/SignerSelect/SignerForm/__tests__/index.test.tsx @@ -0,0 +1,310 @@ +import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners' +import useSafeInfo from '@/hooks/useSafeInfo' +import { render, waitFor } from '@/tests/test-utils' +import { SignerForm } from '..' +import { faker } from '@faker-js/faker' +import { extendedSafeInfoBuilder, addressExBuilder } from '@/tests/builders/safe' +import { generateRandomArray } from '@/tests/builders/utils' +import { type Eip1193Provider } from 'ethers' +import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' +import { type ReactElement, useState } from 'react' +import { WalletContext } from '@/components/common/WalletProvider' +import { SafeTxContext, type SafeTxContextParams } from '@/components/tx-flow/SafeTxProvider' +import { type SafeSignature, type SafeTransaction } from '@safe-global/types-kit' +import { safeSignatureBuilder, safeTxBuilder } from '@/tests/builders/safeTx' +import { shortenAddress } from '@safe-global/utils/utils/formatters' +import { useIsNestedSafeOwner } from '@/hooks/useIsNestedSafeOwner' +import { useIsWalletProposer } from '@/hooks/useProposers' + +jest.mock('@/hooks/useNestedSafeOwners') +jest.mock('@/hooks/useSafeInfo') +jest.mock('@/hooks/useIsNestedSafeOwner') +jest.mock('@/hooks/useProposers') + +const TestSafeTxProvider = ({ + initialSafeTx, + children, +}: { + initialSafeTx: SafeTransaction + children: ReactElement +}) => { + const [safeTx, setSafeTx] = useState<SafeTransaction | undefined>(initialSafeTx) + return ( + <SafeTxContext.Provider value={{ safeTx, setSafeTx } as unknown as SafeTxContextParams}> + {children} + </SafeTxContext.Provider> + ) +} + +const TestWalletContextProvider = ({ + connectedWallet, + children, +}: { + connectedWallet: ConnectedWallet | null + children: ReactElement +}) => { + const [signerAddress, setSignerAddress] = useState<string>() + + return ( + <WalletContext.Provider + value={ + connectedWallet + ? { + connectedWallet, + setSignerAddress, + signer: { + address: signerAddress || connectedWallet.address, + chainId: '1', + provider: null, + isSafe: Boolean(signerAddress), + }, + } + : null + } + > + {children} + </WalletContext.Provider> + ) +} + +describe('SignerForm', () => { + const mockUseSafeInfo = useSafeInfo as jest.MockedFunction<typeof useSafeInfo> + const mockUseNestedSafeOwners = useNestedSafeOwners as jest.MockedFunction<typeof useNestedSafeOwners> + const mockUseIsNestedSafeOwner = useIsNestedSafeOwner as jest.MockedFunction<typeof useIsNestedSafeOwner> + const mockUseIsWalletProposer = useIsWalletProposer as jest.MockedFunction<typeof useIsWalletProposer> + + const safeAddress = faker.finance.ethereumAddress() + // 2/3 Safe + const mockSafeInfo = { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ chainId: '1' }) + .with({ owners: generateRandomArray(() => addressExBuilder().build(), { min: 3, max: 3 }) }) + .with({ threshold: 2 }) + .build(), + safeLoaded: true, + safeLoading: false, + } + + const mockOwners = mockSafeInfo.safe.owners + + beforeEach(() => { + jest.resetAllMocks() + + mockUseSafeInfo.mockReturnValue(mockSafeInfo) + mockUseIsNestedSafeOwner.mockReturnValue(true) + }) + + it('should not render anything if no wallet is connected', () => { + const result = render( + <TestWalletContextProvider connectedWallet={null}> + <SignerForm /> + </TestWalletContextProvider>, + ) + expect(result.queryByText('Sign with')).toBeNull() + }) + + it('should not render if there are no nested Safes', () => { + mockUseNestedSafeOwners.mockReturnValue([]) + mockUseIsNestedSafeOwner.mockReturnValue(false) + + const result = render( + <TestWalletContextProvider + connectedWallet={{ + address: faker.finance.ethereumAddress(), + chainId: '1', + label: 'MetaMask', + provider: {} as Eip1193Provider, + }} + > + <SignerForm /> + </TestWalletContextProvider>, + ) + + expect(result.queryByText('Sign with')).toBeNull() + }) + + it('should render sign form if there are nested Safes', () => { + mockUseNestedSafeOwners.mockReturnValue([mockOwners[0].value]) + const result = render( + <TestWalletContextProvider + connectedWallet={{ + address: faker.finance.ethereumAddress(), + chainId: '1', + label: 'MetaMask', + provider: {} as Eip1193Provider, + }} + > + <SignerForm /> + </TestWalletContextProvider>, + ) + expect(result.queryByText('Sign with')).toBeVisible() + }) + + it('should render execution form if there are nested Safes', () => { + mockUseNestedSafeOwners.mockReturnValue([mockOwners[0].value]) + const result = render( + <TestWalletContextProvider + connectedWallet={{ + address: faker.finance.ethereumAddress(), + chainId: '1', + label: 'MetaMask', + provider: {} as Eip1193Provider, + }} + > + <SignerForm willExecute /> + </TestWalletContextProvider>, + ) + expect(result.queryByText('Execute with')).toBeVisible() + }) + + it('should not render if execution and fully signed', () => { + const mockSignatures = new Map<string, SafeSignature>( + mockSafeInfo.safe.owners + .slice(0, 2) + .map( + (owner) => + [owner.value, safeSignatureBuilder().with({ signer: owner.value }).build()] as [string, SafeSignature], + ), + ) + mockUseNestedSafeOwners.mockReturnValue([mockOwners[0].value]) + const result = render( + <TestSafeTxProvider + initialSafeTx={safeTxBuilder() + .with({ + signatures: mockSignatures, + }) + .build()} + > + <TestWalletContextProvider + connectedWallet={{ + address: faker.finance.ethereumAddress(), + chainId: '1', + label: 'MetaMask', + provider: {} as Eip1193Provider, + }} + > + <SignerForm willExecute /> + </TestWalletContextProvider> + </TestSafeTxProvider>, + ) + expect(result.queryByText('Execute with')).toBeNull() + }) + + it('should render if execution and last signer', async () => { + const mockSignatures = new Map<string, SafeSignature>( + mockSafeInfo.safe.owners + .slice(0, 1) + .map( + (owner) => + [owner.value, safeSignatureBuilder().with({ signer: owner.value }).build()] as [string, SafeSignature], + ), + ) + mockUseNestedSafeOwners.mockReturnValue([mockOwners[1].value]) + const result = render( + <TestSafeTxProvider + initialSafeTx={safeTxBuilder() + .with({ + signatures: mockSignatures, + }) + .build()} + > + <TestWalletContextProvider + connectedWallet={{ + address: faker.finance.ethereumAddress(), + chainId: '1', + label: 'MetaMask', + provider: {} as Eip1193Provider, + }} + > + <SignerForm willExecute /> + </TestWalletContextProvider> + </TestSafeTxProvider>, + ) + expect(result.queryByText('Execute with')).toBeVisible() + await waitFor(() => expect(result.queryByText(shortenAddress(mockSafeInfo.safe.owners[1].value))).toBeVisible()) + }) + + it('should correctly pre-select the signer if the connected wallet has already signed', async () => { + const mockSignatures = new Map<string, SafeSignature>( + mockSafeInfo.safe.owners + .slice(0, 1) + .map( + (owner) => + [owner.value, safeSignatureBuilder().with({ signer: owner.value }).build()] as [string, SafeSignature], + ), + ) + mockUseNestedSafeOwners.mockReturnValue([mockOwners[1].value]) + const result = render( + <TestSafeTxProvider + initialSafeTx={safeTxBuilder() + .with({ + signatures: mockSignatures, + }) + .build()} + > + <TestWalletContextProvider + connectedWallet={{ + address: mockSafeInfo.safe.owners[0].value, + chainId: '1', + label: 'MetaMask', + provider: {} as Eip1193Provider, + }} + > + <SignerForm /> + </TestWalletContextProvider> + </TestSafeTxProvider>, + ) + expect(result.queryByText('Sign with')).toBeVisible() + await waitFor(() => { + expect(result.queryByText(shortenAddress(mockSafeInfo.safe.owners[1].value))).toBeVisible() + }) + }) + + it('adds the connected wallet to the options when the wallet is the proposer of a new tx', async () => { + const proposerWallet = faker.finance.ethereumAddress() + mockUseIsWalletProposer.mockReturnValue(true) + mockUseNestedSafeOwners.mockReturnValue([mockOwners[0].value]) + + const result = render( + <TestWalletContextProvider + connectedWallet={{ + address: proposerWallet, + chainId: '1', + label: 'MetaMask', + provider: {} as Eip1193Provider, + }} + > + <SignerForm /> + </TestWalletContextProvider>, + ) + + expect(result.queryByText('Sign with')).toBeVisible() + await waitFor(() => expect(result.queryByText(shortenAddress(proposerWallet))).toBeVisible()) + }) + + it('does not add the proposer wallet when editing an existing tx', async () => { + const proposerWallet = faker.finance.ethereumAddress() + mockUseIsWalletProposer.mockReturnValue(true) + mockUseNestedSafeOwners.mockReturnValue([mockOwners[0].value]) + + const result = render( + <TestSafeTxProvider initialSafeTx={safeTxBuilder().build()}> + <TestWalletContextProvider + connectedWallet={{ + address: proposerWallet, + chainId: '1', + label: 'MetaMask', + provider: {} as Eip1193Provider, + }} + > + <SignerForm txId="0x123" /> + </TestWalletContextProvider> + </TestSafeTxProvider>, + ) + + expect(result.queryByText('Sign with')).toBeVisible() + expect(result.queryByText(shortenAddress(proposerWallet))).toBeNull() + }) +}) diff --git a/apps/web/src/components/tx/SignOrExecuteForm/SignerForm/index.tsx b/apps/web/src/components/tx-flow/features/SignerSelect/SignerForm/index.tsx similarity index 72% rename from apps/web/src/components/tx/SignOrExecuteForm/SignerForm/index.tsx rename to apps/web/src/components/tx-flow/features/SignerSelect/SignerForm/index.tsx index ceace9f5e3..d2bee7cf66 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/SignerForm/index.tsx +++ b/apps/web/src/components/tx-flow/features/SignerSelect/SignerForm/index.tsx @@ -12,25 +12,28 @@ import { import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners' import { useWalletContext } from '@/hooks/wallets/useWallet' import EthHashInfo from '@/components/common/EthHashInfo' -import { useCallback, useContext, useMemo } from 'react' +import { useCallback, useContext, useEffect, useMemo } from 'react' import useSafeInfo from '@/hooks/useSafeInfo' import TxCard from '@/components/tx-flow/common/TxCard' import InfoIcon from '@/public/images/notifications/info.svg' import SignatureIcon from '@/public/images/transactions/signature.svg' import css from './styles.module.css' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import { MODALS_EVENTS, trackEvent } from '@/services/analytics' import { useIsNestedSafeOwner } from '@/hooks/useIsNestedSafeOwner' +import { useIsWalletProposer } from '@/hooks/useProposers' -export const SignerForm = ({ willExecute }: { willExecute?: boolean }) => { +export const SignerForm = ({ willExecute, txId }: { willExecute?: boolean; txId?: string }) => { const { signer, setSignerAddress, connectedWallet: wallet } = useWalletContext() ?? {} const nestedSafeOwners = useNestedSafeOwners() const signerAddress = signer?.address const { safe } = useSafeInfo() const { safeTx } = useContext(SafeTxContext) const isNestedOwner = useIsNestedSafeOwner() + const isProposer = useIsWalletProposer() + const isCreation = !txId const onChange = (event: SelectChangeEvent<string>) => { trackEvent(MODALS_EVENTS.CHANGE_SIGNER) @@ -57,17 +60,40 @@ export const SignerForm = ({ willExecute }: { willExecute?: boolean }) => { if (!wallet) { return [] } - const owners = new Set(nestedSafeOwners ?? []) const isFullySigned = safeTx ? safeTx.signatures.size >= safe.threshold : false - if ((willExecute && isFullySigned) || safe.owners.some((owner) => sameAddress(owner.value, wallet.address))) { + // No nested execution for fully signed transactions + if (isFullySigned && willExecute) { + return [wallet.address] + } + + const owners = new Set(nestedSafeOwners ?? []) + + if (safe.owners.some((owner) => sameAddress(owner.value, wallet.address))) { + owners.add(wallet.address) + } + + if (isProposer && isCreation) { owners.add(wallet.address) } return Array.from(owners) - }, [nestedSafeOwners, safe.owners, safe.threshold, safeTx, wallet, willExecute]) + }, [nestedSafeOwners, safe.owners, safe.threshold, safeTx, wallet, willExecute, isProposer, isCreation]) + + // Select first option if no signer is selected and the connected wallet cannot sign + useEffect(() => { + const isValidSigner = signerAddress && options.includes(signerAddress) && isOptionEnabled(signerAddress) + if (isValidSigner || !setSignerAddress || !wallet) { + return + } + + const enabledOptions = options.filter(isOptionEnabled) + if (enabledOptions.length > 0 && !enabledOptions.includes(wallet.address)) { + setSignerAddress(enabledOptions[0]) + } + }, [isOptionEnabled, options, setSignerAddress, signerAddress, wallet]) - if (!wallet || !isNestedOwner) { + if (!wallet || !isNestedOwner || (options.length === 1 && options[0] === wallet.address)) { return null } @@ -96,7 +122,7 @@ export const SignerForm = ({ willExecute }: { willExecute?: boolean }) => { label="Signer account" fullWidth onChange={onChange} - value={signerAddress ?? options.filter(isOptionEnabled)[0]} + value={signerAddress} > {options?.map((owner) => ( <MenuItem key={owner} value={owner} disabled={!isOptionEnabled(owner)}> diff --git a/apps/web/src/components/tx/SignOrExecuteForm/SignerForm/styles.module.css b/apps/web/src/components/tx-flow/features/SignerSelect/SignerForm/styles.module.css similarity index 100% rename from apps/web/src/components/tx/SignOrExecuteForm/SignerForm/styles.module.css rename to apps/web/src/components/tx-flow/features/SignerSelect/SignerForm/styles.module.css diff --git a/apps/web/src/components/tx-flow/features/SignerSelect/index.tsx b/apps/web/src/components/tx-flow/features/SignerSelect/index.tsx new file mode 100644 index 0000000000..c0e52bc042 --- /dev/null +++ b/apps/web/src/components/tx-flow/features/SignerSelect/index.tsx @@ -0,0 +1,24 @@ +import { useContext } from 'react' +import { TxFlowContext } from '@/components/tx-flow/TxFlowProvider' +import { SlotName, withSlot } from '../../slots' +import { SignerForm } from './SignerForm' +import { useWalletContext } from '@/hooks/wallets/useWallet' +import { useIsNestedSafeOwner } from '@/hooks/useIsNestedSafeOwner' + +const useShouldRegisterSlot = () => { + const { connectedWallet } = useWalletContext() ?? {} + const isNestedOwner = useIsNestedSafeOwner() + return !!connectedWallet && !!isNestedOwner +} + +const SignerSelectSlot = withSlot({ + Component: () => { + const { willExecute, txId } = useContext(TxFlowContext) + return <SignerForm willExecute={willExecute} txId={txId} /> + }, + slotName: SlotName.Feature, + id: 'signerSelect', + useSlotCondition: useShouldRegisterSlot, +}) + +export default SignerSelectSlot diff --git a/apps/web/src/components/tx-flow/features/TxChecks/TxChecks.tsx b/apps/web/src/components/tx-flow/features/TxChecks/TxChecks.tsx new file mode 100644 index 0000000000..11e899d761 --- /dev/null +++ b/apps/web/src/components/tx-flow/features/TxChecks/TxChecks.tsx @@ -0,0 +1,43 @@ +import { useCurrentChain, useHasFeature } from '@/hooks/useChains' +import { type ReactElement } from 'react' +import { TxSimulation, TxSimulationMessage } from '@/components/tx/security/tenderly' +import TxCard from '@/components/tx-flow/common/TxCard' +import { Box, Typography } from '@mui/material' + +import type { SafeTransaction, MetaTransactionData } from '@safe-global/types-kit' + +import css from './styles.module.css' +import { FEATURES } from '@safe-global/utils/utils/chains' +import { isTxSimulationEnabled } from '@safe-global/utils/components/tx/security/tenderly/utils' + +const TxChecks = ({ + executionOwner, + disabled = false, + transaction, +}: { + executionOwner?: string + disabled?: boolean + transaction: SafeTransaction | Array<MetaTransactionData> +}): ReactElement | null => { + const chain = useCurrentChain() + const isRiskMitigationFeatureEnabled = useHasFeature(FEATURES.RISK_MITIGATION) + const isTxSimulationFeatureEnabled = isTxSimulationEnabled(chain) + + return ( + <TxCard> + <Typography variant="h5">Transaction checks</Typography> + + {(isTxSimulationFeatureEnabled || isRiskMitigationFeatureEnabled) && ( + <> + <TxSimulation disabled={disabled} transactions={transaction} executionOwner={executionOwner} /> + + <Box className={css.mobileTxCheckMessages}> + <TxSimulationMessage /> + </Box> + </> + )} + </TxCard> + ) +} + +export default TxChecks diff --git a/apps/web/src/components/tx-flow/features/TxChecks/index.tsx b/apps/web/src/components/tx-flow/features/TxChecks/index.tsx new file mode 100644 index 0000000000..650565f71b --- /dev/null +++ b/apps/web/src/components/tx-flow/features/TxChecks/index.tsx @@ -0,0 +1,29 @@ +import { useContext } from 'react' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { TxFlowContext } from '@/components/tx-flow/TxFlowProvider' +import useIsCounterfactualSafe from '@/features/counterfactual/hooks/useIsCounterfactualSafe' +import TxChecks from './TxChecks' +import { SlotName, withSlot } from '../../slots' + +const useShouldRegisterSlot = () => { + const { isRejection } = useContext(TxFlowContext) + const { safeTx } = useContext(SafeTxContext) + const isCounterfactualSafe = useIsCounterfactualSafe() + + return !isCounterfactualSafe && !isRejection && !!safeTx +} + +const TxChecksSlot = withSlot({ + Component: () => { + const { safeTx } = useContext(SafeTxContext) + + if (!safeTx) return false + + return <TxChecks transaction={safeTx} /> + }, + slotName: SlotName.Feature, + id: 'txChecks', + useSlotCondition: useShouldRegisterSlot, +}) + +export default TxChecksSlot diff --git a/apps/web/src/components/tx-flow/features/TxChecks/styles.module.css b/apps/web/src/components/tx-flow/features/TxChecks/styles.module.css new file mode 100644 index 0000000000..da3ad23b78 --- /dev/null +++ b/apps/web/src/components/tx-flow/features/TxChecks/styles.module.css @@ -0,0 +1,10 @@ +.mobileTxCheckMessages, +.mobileTxCheckMessages:empty { + display: none; +} + +@media (max-width: 899.95px) { + .mobileTxCheckMessages { + display: block; + } +} diff --git a/apps/web/src/components/tx-flow/features/TxNote.tsx b/apps/web/src/components/tx-flow/features/TxNote.tsx new file mode 100644 index 0000000000..d17946c567 --- /dev/null +++ b/apps/web/src/components/tx-flow/features/TxNote.tsx @@ -0,0 +1,35 @@ +import type { ReactElement } from 'react' +import { useCallback, useContext } from 'react' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { TxFlowContext } from '@/components/tx-flow/TxFlowProvider' +import { encodeTxNote, TxNoteForm } from '@/features/tx-notes' +import { SlotName, withSlot } from '../slots' + +const TxNote = (): ReactElement => { + const { txOrigin, setTxOrigin } = useContext(SafeTxContext) + const { txDetails, isCreation } = useContext(TxFlowContext) + + const onNoteChange = useCallback( + (note: string) => { + setTxOrigin(encodeTxNote(note, txOrigin)) + }, + [setTxOrigin, txOrigin], + ) + + return <TxNoteForm isCreation={isCreation} onChange={onNoteChange} txDetails={txDetails} /> +} + +const useShouldRegisterSlot = () => { + const { txDetails, isCreation } = useContext(TxFlowContext) + + return isCreation || !!txDetails?.note +} + +const TxNoteSlot = withSlot({ + Component: TxNote, + slotName: SlotName.Sidebar, + id: 'txNote', + useSlotCondition: useShouldRegisterSlot, +}) + +export default TxNoteSlot diff --git a/apps/web/src/components/tx-flow/features/index.ts b/apps/web/src/components/tx-flow/features/index.ts new file mode 100644 index 0000000000..aeada952b3 --- /dev/null +++ b/apps/web/src/components/tx-flow/features/index.ts @@ -0,0 +1,5 @@ +export { default as Blockaid } from './Blockaid' +export { default as ExecuteCheckbox } from './ExecuteCheckbox' +export { default as TxChecks } from './TxChecks' +export { default as TxNote } from './TxNote' +export { default as SignerSelect } from './SignerSelect' diff --git a/apps/web/src/components/tx-flow/flows/AddOwner/ChooseOwner.tsx b/apps/web/src/components/tx-flow/flows/AddOwner/ChooseOwner.tsx index 916c0629ce..38351f5687 100644 --- a/apps/web/src/components/tx-flow/flows/AddOwner/ChooseOwner.tsx +++ b/apps/web/src/components/tx-flow/flows/AddOwner/ChooseOwner.tsx @@ -19,7 +19,7 @@ import AddressBookInput from '@/components/common/AddressBookInput' import NameInput from '@/components/common/NameInput' import { useAddressResolver } from '@/hooks/useAddressResolver' import useSafeInfo from '@/hooks/useSafeInfo' -import { uniqueAddress, addressIsNotCurrentSafe } from '@/utils/validation' +import { uniqueAddress, addressIsNotCurrentSafe } from '@safe-global/utils/utils/validation' import type { AddOwnerFlowProps } from '.' import type { ReplaceOwnerFlowProps } from '../ReplaceOwner' import TxCard from '../../common/TxCard' @@ -27,7 +27,7 @@ import InfoIcon from '@/public/images/notifications/info.svg' import commonCss from '@/components/tx-flow/common/styles.module.css' import { TOOLTIP_TITLES } from '@/components/tx-flow/common/constants' import EthHashInfo from '@/components/common/EthHashInfo' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' type FormData = Pick<AddOwnerFlowProps | ReplaceOwnerFlowProps, 'newOwner' | 'threshold'> diff --git a/apps/web/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx b/apps/web/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx index 7c148cc2d2..b1b8e884a6 100644 --- a/apps/web/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx +++ b/apps/web/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx @@ -1,7 +1,6 @@ import { useCurrentChain } from '@/hooks/useChains' -import { useContext, useEffect } from 'react' +import { useContext, useEffect, type PropsWithChildren } from 'react' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import useSafeInfo from '@/hooks/useSafeInfo' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' import { createSwapOwnerTx, createAddOwnerTx } from '@/services/tx/tx-sender' @@ -11,8 +10,16 @@ import { SafeTxContext } from '../../SafeTxProvider' import type { AddOwnerFlowProps } from '.' import type { ReplaceOwnerFlowProps } from '../ReplaceOwner' import { SettingsChangeContext } from './context' +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' -export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwnerFlowProps }) => { +export const ReviewOwner = ({ + params, + onSubmit, + children, +}: PropsWithChildren<{ + params: AddOwnerFlowProps | ReplaceOwnerFlowProps + onSubmit?: () => void +}>) => { const dispatch = useAppDispatch() const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) const { safe } = useSafeInfo() @@ -36,7 +43,7 @@ export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwn promise.then(setSafeTx).catch(setSafeTxError) }, [removedOwner, newOwner, threshold, setSafeTx, setSafeTxError, chain, safe.deployed]) - const addAddressBookEntryAndSubmit = () => { + const addAddressBookEntry = () => { if (typeof newOwner.name !== 'undefined') { dispatch( upsertAddressBookEntries({ @@ -51,9 +58,14 @@ export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwn trackEvent({ ...SETTINGS_EVENTS.SETUP.OWNERS, label: safe.owners.length }) } + const handleSubmit = () => { + addAddressBookEntry() + onSubmit?.() + } + return ( <SettingsChangeContext.Provider value={params}> - <SignOrExecuteForm onSubmit={addAddressBookEntryAndSubmit} showMethodCall /> + <ReviewTransaction onSubmit={handleSubmit}>{children}</ReviewTransaction> </SettingsChangeContext.Provider> ) } diff --git a/apps/web/src/components/tx-flow/flows/AddOwner/index.tsx b/apps/web/src/components/tx-flow/flows/AddOwner/index.tsx index 3ac29131d4..82855b4e0d 100644 --- a/apps/web/src/components/tx-flow/flows/AddOwner/index.tsx +++ b/apps/web/src/components/tx-flow/flows/AddOwner/index.tsx @@ -1,9 +1,13 @@ -import TxLayout from '@/components/tx-flow/common/TxLayout' -import useTxStepper from '@/components/tx-flow/useTxStepper' import { ChooseOwner, ChooseOwnerMode } from '@/components/tx-flow/flows/AddOwner/ChooseOwner' import { ReviewOwner } from '@/components/tx-flow/flows/AddOwner/ReviewOwner' import SaveAddressIcon from '@/public/images/common/save-address.svg' import useSafeInfo from '@/hooks/useSafeInfo' +import { useContext } from 'react' +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' +import { TxFlowStep } from '../../TxFlowStep' +import { TxFlowContext } from '../../TxFlowProvider' +import { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' type Owner = { address: string @@ -16,46 +20,47 @@ export type AddOwnerFlowProps = { threshold: number } -const FlowInner = ({ defaultValues }: { defaultValues: AddOwnerFlowProps }) => { - const { data, step, nextStep, prevStep } = useTxStepper<AddOwnerFlowProps>(defaultValues) +const ChooseOwnerStep = () => { + const { onNext, data } = useContext(TxFlowContext) - const steps = [ - <ChooseOwner - key={0} - params={data} - onSubmit={(formData) => nextStep({ ...data, ...formData })} - mode={ChooseOwnerMode.ADD} - />, - <ReviewOwner key={1} params={data} />, - ] + return <ChooseOwner onSubmit={onNext} params={data} mode={ChooseOwnerMode.ADD} /> +} - return ( - <TxLayout - title={step === 0 ? 'New transaction' : 'Confirm transaction'} - subtitle="Add signer" - icon={SaveAddressIcon} - step={step} - onBack={prevStep} - > - {steps} - </TxLayout> - ) +const ReviewOwnerStep = (props: ReviewTransactionProps) => { + const { data } = useContext(TxFlowContext) + + return <ReviewOwner params={data} {...props} /> } const AddOwnerFlow = ({ address }: { address?: string }) => { - const { safe, safeLoading, safeLoaded } = useSafeInfo() + const { + safe: { threshold }, + safeLoaded, + } = useSafeInfo() const defaultValues: AddOwnerFlowProps = { newOwner: { address: address || '', name: '', }, - threshold: safe.threshold, + threshold, } - if (!safeLoaded || safeLoading) return null + if (!safeLoaded) return null - return <FlowInner defaultValues={defaultValues} /> + return ( + <TxFlow + initialData={defaultValues} + eventCategory={TxFlowType.ADD_OWNER} + icon={SaveAddressIcon} + subtitle="Add signer" + ReviewTransactionComponent={ReviewOwnerStep} + > + <TxFlowStep title="New transaction"> + <ChooseOwnerStep /> + </TxFlowStep> + </TxFlow> + ) } export default AddOwnerFlow diff --git a/apps/web/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryFlowReview.tsx b/apps/web/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryFlowReview.tsx index 578f308e37..14d83a2517 100644 --- a/apps/web/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryFlowReview.tsx +++ b/apps/web/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryFlowReview.tsx @@ -2,22 +2,25 @@ import { trackEvent } from '@/services/analytics' import { RECOVERY_EVENTS } from '@/services/analytics/events/recovery' import { Typography } from '@mui/material' import { useContext } from 'react' -import type { ReactElement } from 'react' +import type { PropsWithChildren, ReactElement } from 'react' import { SafeTxContext } from '../../SafeTxProvider' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { getRecoverySkipTransaction } from '@/features/recovery/services/transaction' import { createTx } from '@/services/tx/tx-sender' import ErrorMessage from '@/components/tx/ErrorMessage' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' -const onSubmit = () => { - trackEvent({ ...RECOVERY_EVENTS.SUBMIT_RECOVERY_CANCEL }) -} - -export function CancelRecoveryFlowReview({ recovery }: { recovery: RecoveryQueueItem }): ReactElement { +export function CancelRecoveryFlowReview({ + recovery, + onSubmit, + children, +}: PropsWithChildren<{ + recovery: RecoveryQueueItem + onSubmit: () => void +}>): ReactElement { const web3ReadOnly = useWeb3ReadOnly() const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) @@ -29,8 +32,13 @@ export function CancelRecoveryFlowReview({ recovery }: { recovery: RecoveryQueue createTx(transaction).then(setSafeTx).catch(setSafeTxError) }, [setSafeTx, setSafeTxError, recovery, web3ReadOnly]) + const handleSubmit = () => { + trackEvent({ ...RECOVERY_EVENTS.SUBMIT_RECOVERY_CANCEL }) + onSubmit() + } + return ( - <SignOrExecuteForm onSubmit={onSubmit} isBatchable={false}> + <ReviewTransaction onSubmit={handleSubmit}> <Typography mb={1}> All actions initiated by the Recoverer will be cancelled. The current signers will remain the signers of the Safe Account. @@ -41,6 +49,8 @@ export function CancelRecoveryFlowReview({ recovery }: { recovery: RecoveryQueue {recovery.isMalicious ? 'malicious transaction' : 'recovery proposal'}. It requires other signer signatures in order to be executed. </ErrorMessage> - </SignOrExecuteForm> + + {children} + </ReviewTransaction> ) } diff --git a/apps/web/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryOverview.tsx b/apps/web/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryOverview.tsx index 8e128c2871..546443145e 100644 --- a/apps/web/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryOverview.tsx +++ b/apps/web/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryOverview.tsx @@ -9,9 +9,11 @@ import css from './styles.module.css' import ReplaceTxIcon from '@/public/images/transactions/replace-tx.svg' import { TxModalContext } from '../..' import TxCard from '../../common/TxCard' +import { TxFlowContext } from '../../TxFlowProvider' -export function CancelRecoveryOverview({ onSubmit }: { onSubmit: () => void }): ReactElement { +export function CancelRecoveryOverview(): ReactElement { const { setTxFlow } = useContext(TxModalContext) + const { onNext } = useContext(TxFlowContext) const onClose = () => { setTxFlow(undefined) @@ -38,7 +40,7 @@ export function CancelRecoveryOverview({ onSubmit }: { onSubmit: () => void }): Go back </Button> - <Button data-testid="cancel-proposal-btn" variant="contained" onClick={onSubmit} className={css.button}> + <Button data-testid="cancel-proposal-btn" variant="contained" onClick={onNext} className={css.button}> Yes, cancel proposal </Button> </Box> diff --git a/apps/web/src/components/tx-flow/flows/CancelRecovery/index.tsx b/apps/web/src/components/tx-flow/flows/CancelRecovery/index.tsx index 7038c43d16..c10e66da32 100644 --- a/apps/web/src/components/tx-flow/flows/CancelRecovery/index.tsx +++ b/apps/web/src/components/tx-flow/flows/CancelRecovery/index.tsx @@ -1,32 +1,38 @@ -import { CANCEL_RECOVERY_CATEGORY } from '@/services/analytics/events/recovery' -import type { ReactElement } from 'react' - -import TxLayout from '../../common/TxLayout' +import { useMemo, type ReactElement } from 'react' import { CancelRecoveryFlowReview } from './CancelRecoveryFlowReview' import { CancelRecoveryOverview } from './CancelRecoveryOverview' -import useTxStepper from '../../useTxStepper' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' +import { TxFlowStep } from '../../TxFlowStep' +import type ReviewTransaction from '@/components/tx/ReviewTransactionV2' -function CancelRecoveryFlow({ recovery }: { recovery: RecoveryQueueItem }): ReactElement { - const { step, nextStep, prevStep } = useTxStepper<undefined>(undefined, CANCEL_RECOVERY_CATEGORY) +const TITLE = 'Cancel Account recovery' - const steps = [ - <CancelRecoveryOverview key={0} onSubmit={() => nextStep(undefined)} />, - <CancelRecoveryFlowReview key={1} recovery={recovery} />, - ] +type CancelRecoveryFlowProps = { + recovery: RecoveryQueueItem +} - const isIntro = step === 0 +function CancelRecoveryFlow({ recovery }: CancelRecoveryFlowProps): ReactElement { + const ReviewTransactionComponent = useMemo<typeof ReviewTransaction>( + () => + function ReviewCancelRecovery(props) { + return <CancelRecoveryFlowReview recovery={recovery} {...props} /> + }, + [recovery], + ) return ( - <TxLayout - title={isIntro ? 'Cancel Account recovery' : 'New transaction'} - subtitle={isIntro ? undefined : 'Cancel Account recovery'} - step={step} - hideNonce={isIntro} - onBack={prevStep} + <TxFlow + subtitle={TITLE} + eventCategory={TxFlowType.CANCEL_RECOVERY} + isBatchable={false} + ReviewTransactionComponent={ReviewTransactionComponent} > - {steps} - </TxLayout> + <TxFlowStep title={TITLE} subtitle="" hideNonce> + <CancelRecoveryOverview /> + </TxFlowStep> + </TxFlow> ) } diff --git a/apps/web/src/components/tx-flow/flows/ChangeThreshold/ChooseThreshold.tsx b/apps/web/src/components/tx-flow/flows/ChangeThreshold/ChooseThreshold.tsx index e54990b9b4..8f824d6334 100644 --- a/apps/web/src/components/tx-flow/flows/ChangeThreshold/ChooseThreshold.tsx +++ b/apps/web/src/components/tx-flow/flows/ChangeThreshold/ChooseThreshold.tsx @@ -11,34 +11,35 @@ import { SvgIcon, Tooltip, } from '@mui/material' -import type { ReactElement } from 'react' - +import { useContext, useEffect } from 'react' import useSafeInfo from '@/hooks/useSafeInfo' import TxCard from '@/components/tx-flow/common/TxCard' import { ChangeThresholdFlowFieldNames } from '@/components/tx-flow/flows/ChangeThreshold' import type { ChangeThresholdFlowProps } from '@/components/tx-flow/flows/ChangeThreshold' import InfoIcon from '@/public/images/notifications/info.svg' import { TOOLTIP_TITLES } from '@/components/tx-flow/common/constants' - import commonCss from '@/components/tx-flow/common/styles.module.css' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { createUpdateThresholdTx } from '@/services/tx/tx-sender' +import { TxFlowContext } from '@/components/tx-flow/TxFlowProvider' -export const ChooseThreshold = ({ - params, - onSubmit, -}: { - params: ChangeThresholdFlowProps - onSubmit: (data: ChangeThresholdFlowProps) => void -}): ReactElement => { +export const ChooseThreshold = () => { + const { onNext, data } = useContext(TxFlowContext) + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) const { safe } = useSafeInfo() const formMethods = useForm<ChangeThresholdFlowProps>({ - defaultValues: params, + defaultValues: data, mode: 'onChange', }) const newThreshold = formMethods.watch(ChangeThresholdFlowFieldNames.threshold) + useEffect(() => { + createUpdateThresholdTx(newThreshold).then(setSafeTx).catch(setSafeTxError) + }, [newThreshold, setSafeTx, setSafeTxError]) + return ( <TxCard> <div> @@ -67,7 +68,7 @@ export const ChooseThreshold = ({ <Typography>Any transaction will require the confirmation of:</Typography> </div> - <form onSubmit={formMethods.handleSubmit(onSubmit)}> + <form onSubmit={formMethods.handleSubmit(onNext)}> <Box sx={{ mb: 2, diff --git a/apps/web/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx b/apps/web/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx index 48e7a2a238..10ca9e92c5 100644 --- a/apps/web/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx +++ b/apps/web/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx @@ -1,16 +1,19 @@ import useSafeInfo from '@/hooks/useSafeInfo' -import { useContext, useEffect } from 'react' +import { useContext, useEffect, type PropsWithChildren } from 'react' import { createUpdateThresholdTx } from '@/services/tx/tx-sender' import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import { ChangeThresholdFlowFieldNames } from '@/components/tx-flow/flows/ChangeThreshold' import type { ChangeThresholdFlowProps } from '@/components/tx-flow/flows/ChangeThreshold' -import { ChangeThresholdReviewContext } from './context' +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' -const ReviewChangeThreshold = ({ params }: { params: ChangeThresholdFlowProps }) => { +const ReviewChangeThreshold = ({ + params, + onSubmit, + children, +}: PropsWithChildren<{ params: ChangeThresholdFlowProps; onSubmit: () => void }>) => { const { safe } = useSafeInfo() const newThreshold = params[ChangeThresholdFlowFieldNames.threshold] @@ -20,16 +23,17 @@ const ReviewChangeThreshold = ({ params }: { params: ChangeThresholdFlowProps }) createUpdateThresholdTx(newThreshold).then(setSafeTx).catch(setSafeTxError) }, [newThreshold, setSafeTx, setSafeTxError]) - const onChangeThreshold = () => { + const trackEvents = () => { trackEvent({ ...SETTINGS_EVENTS.SETUP.OWNERS, label: safe.owners.length }) trackEvent({ ...SETTINGS_EVENTS.SETUP.THRESHOLD, label: newThreshold }) } - return ( - <ChangeThresholdReviewContext.Provider value={{ newThreshold }}> - <SignOrExecuteForm onSubmit={onChangeThreshold} showMethodCall /> - </ChangeThresholdReviewContext.Provider> - ) + const handleSubmit = () => { + trackEvents() + onSubmit() + } + + return <ReviewTransaction onSubmit={handleSubmit}>{children}</ReviewTransaction> } export default ReviewChangeThreshold diff --git a/apps/web/src/components/tx-flow/flows/ChangeThreshold/context.tsx b/apps/web/src/components/tx-flow/flows/ChangeThreshold/context.tsx deleted file mode 100644 index 5acdc018cf..0000000000 --- a/apps/web/src/components/tx-flow/flows/ChangeThreshold/context.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { createContext } from 'react' - -export const ChangeThresholdReviewContext = createContext<{ newThreshold?: number }>({}) diff --git a/apps/web/src/components/tx-flow/flows/ChangeThreshold/index.tsx b/apps/web/src/components/tx-flow/flows/ChangeThreshold/index.tsx index 907d48da78..f3d72a7003 100644 --- a/apps/web/src/components/tx-flow/flows/ChangeThreshold/index.tsx +++ b/apps/web/src/components/tx-flow/flows/ChangeThreshold/index.tsx @@ -1,9 +1,13 @@ -import TxLayout from '@/components/tx-flow/common/TxLayout' -import ReviewChangeThreshold from '@/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold' -import useTxStepper from '@/components/tx-flow/useTxStepper' import SaveAddressIcon from '@/public/images/common/save-address.svg' import useSafeInfo from '@/hooks/useSafeInfo' -import { ChooseThreshold } from '@/components/tx-flow/flows/ChangeThreshold/ChooseThreshold' +import { ChooseThreshold } from './ChooseThreshold' +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' +import { TxFlowStep } from '../../TxFlowStep' +import { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' +import { useContext } from 'react' +import { TxFlowContext } from '../../TxFlowProvider' +import ReviewChangeThreshold from './ReviewChangeThreshold' export enum ChangeThresholdFlowFieldNames { threshold = 'threshold', @@ -13,28 +17,29 @@ export type ChangeThresholdFlowProps = { [ChangeThresholdFlowFieldNames.threshold]: number } -const ChangeThresholdFlow = () => { - const { safe } = useSafeInfo() +const ReviewThresholdStep = (props: ReviewTransactionProps) => { + const { data } = useContext(TxFlowContext) - const { data, step, nextStep, prevStep } = useTxStepper<ChangeThresholdFlowProps>({ - [ChangeThresholdFlowFieldNames.threshold]: safe.threshold, - }) + return <ReviewChangeThreshold params={data} {...props} /> +} - const steps = [ - <ChooseThreshold key={0} params={data} onSubmit={(formData) => nextStep(formData)} />, - <ReviewChangeThreshold key={1} params={data} />, - ] +const ChangeThresholdFlow = () => { + const { + safe: { threshold }, + } = useSafeInfo() return ( - <TxLayout - title={step === 0 ? 'New transaction' : 'Confirm transaction'} - subtitle="Change threshold" + <TxFlow + initialData={{ threshold }} icon={SaveAddressIcon} - step={step} - onBack={prevStep} + subtitle="Change threshold" + eventCategory={TxFlowType.CHANGE_THRESHOLD} + ReviewTransactionComponent={ReviewThresholdStep} > - {steps} - </TxLayout> + <TxFlowStep title="New transaction"> + <ChooseThreshold /> + </TxFlowStep> + </TxFlow> ) } diff --git a/apps/web/src/components/tx-flow/flows/ConfirmBatch/index.tsx b/apps/web/src/components/tx-flow/flows/ConfirmBatch/index.tsx index dcb2476e55..7d2182c487 100644 --- a/apps/web/src/components/tx-flow/flows/ConfirmBatch/index.tsx +++ b/apps/web/src/components/tx-flow/flows/ConfirmBatch/index.tsx @@ -1,14 +1,15 @@ -import { type ReactElement, useContext, useEffect } from 'react' +import { useContext, useEffect } from 'react' import { type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { createMultiSendCallOnlyTx } from '@/services/tx/tx-sender' import { SafeTxContext } from '../../SafeTxProvider' -import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' -import { OperationType } from '@safe-global/safe-core-sdk-types' -import TxLayout from '../../common/TxLayout' +import type { MetaTransactionData } from '@safe-global/types-kit' +import { OperationType } from '@safe-global/types-kit' import BatchIcon from '@/public/images/common/batch.svg' import { useDraftBatch } from '@/hooks/useDraftBatch' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' +import ReviewTransaction, { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' type ConfirmBatchProps = { onSubmit: () => void @@ -23,7 +24,7 @@ const getData = (txDetails: TransactionDetails): MetaTransactionData => { } } -const ConfirmBatch = ({ onSubmit }: ConfirmBatchProps): ReactElement => { +const ConfirmBatch = (props: ReviewTransactionProps) => { const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) const batchTxs = useDraftBatch() @@ -32,21 +33,21 @@ const ConfirmBatch = ({ onSubmit }: ConfirmBatchProps): ReactElement => { createMultiSendCallOnlyTx(calls).then(setSafeTx).catch(setSafeTxError) }, [batchTxs, setSafeTx, setSafeTxError]) - return <SignOrExecuteForm onSubmit={onSubmit} isBatch /> + return <ReviewTransaction {...props} title="Confirm batch" /> } -const ConfirmBatchFlow = (props: ConfirmBatchProps) => { +const ConfirmBatchFlow = ({ onSubmit }: ConfirmBatchProps) => { const { length } = useDraftBatch() + return ( - <TxLayout - title="Confirm batch" - subtitle={`This batch contains ${length} transaction${maybePlural(length)}`} + <TxFlow icon={BatchIcon} - step={0} + subtitle={`This batch contains ${length} transaction${maybePlural(length)}`} + eventCategory={TxFlowType.CONFIRM_BATCH} + ReviewTransactionComponent={ConfirmBatch} + onSubmit={onSubmit} isBatch - > - <ConfirmBatch {...props} /> - </TxLayout> + /> ) } diff --git a/apps/web/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx b/apps/web/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx index 391e2c77ba..632bab9ea0 100644 --- a/apps/web/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx +++ b/apps/web/src/components/tx-flow/flows/ConfirmTx/ConfirmProposedTx.tsx @@ -1,53 +1,46 @@ -import { type ReactElement, useContext, useEffect } from 'react' +import { type PropsWithChildren, type ReactElement, useContext, useEffect } from 'react' import { Typography } from '@mui/material' -import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' -import useSafeInfo from '@/hooks/useSafeInfo' import { useChainId } from '@/hooks/useChainId' -import { useSigner } from '@/hooks/wallets/useWallet' -import { isExecutable, isMultisigExecutionInfo, isSignableBy } from '@/utils/transaction-guards' import { createExistingTx } from '@/services/tx/tx-sender' -import { SafeTxContext } from '../../SafeTxProvider' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' +import type { ReviewTransactionContentProps } from '@/components/tx/ReviewTransactionV2/ReviewTransactionContent' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { TxFlowContext } from '@/components/tx-flow/TxFlowProvider' -type ConfirmProposedTxProps = { - txSummary: TransactionSummary -} +type ConfirmProposedTxProps = PropsWithChildren< + { + txNonce: number | undefined + } & ReviewTransactionContentProps +> const SIGN_TEXT = 'Sign this transaction.' const EXECUTE_TEXT = 'Submit the form to execute this transaction.' const SIGN_EXECUTE_TEXT = 'Sign or immediately execute this transaction.' -const ConfirmProposedTx = ({ txSummary }: ConfirmProposedTxProps): ReactElement => { - const signer = useSigner() - const { safe, safeAddress } = useSafeInfo() +const ConfirmProposedTx = ({ txNonce, children, ...props }: ConfirmProposedTxProps): ReactElement => { const chainId = useChainId() - const { setSafeTx, setSafeTxError, setNonce } = useContext(SafeTxContext) - - const txId = txSummary.id - const txNonce = isMultisigExecutionInfo(txSummary.executionInfo) ? txSummary.executionInfo.nonce : undefined - const canExecute = isExecutable(txSummary, signer?.address || '', safe) - const canSign = isSignableBy(txSummary, signer?.address || '') + const { setSafeTx, setSafeTxError, setNonce, setIsReadOnly } = useContext(SafeTxContext) + const { txId, onlyExecute, isExecutable } = useContext(TxFlowContext) useEffect(() => { txNonce !== undefined && setNonce(txNonce) - }, [setNonce, txNonce]) + // Data of transactions in the queue should never be editable + setIsReadOnly(true) + }, [setNonce, txNonce, setIsReadOnly]) useEffect(() => { - createExistingTx(chainId, safeAddress, txId).then(setSafeTx).catch(setSafeTxError) - }, [txId, safeAddress, chainId, setSafeTx, setSafeTxError]) + if (txId) { + createExistingTx(chainId, txId).then(setSafeTx).catch(setSafeTxError) + } + }, [txId, chainId, setSafeTx, setSafeTxError]) - const text = canSign ? (canExecute ? SIGN_EXECUTE_TEXT : SIGN_TEXT) : EXECUTE_TEXT + const text = !onlyExecute ? (isExecutable ? SIGN_EXECUTE_TEXT : SIGN_TEXT) : EXECUTE_TEXT return ( - <SignOrExecuteForm txId={txId} isExecutable={canExecute} onlyExecute={!canSign} showMethodCall> - <Typography - sx={{ - mb: 1, - }} - > - {text} - </Typography> - </SignOrExecuteForm> + <ReviewTransaction {...props}> + <Typography mb={1}>{text}</Typography> + {children} + </ReviewTransaction> ) } diff --git a/apps/web/src/components/tx-flow/flows/ConfirmTx/index.tsx b/apps/web/src/components/tx-flow/flows/ConfirmTx/index.tsx index 47c91cc764..ee3a54ec00 100644 --- a/apps/web/src/components/tx-flow/flows/ConfirmTx/index.tsx +++ b/apps/web/src/components/tx-flow/flows/ConfirmTx/index.tsx @@ -1,24 +1,36 @@ import { isSwapOrderTxInfo } from '@/utils/transaction-guards' import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' -import TxLayout from '@/components/tx-flow/common/TxLayout' import ConfirmProposedTx from './ConfirmProposedTx' import { useTransactionType } from '@/hooks/useTransactionType' import SwapIcon from '@/public/images/common/swap.svg' +import { isExecutable, isMultisigExecutionInfo, isSignableBy } from '@/utils/transaction-guards' +import { useSigner } from '@/hooks/wallets/useWallet' +import useSafeInfo from '@/hooks/useSafeInfo' +import { TxFlow } from '../../TxFlow' +import { TxFlowType } from '@/services/analytics' const ConfirmTxFlow = ({ txSummary }: { txSummary: TransactionSummary }) => { const { text } = useTransactionType(txSummary) const isSwapOrder = isSwapOrderTxInfo(txSummary.txInfo) + const signer = useSigner() + const { safe } = useSafeInfo() + + const txId = txSummary.id + const txNonce = isMultisigExecutionInfo(txSummary.executionInfo) ? txSummary.executionInfo.nonce : undefined + const canExecute = isExecutable(txSummary, signer?.address || '', safe) + const canSign = isSignableBy(txSummary, signer?.address || '') return ( - <TxLayout - title="Confirm transaction" - subtitle={<>{text} </>} + <TxFlow icon={isSwapOrder && SwapIcon} - step={0} + subtitle={<>{text} </>} + txId={txId} + isExecutable={canExecute} + onlyExecute={!canSign} txSummary={txSummary} - > - <ConfirmProposedTx txSummary={txSummary} /> - </TxLayout> + ReviewTransactionComponent={(props) => <ConfirmProposedTx txNonce={txNonce} {...props} />} + eventCategory={TxFlowType.CONFIRM_TX} + /> ) } diff --git a/apps/web/src/components/tx-flow/flows/CreateNestedSafe/ReviewNestedSafe.tsx b/apps/web/src/components/tx-flow/flows/CreateNestedSafe/ReviewNestedSafe.tsx new file mode 100644 index 0000000000..deca0b6dad --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/CreateNestedSafe/ReviewNestedSafe.tsx @@ -0,0 +1,114 @@ +import { skipToken } from '@reduxjs/toolkit/query' +import { useCallback, useContext, useEffect, useMemo } from 'react' +import type { PropsWithChildren, ReactElement } from 'react' +import type { MetaTransactionData, SafeTransaction } from '@safe-global/types-kit' + +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import useSafeInfo from '@/hooks/useSafeInfo' +import useBalances from '@/hooks/useBalances' +import { useCurrentChain } from '@/hooks/useChains' +import useAsync from '@safe-global/utils/hooks/useAsync' +import { createNewUndeployedSafeWithoutSalt, encodeSafeCreationTx } from '@/components/new-safe/create/logic' +import { useGetOwnedSafesQuery } from '@/store/slices' +import { predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils' +import { useWeb3ReadOnly } from '@/hooks/wallets/web3' +import { createTokenTransferParams } from '@/services/tx/tokenTransferParams' +import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' +import { SetupNestedSafeFormAssetFields } from '@/components/tx-flow/flows/CreateNestedSafe/SetupNestedSafe' +import type { SetupNestedSafeForm } from '@/components/tx-flow/flows/CreateNestedSafe/SetupNestedSafe' +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' + +export function ReviewNestedSafe({ + params, + onSubmit, + children, +}: PropsWithChildren<{ + params: SetupNestedSafeForm + onSubmit: (predictedSafeAddress?: string) => void +}>): ReactElement { + const { safeAddress, safe, safeLoaded } = useSafeInfo() + const chain = useCurrentChain() + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) + const { balances } = useBalances() + const provider = useWeb3ReadOnly() + const { data: nestedSafes } = useGetOwnedSafesQuery( + safeLoaded ? { chainId: safe.chainId, ownerAddress: safeAddress } : skipToken, + ) + const version = getLatestSafeVersion(chain) + + const safeAccountConfig = useMemo(() => { + if (!chain || !nestedSafes) { + return + } + + const undeployedSafe = createNewUndeployedSafeWithoutSalt( + version, + { + owners: [safeAddress], + threshold: 1, + }, + chain, + ) + const saltNonce = Date.now().toString() + + return { + ...undeployedSafe, + saltNonce, + } + }, [chain, safeAddress, nestedSafes, version]) + + const [predictedSafeAddress] = useAsync(async () => { + if (provider && safeAccountConfig) { + return predictAddressBasedOnReplayData(safeAccountConfig, provider) + } + }, [provider, safeAccountConfig]) + + useEffect(() => { + if (!chain || !safeAccountConfig || !predictedSafeAddress) { + return + } + + const deploymentTx = { + to: safeAccountConfig.factoryAddress, + data: encodeSafeCreationTx(safeAccountConfig, chain), + value: '0', + } + + const fundingTxs: Array<MetaTransactionData> = [] + + for (const asset of params.assets) { + const token = balances.items.find((item) => { + return item.tokenInfo.address === asset[SetupNestedSafeFormAssetFields.tokenAddress] + }) + + if (token) { + fundingTxs.push( + createTokenTransferParams( + predictedSafeAddress, + asset[SetupNestedSafeFormAssetFields.amount], + token.tokenInfo.decimals, + token.tokenInfo.address, + ), + ) + } + } + + const createSafeTx = async (): Promise<SafeTransaction> => { + const isMultiSend = fundingTxs.length > 0 + return isMultiSend ? createMultiSendCallOnlyTx([deploymentTx, ...fundingTxs]) : createTx(deploymentTx) + } + + createSafeTx().then(setSafeTx).catch(setSafeTxError) + }, [chain, params.assets, safeAccountConfig, predictedSafeAddress, balances.items, setSafeTx, setSafeTxError]) + + const handleSubmit = useCallback(() => { + onSubmit(predictedSafeAddress) + }, [onSubmit, predictedSafeAddress]) + + return ( + <ReviewTransaction onSubmit={handleSubmit} title="Confirm Nested Safe"> + {children} + </ReviewTransaction> + ) +} diff --git a/apps/web/src/components/tx-flow/flows/CreateNestedSafe/SetupNestedSafe.tsx b/apps/web/src/components/tx-flow/flows/CreateNestedSafe/SetupNestedSafe.tsx new file mode 100644 index 0000000000..6d4b3098d8 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/CreateNestedSafe/SetupNestedSafe.tsx @@ -0,0 +1,261 @@ +import { + Box, + Button, + CardActions, + Divider, + FormControl, + IconButton, + InputAdornment, + InputLabel, + MenuItem, + SvgIcon, + TextField, + Tooltip, + Typography, +} from '@mui/material' +import classNames from 'classnames' +import { Controller, FormProvider, useFieldArray, useForm, useFormContext } from 'react-hook-form' +import { useContext, type ReactElement } from 'react' + +import InfoIcon from '@/public/images/notifications/info.svg' +import AddIcon from '@/public/images/common/add.svg' +import DeleteIcon from '@/public/images/common/delete.svg' +import TxCard from '@/components/tx-flow/common/TxCard' +import useSafeAddress from '@/hooks/useSafeAddress' +import useAddressBook from '@/hooks/useAddressBook' +import NameInput from '@/components/common/NameInput' +import tokenInputCss from '@/components/common/TokenAmountInput/styles.module.css' +import NumberField from '@/components/common/NumberField' +import { useVisibleBalances } from '@/hooks/useVisibleBalances' +import { AutocompleteItem } from '@/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer' +import { validateDecimalLength, validateLimitedAmount } from '@safe-global/utils/utils/validation' +import { safeFormatUnits } from '@safe-global/utils/utils/formatters' +import { useMnemonicPrefixedSafeName } from '@/hooks/useMnemonicName' +import css from '@/components/tx-flow/flows/CreateNestedSafe/styles.module.css' +import commonCss from '@/components/tx-flow/common/styles.module.css' +import { TxFlowContext, type TxFlowContextType } from '../../TxFlowProvider' + +export type SetupNestedSafeForm = { + [SetupNestedSafeFormFields.name]: string + [SetupNestedSafeFormFields.assets]: Array<Record<SetupNestedSafeFormAssetFields, string>> +} + +export enum SetupNestedSafeFormFields { + name = 'name', + assets = 'assets', +} + +export enum SetupNestedSafeFormAssetFields { + tokenAddress = 'tokenAddress', + amount = 'amount', +} + +export function SetUpNestedSafe(): ReactElement { + const addressBook = useAddressBook() + const safeAddress = useSafeAddress() + const randomName = useMnemonicPrefixedSafeName('Nested') + const fallbackName = addressBook[safeAddress] ?? randomName + const { onNext, data } = useContext<TxFlowContextType<SetupNestedSafeForm>>(TxFlowContext) + + const formMethods = useForm<SetupNestedSafeForm>({ + defaultValues: data, + mode: 'onChange', + }) + + const onFormSubmit = (data: SetupNestedSafeForm) => { + onNext({ + ...data, + [SetupNestedSafeFormFields.name]: data[SetupNestedSafeFormFields.name] || fallbackName, + }) + } + + return ( + <TxCard> + <FormProvider {...formMethods}> + <form onSubmit={formMethods.handleSubmit(onFormSubmit)}> + <Typography variant="body2" mt={1}> + Name your Nested Safe and select which assets to fund it with. All selected assets will be transferred when + deployed. + </Typography> + + <FormControl fullWidth sx={{ mt: 3 }}> + <NameInput + data-testid="nested-safe-name-input" + name={SetupNestedSafeFormFields.name} + label="Name" + placeholder={fallbackName} + InputLabelProps={{ shrink: true }} + InputProps={{ + endAdornment: ( + <Tooltip + title="This name is stored locally and will never be shared with us or any third parties." + arrow + placement="top" + > + <InputAdornment position="end"> + <SvgIcon component={InfoIcon} inheritViewBox /> + </InputAdornment> + </Tooltip> + ), + }} + /> + </FormControl> + + <AssetInputs name={SetupNestedSafeFormFields.assets} /> + + <Divider className={commonCss.nestedDivider} /> + + <CardActions> + <Button data-testid="next-button" variant="contained" type="submit"> + Next + </Button> + </CardActions> + </form> + </FormProvider> + </TxCard> + ) +} + +/** + * Note: the following is very similar to TokenAmountInput but with key differences to support + * a field array. Adjusting the former was initially attempted but proved to be too complex. + * + * TODO: Refactor the both to share a common implementation. + */ +function AssetInputs({ name }: { name: SetupNestedSafeFormFields.assets }) { + const { balances } = useVisibleBalances() + + const formMethods = useFormContext<SetupNestedSafeForm>() + const fieldArray = useFieldArray<SetupNestedSafeForm>({ name }) + + const selectedAssets = formMethods.watch(name) + const nonSelectedAssets = balances.items.filter((item) => { + return !selectedAssets.map((asset) => asset.tokenAddress).includes(item.tokenInfo.address) + }) + const defaultAsset: SetupNestedSafeForm[typeof name][number] = { + tokenAddress: nonSelectedAssets[0]?.tokenInfo.address, + amount: '', + } + + return ( + <> + {fieldArray.fields.map((field, index) => { + const errors = formMethods.formState.errors?.[name]?.[index] + const label = + errors?.[SetupNestedSafeFormAssetFields.tokenAddress]?.message || + errors?.[SetupNestedSafeFormAssetFields.amount]?.message || + 'Amount' + const isError = !!errors && Object.keys(errors).length > 0 + + const thisAsset = balances.items.find((item) => { + return item.tokenInfo.address === selectedAssets[index][SetupNestedSafeFormAssetFields.tokenAddress] + }) + const thisAndNonSelectedAssets = balances.items.filter((item) => { + return ( + item.tokenInfo.address === thisAsset?.tokenInfo.address || + nonSelectedAssets.some((nonSelected) => item.tokenInfo.address === nonSelected.tokenInfo.address) + ) + }) + return ( + <Box data-testid="asset-data" className={css.assetInput} key={field.id}> + <FormControl className={classNames(tokenInputCss.outline, { [tokenInputCss.error]: isError })} fullWidth> + <InputLabel shrink required className={tokenInputCss.label}> + {label} + </InputLabel> + + <div className={tokenInputCss.inputs}> + <Controller + name={`${name}.${index}.${SetupNestedSafeFormAssetFields.amount}`} + rules={{ + required: true, + validate: (value) => { + return ( + validateLimitedAmount(value, thisAsset?.tokenInfo.decimals, thisAsset?.balance) || + validateDecimalLength(value, thisAsset?.tokenInfo.decimals) + ) + }, + }} + render={({ field }) => { + const onClickMax = () => { + if (thisAsset) { + const maxAmount = safeFormatUnits(thisAsset.balance, thisAsset.tokenInfo.decimals) + field.onChange(maxAmount) + } + } + return ( + <NumberField + data-testid="amount-input" + variant="standard" + InputProps={{ + disableUnderline: true, + endAdornment: ( + <Button data-testid="max-button" className={tokenInputCss.max} onClick={onClickMax}> + Max + </Button> + ), + }} + className={tokenInputCss.amount} + required + placeholder="0" + {...field} + /> + ) + }} + /> + + <Divider orientation="vertical" flexItem /> + + <Controller + name={`${name}.${index}.${SetupNestedSafeFormAssetFields.tokenAddress}`} + rules={{ required: true, deps: [`${name}.${index}.${SetupNestedSafeFormAssetFields.amount}`] }} + render={({ field }) => { + return ( + <TextField + data-testid="token-selector" + select + variant="standard" + InputProps={{ + disableUnderline: true, + }} + className={tokenInputCss.select} + required + sx={{ minWidth: '200px' }} + {...field} + > + {thisAndNonSelectedAssets.map((item) => { + return ( + <MenuItem key={item.tokenInfo.address} value={item.tokenInfo.address}> + <AutocompleteItem {...item} /> + </MenuItem> + ) + })} + </TextField> + ) + }} + /> + </div> + </FormControl> + + <IconButton data-testid="remove-asset-icon" onClick={() => fieldArray.remove(index)}> + <SvgIcon component={DeleteIcon} inheritViewBox /> + </IconButton> + </Box> + ) + })} + + <Button + data-testid="fund-asset-button" + variant="text" + onClick={() => { + fieldArray.append(defaultAsset, { shouldFocus: true }) + }} + startIcon={<SvgIcon component={AddIcon} inheritViewBox fontSize="small" />} + size="large" + sx={{ my: 3 }} + disabled={nonSelectedAssets.length === 0} + > + Fund new asset + </Button> + </> + ) +} diff --git a/apps/web/src/components/tx-flow/flows/CreateNestedSafe/index.tsx b/apps/web/src/components/tx-flow/flows/CreateNestedSafe/index.tsx new file mode 100644 index 0000000000..462d069776 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/CreateNestedSafe/index.tsx @@ -0,0 +1,66 @@ +import { useCallback, useContext, useMemo, useState, type ReactElement } from 'react' +import NestedSafeIcon from '@/public/images/sidebar/nested-safes-icon.svg' +import { ReviewNestedSafe } from '@/components/tx-flow/flows/CreateNestedSafe/ReviewNestedSafe' +import { SetUpNestedSafe } from '@/components/tx-flow/flows/CreateNestedSafe/SetupNestedSafe' +import type { SetupNestedSafeForm } from '@/components/tx-flow/flows/CreateNestedSafe/SetupNestedSafe' +import { useAppDispatch } from '@/store' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' +import useSafeInfo from '@/hooks/useSafeInfo' +import { type SubmitCallbackWithData, TxFlow } from '../../TxFlow' +import { TxFlowStep } from '../../TxFlowStep' +import type ReviewTransaction from '@/components/tx/ReviewTransactionV2' +import { TxFlowContext, type TxFlowContextType } from '../../TxFlowProvider' + +export function CreateNestedSafe(): ReactElement { + const dispatch = useAppDispatch() + const { safe } = useSafeInfo() + const [predictedSafeAddress, setPredictedSafeAddress] = useState<string | undefined>() + + const ReviewNestedSafeCreationComponent = useMemo<typeof ReviewTransaction>( + () => + function ReviewNestedSafeCreation({ onSubmit, ...props }) { + const { data } = useContext<TxFlowContextType<SetupNestedSafeForm>>(TxFlowContext) + + const handleSubmit = useCallback( + (predictedSafeAddress?: string) => { + setPredictedSafeAddress(predictedSafeAddress) + onSubmit() + }, + [onSubmit], + ) + + return <ReviewNestedSafe {...props} params={data!} onSubmit={handleSubmit} /> + }, + [setPredictedSafeAddress], + ) + + const handleSubmit = useCallback<SubmitCallbackWithData<SetupNestedSafeForm>>( + (args) => { + if (!predictedSafeAddress) { + return + } + dispatch( + upsertAddressBookEntries({ + chainIds: [safe.chainId], + address: predictedSafeAddress, + name: args?.data?.name ?? '', + }), + ) + }, + [dispatch, predictedSafeAddress, safe.chainId], + ) + + return ( + <TxFlow<SetupNestedSafeForm> + initialData={{ name: '', assets: [] }} + icon={NestedSafeIcon} + subtitle="Create a Nested Safe" + ReviewTransactionComponent={ReviewNestedSafeCreationComponent} + onSubmit={handleSubmit} + > + <TxFlowStep title="Set up Nested Safe"> + <SetUpNestedSafe /> + </TxFlowStep> + </TxFlow> + ) +} diff --git a/apps/web/src/components/tx-flow/flows/CreateNestedSafe/styles.module.css b/apps/web/src/components/tx-flow/flows/CreateNestedSafe/styles.module.css new file mode 100644 index 0000000000..c0be8e64f1 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/CreateNestedSafe/styles.module.css @@ -0,0 +1,8 @@ +.assetInput { + display: flex; + flex-direction: row; + margin-top: var(--space-3); + gap: var(--space-1); + align-items: center; + justify-content: center; +} diff --git a/apps/web/src/components/tx-flow/flows/ExecuteBatch/DecodedTxs.tsx b/apps/web/src/components/tx-flow/flows/ExecuteBatch/DecodedTxs.tsx index 5b2b83f2b3..05a3c6bff1 100644 --- a/apps/web/src/components/tx-flow/flows/ExecuteBatch/DecodedTxs.tsx +++ b/apps/web/src/components/tx-flow/flows/ExecuteBatch/DecodedTxs.tsx @@ -1,6 +1,5 @@ import type { DataDecoded, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { Box } from '@mui/material' -import useSafeInfo from '@/hooks/useSafeInfo' import extractTxInfo from '@/services/tx/extractTxInfo' import { isCustomTxInfo, isNativeTokenTransfer, isTransferTxInfo } from '@/utils/transaction-guards' import SingleTxDecoded from '@/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded' @@ -11,7 +10,6 @@ import { type AccordionProps } from '@mui/material/Accordion/Accordion' const DecodedTxs = ({ txs }: { txs: TransactionDetails[] | undefined }) => { const [openMap, setOpenMap] = useState<Record<number, boolean>>() - const { safeAddress } = useSafeInfo() if (!txs) return null @@ -30,7 +28,7 @@ const DecodedTxs = ({ txs }: { txs: TransactionDetails[] | undefined }) => { })) } - const { txParams } = extractTxInfo(transaction, safeAddress) + const { txParams } = extractTxInfo(transaction) let decodedDataParams: DataDecoded = { method: '', @@ -61,6 +59,7 @@ const DecodedTxs = ({ txs }: { txs: TransactionDetails[] | undefined }) => { actionTitle={`${idx + 1}`} expanded={openMap?.[idx] ?? false} onChange={onChange} + isExecuted={!!transaction.executedAt} /> ) })} diff --git a/apps/web/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx b/apps/web/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx index 1aab7a96fb..1897d17e34 100644 --- a/apps/web/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx +++ b/apps/web/src/components/tx-flow/flows/ExecuteBatch/ReviewBatch.tsx @@ -1,7 +1,6 @@ import useWallet from '@/hooks/wallets/useWallet' import { CircularProgress, Typography, Button, CardActions, Divider, Alert } from '@mui/material' -import useAsync from '@/hooks/useAsync' -import { FEATURES } from '@/utils/chains' +import useAsync from '@safe-global/utils/hooks/useAsync' import { getReadOnlyMultiSendCallOnlyContract } from '@/services/contracts/safeContracts' import { useCurrentChain } from '@/hooks/useChains' import useSafeInfo from '@/hooks/useSafeInfo' @@ -11,7 +10,7 @@ import type { SyntheticEvent } from 'react' import ErrorMessage from '@/components/tx/ErrorMessage' import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector' import DecodedTxs from '@/components/tx-flow/flows/ExecuteBatch/DecodedTxs' -import { TxSimulation } from '@/components/tx/security/tenderly' +import TxChecks from '@/components/tx-flow/features/TxChecks/TxChecks' import { useRelaysBySafe } from '@/hooks/useRemainingRelays' import useOnboard from '@/hooks/wallets/useOnboard' import { logError, Errors } from '@/services/exceptions' @@ -21,24 +20,23 @@ import { getMultiSendTxs } from '@/utils/transactions' import TxCard from '../../common/TxCard' import CheckWallet from '@/components/common/CheckWallet' import type { ExecuteBatchFlowProps } from '.' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import SendToBlock from '@/components/tx/SendToBlock' import ConfirmationTitle, { ConfirmationTitleTypes } from '@/components/tx/SignOrExecuteForm/ConfirmationTitle' import commonCss from '@/components/tx-flow/common/styles.module.css' import { TxModalContext } from '@/components/tx-flow' import useGasPrice from '@/hooks/useGasPrice' -import { hasFeature } from '@/utils/chains' import type { Overrides } from 'ethers' import { trackEvent } from '@/services/analytics' import { TX_EVENTS, TX_TYPES } from '@/services/analytics/events/transactions' import { isWalletRejection } from '@/utils/wallets' import WalletRejectionError from '@/components/tx/SignOrExecuteForm/WalletRejectionError' import useUserNonce from '@/components/tx/AdvancedParams/useUserNonce' -import { getLatestSafeVersion } from '@/utils/chains' import { HexEncodedData } from '@/components/transactions/HexEncodedData' import { useGetMultipleTransactionDetailsQuery } from '@/store/api/gateway' import { skipToken } from '@reduxjs/toolkit/query/react' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' +import { FEATURES, getLatestSafeVersion, hasFeature } from '@safe-global/utils/utils/chains' export const ReviewBatch = ({ params }: { params: ExecuteBatchFlowProps }) => { const [isSubmittable, setIsSubmittable] = useState<boolean>(true) @@ -86,7 +84,7 @@ export const ReviewBatch = ({ params }: { params: ExecuteBatchFlowProps }) => { const [multisendContractAddress = ''] = useAsync(async () => { if (!multiSendContract) return '' - return await multiSendContract.getAddress() + return multiSendContract.getAddress() }, [multiSendContract]) const [multiSendTxs] = useAsync(async () => { @@ -96,7 +94,7 @@ export const ReviewBatch = ({ params }: { params: ExecuteBatchFlowProps }) => { const multiSendTxData = useMemo(() => { if (!txsWithDetails || !multiSendTxs) return - return encodeMultiSendData(multiSendTxs) + return encodeMultiSendData(multiSendTxs) as `0x${string}` }, [txsWithDetails, multiSendTxs]) const onExecute = async () => { @@ -115,7 +113,6 @@ export const ReviewBatch = ({ params }: { params: ExecuteBatchFlowProps }) => { multiSendTxData, wallet.provider, wallet.address, - safe.address.value, overrides as Overrides & { nonce: number }, safe.nonce, ) @@ -173,20 +170,14 @@ export const ReviewBatch = ({ params }: { params: ExecuteBatchFlowProps }) => { {multiSendContract && <SendToBlock address={multisendContractAddress} title="Interact with" />} - {multiSendTxData && <HexEncodedData title="Data (hex-encoded)" hexData={multiSendTxData} />} + {multiSendTxData && <HexEncodedData title="Data" hexData={multiSendTxData} />} <div> <DecodedTxs txs={txsWithDetails} /> </div> </TxCard> - {multiSendTxs && ( - <TxCard> - <Typography variant="h5">Transaction checks</Typography> - - <TxSimulation transactions={multiSendTxs} disabled={submitDisabled} /> - </TxCard> - )} + {multiSendTxs && <TxChecks disabled={submitDisabled} transaction={multiSendTxs} />} <TxCard> <ConfirmationTitle variant={ConfirmationTitleTypes.execute} /> diff --git a/apps/web/src/components/tx-flow/flows/ManagerSigners/ReviewSigners.tsx b/apps/web/src/components/tx-flow/flows/ManagerSigners/ReviewSigners.tsx new file mode 100644 index 0000000000..836403e29d --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/ManagerSigners/ReviewSigners.tsx @@ -0,0 +1,67 @@ +import { useContext, useEffect } from 'react' +import type { SafeTransaction } from '@safe-global/types-kit' +import type { ReactElement } from 'react' + +import useSafeInfo from '@/hooks/useSafeInfo' +import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' +import { SafeTxContext } from '../../SafeTxProvider' +import { getRecoveryProposalTransactions } from '@/features/recovery/services/transaction' +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' +import { TxFlowContext } from '../../TxFlowProvider' +import type { ManageSignersForm } from '.' +import type { TxFlowContextType } from '../../TxFlowProvider' +import type { ReviewTransactionContentProps } from '@/components/tx/ReviewTransactionV2/ReviewTransactionContent' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' +import { useAppDispatch } from '@/store' + +export function ReviewSigners({ onSubmit, ...props }: ReviewTransactionContentProps): ReactElement { + const { data } = useContext<TxFlowContextType<ManageSignersForm>>(TxFlowContext) + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) + const { safe } = useSafeInfo() + const dispatch = useAppDispatch() + + useEffect(() => { + if (!data) { + return + } + + const transactions = getRecoveryProposalTransactions({ + safe, + newThreshold: data.threshold, + newOwners: data.owners.map((owner) => ({ + value: owner.address, + })), + }) + + const createSafeTx = async (): Promise<SafeTransaction> => { + const isMultiSend = transactions.length > 1 + return isMultiSend ? createMultiSendCallOnlyTx(transactions) : createTx(transactions[0]) + } + + createSafeTx().then(setSafeTx).catch(setSafeTxError) + }, [data, safe, setSafeTx, setSafeTxError]) + + const addAddressBookEntry = () => { + if (!data) return + + // Add address book entries for new owners with names + data.owners + .filter((owner) => !!owner.name) + .forEach((owner) => { + dispatch( + upsertAddressBookEntries({ + chainIds: [safe.chainId], + address: owner.address, + name: owner.name, + }), + ) + }) + } + + const handleSubmit = () => { + addAddressBookEntry() + onSubmit() + } + + return <ReviewTransaction onSubmit={handleSubmit} {...props} /> +} diff --git a/apps/web/src/components/tx-flow/flows/ManagerSigners/SignersStructure.tsx b/apps/web/src/components/tx-flow/flows/ManagerSigners/SignersStructure.tsx new file mode 100644 index 0000000000..68f595abc6 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/ManagerSigners/SignersStructure.tsx @@ -0,0 +1,59 @@ +import { useContext } from 'react' +import { useForm, useFieldArray } from 'react-hook-form' +import type { ReactElement } from 'react' + +import useSafeInfo from '@/hooks/useSafeInfo' +import { SignersStructureView } from './SignersStructureView' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import { TxFlowContext } from '../../TxFlowProvider' +import { ManageSignersFormFields } from '.' +import type { ManageSignersForm } from '.' +import type { TxFlowContextType } from '../../TxFlowProvider' + +export function SignersStructure(): ReactElement { + const { data } = useContext<TxFlowContextType<ManageSignersForm>>(TxFlowContext) + const { safe } = useSafeInfo() + + const formMethods = useForm<ManageSignersForm>({ + defaultValues: data, + mode: 'onChange', + }) + const fieldArray = useFieldArray<ManageSignersForm>({ + control: formMethods.control, + name: ManageSignersFormFields.owners, + }) + + const newOwners = formMethods.watch(ManageSignersFormFields.owners) + const newThreshold = formMethods.watch(ManageSignersFormFields.threshold) + + const onAdd = () => { + fieldArray.append({ name: '', address: '' }, { shouldFocus: true }) + } + + const onRemove = (index: number) => { + fieldArray.remove(index) + // newOwners does not update immediately after removal + const newOwnersLength = newOwners.length - 1 + if (newThreshold > newOwnersLength) { + formMethods.setValue(ManageSignersFormFields.threshold, newOwnersLength) + } + } + + const isSameOwners = + newOwners.length === safe.owners.length && + newOwners.every((newOwner) => { + return safe.owners.some((currentOwner) => sameAddress(currentOwner.value, newOwner.address)) + }) + const isSameThreshold = safe.threshold === newThreshold + + return ( + <SignersStructureView + formMethods={formMethods} + fieldArray={fieldArray} + newOwners={newOwners} + onRemove={onRemove} + onAdd={onAdd} + isSameSetup={isSameOwners && isSameThreshold} + /> + ) +} diff --git a/apps/web/src/components/tx-flow/flows/ManagerSigners/SignersStructureView.tsx b/apps/web/src/components/tx-flow/flows/ManagerSigners/SignersStructureView.tsx new file mode 100644 index 0000000000..6a77aaa600 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/ManagerSigners/SignersStructureView.tsx @@ -0,0 +1,160 @@ +import { + Box, + Button, + CardActions, + Divider, + Grid, + MenuItem, + SvgIcon, + TextField, + Tooltip, + Typography, +} from '@mui/material' +import { Controller, FormProvider } from 'react-hook-form' +import { useContext } from 'react' +import type { ReactElement } from 'react' + +import AddIcon from '@/public/images/common/add.svg' +import InfoIcon from '@/public/images/notifications/info.svg' +import commonCss from '@/components/tx-flow/common/styles.module.css' +import TxCard from '../../common/TxCard' +import OwnerRow from '@/components/new-safe/OwnerRow' +import { maybePlural } from '@safe-global/utils/utils/formatters' +import { ManageSignersFormFields } from '.' +import { TxFlowContext } from '../../TxFlowProvider' +import { SETTINGS_EVENTS, SETTINGS_LABELS, trackEvent } from '@/services/analytics' +import Track from '@/components/common/Track' +import type { TxFlowContextType } from '../../TxFlowProvider' +import type { ManageSignersForm } from '.' +import type { UseFormReturn, UseFieldArrayReturn } from 'react-hook-form' + +type Props = { + formMethods: UseFormReturn<ManageSignersForm> + fieldArray: UseFieldArrayReturn<ManageSignersForm, 'owners'> + newOwners: ManageSignersForm['owners'] + isSameSetup: boolean + onRemove: (index: number) => void + onAdd: () => void +} + +export function SignersStructureView(props: Props): ReactElement { + const { onNext } = useContext<TxFlowContextType<ManageSignersForm>>(TxFlowContext) + + return ( + <TxCard> + <FormProvider {...props.formMethods}> + <form onSubmit={props.formMethods.handleSubmit(onNext)} className={commonCss.form}> + <Signers {...props} /> + + <Divider className={commonCss.nestedDivider} /> + + <Threshold {...props} /> + + <Divider className={commonCss.nestedDivider} /> + + <CardActions> + <Button + data-testId="submit-next" + variant="contained" + type="submit" + disabled={props.isSameSetup || !props.formMethods.formState.isValid} + > + Next + </Button> + </CardActions> + </form> + </FormProvider> + </TxCard> + ) +} + +function Signers({ + fieldArray, + onRemove: _onRemove, + onAdd, +}: Pick<Props, 'fieldArray' | 'onAdd' | 'onRemove'>): ReactElement { + const onRemove = (index: number) => { + _onRemove(index) + trackEvent({ ...SETTINGS_EVENTS.SETUP.REMOVE_OWNER, label: SETTINGS_LABELS.manage_signers }) + } + + return ( + <> + {fieldArray.fields.map((field, index) => ( + <OwnerRow + key={field.id} + index={index} + groupName={ManageSignersFormFields.owners} + removable={fieldArray.fields.length > 1} + remove={onRemove} + /> + ))} + + <Track {...SETTINGS_EVENTS.SETUP.ADD_OWNER} label={SETTINGS_LABELS.manage_signers}> + <Button + data-testid="add-new-signer" + variant="text" + onClick={onAdd} + startIcon={<SvgIcon component={AddIcon} inheritViewBox fontSize="small" />} + size="large" + sx={{ mt: -1, mb: 3 }} + > + Add new signer + </Button> + </Track> + </> + ) +} + +function Threshold({ formMethods, newOwners }: Pick<Props, 'formMethods' | 'newOwners'>): ReactElement { + return ( + <Box my={3}> + <Typography variant="h4" fontWeight={700} display="inline-flex" alignItems="center" gap={1}> + Threshold + <Tooltip + title="The threshold of a Safe Account specifies how many signers need to confirm a Safe Account transaction before it can be executed." + arrow + placement="top" + > + <span style={{ display: 'flex' }}> + <SvgIcon component={InfoIcon} inheritViewBox color="border" fontSize="small" /> + </span> + </Tooltip> + </Typography> + + <Typography variant="body2" mb={2}> + Any transaction requires the confirmation of: + </Typography> + + <Grid container direction="row" sx={{ alignItems: 'center', gap: 2, pt: 1 }}> + <Grid item> + <Controller + control={formMethods.control} + name="threshold" + render={({ field }) => { + const onChange = (event: React.ChangeEvent<HTMLInputElement>) => { + field.onChange(event) + trackEvent({ ...SETTINGS_EVENTS.SETUP.CHANGE_THRESHOLD, label: SETTINGS_LABELS.manage_signers }) + } + + return ( + <TextField select {...field} onChange={onChange}> + {newOwners.map((_, index) => ( + <MenuItem key={index + 1} value={index + 1}> + {index + 1} + </MenuItem> + ))} + </TextField> + ) + }} + /> + </Grid> + <Grid item> + <Typography> + out of {newOwners.length} signer{maybePlural(newOwners)} + </Typography> + </Grid> + </Grid> + </Box> + ) +} diff --git a/apps/web/src/components/tx-flow/flows/ManagerSigners/index.tsx b/apps/web/src/components/tx-flow/flows/ManagerSigners/index.tsx new file mode 100644 index 0000000000..ce84cfa918 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/ManagerSigners/index.tsx @@ -0,0 +1,51 @@ +import { useMemo } from 'react' +import type { ReactElement } from 'react' + +import SaveAddressIcon from '@/public/images/common/save-address.svg' +import useSafeInfo from '@/hooks/useSafeInfo' +import { SignersStructure } from './SignersStructure' +import { ReviewSigners } from './ReviewSigners' +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' +import { TxFlowStep } from '../../TxFlowStep' +import type { NamedAddress } from '@/components/new-safe/create/types' + +export enum ManageSignersFormFields { + threshold = 'threshold', + owners = 'owners', +} + +export type ManageSignersForm = { + [ManageSignersFormFields.threshold]: number + [ManageSignersFormFields.owners]: Array<NamedAddress> +} + +export function ManageSignersFlow(): ReactElement { + const { safe } = useSafeInfo() + + const defaultValues = useMemo(() => { + return { + [ManageSignersFormFields.threshold]: safe.threshold, + [ManageSignersFormFields.owners]: safe.owners.map((owner) => { + return { + address: owner.value, + name: '', + } + }), + } + }, [safe.threshold, safe.owners]) + + return ( + <TxFlow + icon={SaveAddressIcon} + subtitle="Manage signers" + ReviewTransactionComponent={ReviewSigners} + eventCategory={TxFlowType.SIGNERS_STRUCTURE} + initialData={defaultValues} + > + <TxFlowStep title="New transaction"> + <SignersStructure /> + </TxFlowStep> + </TxFlow> + ) +} diff --git a/apps/web/src/components/tx-flow/flows/MigrateSafeL2/MigrateSafeL2Review.tsx b/apps/web/src/components/tx-flow/flows/MigrateSafeL2/MigrateSafeL2Review.tsx new file mode 100644 index 0000000000..872246adeb --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/MigrateSafeL2/MigrateSafeL2Review.tsx @@ -0,0 +1,37 @@ +import { useContext, useEffect } from 'react' +import { useCurrentChain } from '@/hooks/useChains' +import { createTx } from '@/services/tx/tx-sender' +import { SafeTxContext } from '../../SafeTxProvider' +import { createMigrateToL2 } from '@/utils/safe-migrations' +import { Box, Typography } from '@mui/material' +import ErrorMessage from '@/components/tx/ErrorMessage' +import ReviewTransaction, { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' + +export const MigrateSafeL2Review = ({ children, ...props }: ReviewTransactionProps) => { + const chain = useCurrentChain() + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) + + useEffect(() => { + if (!chain) return + + const txData = createMigrateToL2(chain) + createTx(txData).then(setSafeTx).catch(setSafeTxError) + }, [chain, setSafeTx, setSafeTxError]) + + return ( + <Box> + <ReviewTransaction {...props}> + <ErrorMessage level="warning" title="Migration transaction"> + <Typography> + When executing this transaction, it will not get indexed and appear in the history due to the current + incompatible base contract. It might also take a few minutes until the new Safe Account version and nonce + are reflected in the interface. After the migration is complete, future transactions will get processed and + indexed as usual, and there will be no further restrictions. + </Typography> + </ErrorMessage> + + {children} + </ReviewTransaction> + </Box> + ) +} diff --git a/apps/web/src/components/tx-flow/flows/MigrateSafeL2/index.tsx b/apps/web/src/components/tx-flow/flows/MigrateSafeL2/index.tsx new file mode 100644 index 0000000000..f210c731dc --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/MigrateSafeL2/index.tsx @@ -0,0 +1,13 @@ +import { MigrateSafeL2Review } from './MigrateSafeL2Review' +import SettingsIcon from '@/public/images/sidebar/settings.svg' +import { TxFlow } from '../../TxFlow' + +const MigrateSafeL2Flow = () => ( + <TxFlow + icon={SettingsIcon} + subtitle="Update Safe Account base contract" + ReviewTransactionComponent={MigrateSafeL2Review} + /> +) + +export default MigrateSafeL2Flow diff --git a/apps/web/src/components/tx-flow/flows/NestedTxSuccessScreen/index.tsx b/apps/web/src/components/tx-flow/flows/NestedTxSuccessScreen/index.tsx index 0e7113fe5c..2ab32b3c14 100644 --- a/apps/web/src/components/tx-flow/flows/NestedTxSuccessScreen/index.tsx +++ b/apps/web/src/components/tx-flow/flows/NestedTxSuccessScreen/index.tsx @@ -14,7 +14,7 @@ import { useAppSelector } from '@/store' import ExternalLink from '@/components/common/ExternalLink' import { MODALS_EVENTS } from '@/services/analytics' import Track from '@/components/common/Track' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { getSafeTransaction } from '@/utils/transactions' import { isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards' diff --git a/apps/web/src/components/tx-flow/flows/NewSpendingLimit/CreateSpendingLimit.tsx b/apps/web/src/components/tx-flow/flows/NewSpendingLimit/CreateSpendingLimit.tsx index 4d99ecfa03..cbdbb6c2a9 100644 --- a/apps/web/src/components/tx-flow/flows/NewSpendingLimit/CreateSpendingLimit.tsx +++ b/apps/web/src/components/tx-flow/flows/NewSpendingLimit/CreateSpendingLimit.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useContext, useMemo } from 'react' import { Controller, FormProvider, useForm } from 'react-hook-form' import { Button, CardActions, FormControl, InputLabel, MenuItem, Select, Typography } from '@mui/material' import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded' @@ -13,32 +13,28 @@ import TxCard from '../../common/TxCard' import css from '@/components/tx/ExecuteCheckbox/styles.module.css' import TokenAmountInput from '@/components/common/TokenAmountInput' import { SpendingLimitFields } from '.' -import { validateAmount, validateDecimalLength } from '@/utils/validation' +import { validateAmount, validateDecimalLength } from '@safe-global/utils/utils/validation' +import { TxFlowContext, type TxFlowContextType } from '../../TxFlowProvider' -export const _validateSpendingLimit = (val: string, decimals?: number) => { - // Allowance amount is uint96 https://github.com/safe-global/safe-modules/blob/master/allowances/contracts/AlowanceModule.sol#L52 +export const _validateSpendingLimit = (val: string, decimals?: number | null) => { + // Allowance amount is uint96 https://github.com/safe-global/safe-modules/blob/main/modules/allowances/contracts/AllowanceModule.sol#L52 try { - const amount = parseUnits(val, decimals) + const amount = parseUnits(val, decimals ?? 'Gwei') AbiCoder.defaultAbiCoder().encode(['int96'], [amount]) } catch (e) { return Number(val) > 1 ? 'Amount is too big' : 'Amount is too small' } } -export const CreateSpendingLimit = ({ - params, - onSubmit, -}: { - params: NewSpendingLimitFlowProps - onSubmit: (data: NewSpendingLimitFlowProps) => void -}) => { +export const CreateSpendingLimit = () => { const chainId = useChainId() const { balances } = useVisibleBalances() + const { onNext, data } = useContext<TxFlowContextType<NewSpendingLimitFlowProps>>(TxFlowContext) const resetTimeOptions = useMemo(() => getResetTimeOptions(chainId), [chainId]) const formMethods = useForm<NewSpendingLimitFlowProps>({ - defaultValues: params, + defaultValues: data, mode: 'onChange', }) @@ -63,7 +59,7 @@ export const CreateSpendingLimit = ({ return ( <TxCard> <FormProvider {...formMethods}> - <form onSubmit={handleSubmit(onSubmit)}> + <form onSubmit={handleSubmit(onNext)}> <FormControl fullWidth sx={{ mb: 3 }}> <AddressBookInput data-testid="beneficiary-section" diff --git a/apps/web/src/components/tx-flow/flows/NewSpendingLimit/ReviewSpendingLimit.tsx b/apps/web/src/components/tx-flow/flows/NewSpendingLimit/ReviewSpendingLimit.tsx index 97e47d9443..3312d13fa9 100644 --- a/apps/web/src/components/tx-flow/flows/NewSpendingLimit/ReviewSpendingLimit.tsx +++ b/apps/web/src/components/tx-flow/flows/NewSpendingLimit/ReviewSpendingLimit.tsx @@ -2,49 +2,52 @@ import { useCurrentChain } from '@/hooks/useChains' import useSafeInfo from '@/hooks/useSafeInfo' import { useEffect, useMemo, useContext } from 'react' import { useSelector } from 'react-redux' -import { Typography, Grid, Alert } from '@mui/material' +import { Typography, Alert, Box } from '@mui/material' import SpendingLimitLabel from '@/components/common/SpendingLimitLabel' import { getResetTimeOptions } from '@/components/transactions/TxDetails/TxData/SpendingLimits' import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import useBalances from '@/hooks/useBalances' import useChainId from '@/hooks/useChainId' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' import { createNewSpendingLimitTx } from '@/services/tx/tx-sender' import { selectSpendingLimits } from '@/store/spendingLimitsSlice' -import { formatVisualAmount, safeParseUnits } from '@/utils/formatters' +import { formatVisualAmount, safeParseUnits } from '@safe-global/utils/utils/formatters' import type { NewSpendingLimitFlowProps } from '.' import EthHashInfo from '@/components/common/EthHashInfo' import { SafeTxContext } from '../../SafeTxProvider' +import ReviewTransaction, { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' +import { TxFlowContext, type TxFlowContextType } from '../../TxFlowProvider' +import TxDetailsRow from '@/components/tx/ConfirmTxDetails/TxDetailsRow' -export const ReviewSpendingLimit = ({ params }: { params: NewSpendingLimitFlowProps }) => { +export const ReviewSpendingLimit = ({ onSubmit, children }: ReviewTransactionProps) => { + const { data } = useContext<TxFlowContextType<NewSpendingLimitFlowProps>>(TxFlowContext) const spendingLimits = useSelector(selectSpendingLimits) const { safe } = useSafeInfo() const chainId = useChainId() const chain = useCurrentChain() const { balances } = useBalances() const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) - const token = balances.items.find((item) => item.tokenInfo.address === params.tokenAddress) + const token = balances.items.find((item) => item.tokenInfo.address === data?.tokenAddress) const { decimals } = token?.tokenInfo || {} const amountInWei = useMemo( - () => safeParseUnits(params.amount, token?.tokenInfo.decimals)?.toString() || '0', - [params.amount, token?.tokenInfo.decimals], + () => safeParseUnits(data?.amount || '0', token?.tokenInfo.decimals)?.toString() || '0', + [data?.amount, token?.tokenInfo.decimals], ) const existingSpendingLimit = useMemo(() => { return spendingLimits.find( (spendingLimit) => - spendingLimit.beneficiary === params.beneficiary && spendingLimit.token.address === params.tokenAddress, + spendingLimit.beneficiary === data?.beneficiary && spendingLimit.token.address === data?.tokenAddress, ) - }, [spendingLimits, params]) + }, [spendingLimits, data]) useEffect(() => { - if (!chain) return + if (!chain || !data) return createNewSpendingLimitTx( - params, + data, spendingLimits, chainId, chain, @@ -60,7 +63,7 @@ export const ReviewSpendingLimit = ({ params }: { params: NewSpendingLimitFlowPr chainId, decimals, existingSpendingLimit, - params, + data, safe.modules, safe.deployed, setSafeTx, @@ -68,18 +71,20 @@ export const ReviewSpendingLimit = ({ params }: { params: NewSpendingLimitFlowPr spendingLimits, ]) - const isOneTime = params.resetTime === '0' + const isOneTime = data?.resetTime === '0' const resetTime = useMemo(() => { return isOneTime ? 'One-time spending limit' - : getResetTimeOptions(chainId).find((time) => time.value === params.resetTime)?.label - }, [isOneTime, params.resetTime, chainId]) + : getResetTimeOptions(chainId).find((time) => time.value === data?.resetTime)?.label + }, [isOneTime, data?.resetTime, chainId]) const onFormSubmit = () => { trackEvent({ ...SETTINGS_EVENTS.SPENDING_LIMIT.RESET_PERIOD, label: resetTime, }) + + onSubmit() } const existingAmount = existingSpendingLimit @@ -91,10 +96,10 @@ export const ReviewSpendingLimit = ({ params }: { params: NewSpendingLimitFlowPr : undefined return ( - <SignOrExecuteForm onSubmit={onFormSubmit}> + <ReviewTransaction onSubmit={onFormSubmit} withDecodedData={false}> {token && ( <SendAmountBlock amountInWei={amountInWei} tokenInfo={token.tokenInfo} title="Amount"> - {existingAmount && existingAmount !== params.amount && ( + {existingAmount && existingAmount !== data?.amount && ( <> <Typography data-testid="old-token-amount" @@ -109,107 +114,64 @@ export const ReviewSpendingLimit = ({ params }: { params: NewSpendingLimitFlowPr )} </SendAmountBlock> )} - <Grid - container - sx={{ - gap: 1, - alignItems: 'center', - }} - > - <Grid item md> - <Typography - variant="body2" - sx={{ - color: 'text.secondary', - }} - > - Beneficiary - </Typography> - </Grid> - <Grid data-testid="beneficiary-address" item md={10}> + <TxDetailsRow label="Beneficiary" grid> + <Box data-testid="beneficiary-address"> <EthHashInfo - address={params.beneficiary} + address={data?.beneficiary || ''} shortAddress={false} hasExplorer showCopyButton showAvatar={false} /> - </Grid> - </Grid> - <Grid - container - sx={{ - gap: 1, - alignItems: 'center', - }} - > - <Grid item md> - <Typography - variant="body2" - sx={{ - color: 'text.secondary', - }} - > - Reset time - </Typography> - </Grid> - <Grid item md={10}> - {existingSpendingLimit ? ( - <> - <SpendingLimitLabel - label={ - <> - {existingSpendingLimit.resetTimeMin !== params.resetTime && ( - <> - <Typography - data-testid="old-reset-time" - color="error" - component="span" - sx={{ - display: 'inline', - textDecoration: 'line-through', - }} - > - {oldResetTime} - </Typography> - {' → '} - </> - )} - <Typography - component="span" - sx={{ - display: 'inline', - }} - > - {resetTime} - </Typography> - </> - } - isOneTime={existingSpendingLimit.resetTimeMin === '0'} - /> - </> - ) : ( + </Box> + </TxDetailsRow> + + <TxDetailsRow label="Reset time" grid> + {existingSpendingLimit ? ( + <> <SpendingLimitLabel - data-testid="spending-limit-label" - label={resetTime || 'One-time spending limit'} - isOneTime={!!resetTime && isOneTime} + label={ + <> + {existingSpendingLimit.resetTimeMin !== data?.resetTime && ( + <> + <Typography + data-testid="old-reset-time" + color="error" + component="span" + sx={{ + textDecoration: 'line-through', + }} + > + {oldResetTime} + </Typography> + {' → '} + </> + )} + <Typography component="span">{resetTime}</Typography> + </> + } + isOneTime={existingSpendingLimit.resetTimeMin === '0'} /> - )} - </Grid> - </Grid> + </> + ) : ( + <SpendingLimitLabel + data-testid="spending-limit-label" + label={resetTime || 'One-time spending limit'} + isOneTime={!!resetTime && isOneTime} + /> + )} + </TxDetailsRow> + {existingSpendingLimit && ( <Alert severity="warning" sx={{ border: 'unset' }}> - <Typography - data-testid="limit-replacement-warning" - sx={{ - fontWeight: 700, - }} - > + <Typography data-testid="limit-replacement-warning" fontWeight={700}> You are about to replace an existing spending limit </Typography> </Alert> )} - </SignOrExecuteForm> + + {children} + </ReviewTransaction> ) } diff --git a/apps/web/src/components/tx-flow/flows/NewSpendingLimit/__tests__/SpendingLimitForm.test.ts b/apps/web/src/components/tx-flow/flows/NewSpendingLimit/__tests__/SpendingLimitForm.test.ts index df35b74995..1dd87f4fe0 100644 --- a/apps/web/src/components/tx-flow/flows/NewSpendingLimit/__tests__/SpendingLimitForm.test.ts +++ b/apps/web/src/components/tx-flow/flows/NewSpendingLimit/__tests__/SpendingLimitForm.test.ts @@ -3,10 +3,10 @@ import { _validateSpendingLimit } from '../CreateSpendingLimit' describe('CreateSpendingLimit', () => { describe('validateSpendingLimit', () => { it('should return no error if the amount is valid', () => { - const result1 = _validateSpendingLimit('9999999999.999999999999999999') + const result1 = _validateSpendingLimit('9999999999.999999999999999999', 18) expect(result1).toBeUndefined() - const result2 = _validateSpendingLimit('0.000000000000000001') + const result2 = _validateSpendingLimit('0.000000000000000001', 18) expect(result2).toBeUndefined() }) diff --git a/apps/web/src/components/tx-flow/flows/NewSpendingLimit/index.tsx b/apps/web/src/components/tx-flow/flows/NewSpendingLimit/index.tsx index 007b24f183..808657d81a 100644 --- a/apps/web/src/components/tx-flow/flows/NewSpendingLimit/index.tsx +++ b/apps/web/src/components/tx-flow/flows/NewSpendingLimit/index.tsx @@ -1,10 +1,11 @@ -import TxLayout from '../../common/TxLayout' -import useTxStepper from '../../useTxStepper' import { CreateSpendingLimit } from './CreateSpendingLimit' import { ReviewSpendingLimit } from './ReviewSpendingLimit' import SaveAddressIcon from '@/public/images/common/save-address.svg' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { TokenAmountFields } from '@/components/common/TokenAmountInput' +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' +import { TxFlowStep } from '../../TxFlowStep' enum Fields { beneficiary = 'beneficiary', @@ -28,23 +29,18 @@ const defaultValues: NewSpendingLimitFlowProps = { } const NewSpendingLimitFlow = () => { - const { data, step, nextStep, prevStep } = useTxStepper<NewSpendingLimitFlowProps>(defaultValues) - - const steps = [ - <CreateSpendingLimit key={0} params={data} onSubmit={(formData) => nextStep({ ...data, ...formData })} />, - <ReviewSpendingLimit key={1} params={data} />, - ] - return ( - <TxLayout - title={step === 0 ? 'New transaction' : 'Confirm transaction'} - subtitle="Spending limit" + <TxFlow icon={SaveAddressIcon} - step={step} - onBack={prevStep} + subtitle="Spending limit" + ReviewTransactionComponent={ReviewSpendingLimit} + eventCategory={TxFlowType.SETUP_SPENDING_LIMIT} + initialData={defaultValues} > - {steps} - </TxLayout> + <TxFlowStep title="New transaction"> + <CreateSpendingLimit /> + </TxFlowStep> + </TxFlow> ) } diff --git a/apps/web/src/components/tx-flow/flows/NftTransfer/ReviewNftBatch.tsx b/apps/web/src/components/tx-flow/flows/NftTransfer/ReviewNftBatch.tsx index e62924459b..8391be22ba 100644 --- a/apps/web/src/components/tx-flow/flows/NftTransfer/ReviewNftBatch.tsx +++ b/apps/web/src/components/tx-flow/flows/NftTransfer/ReviewNftBatch.tsx @@ -1,69 +1,44 @@ import { type ReactElement, useEffect, useContext } from 'react' -import { Grid, Typography } from '@mui/material' import SendToBlock from '@/components/tx/SendToBlock' import { createNftTransferParams } from '@/services/tx/tokenTransferParams' import type { NftTransferParams } from '.' import useSafeAddress from '@/hooks/useSafeAddress' import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { SafeTxContext } from '../../SafeTxProvider' import { NftItems } from '@/components/tx-flow/flows/NftTransfer/SendNftBatch' - -type ReviewNftBatchProps = { - params: NftTransferParams - onSubmit: () => void - txNonce?: number -} - -const ReviewNftBatch = ({ params, onSubmit, txNonce }: ReviewNftBatchProps): ReactElement => { - const { setSafeTx, setSafeTxError, setNonce } = useContext(SafeTxContext) +import ReviewTransaction, { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' +import { maybePlural } from '@safe-global/utils/utils/formatters' +import FieldsGrid from '@/components/tx/FieldsGrid' +import { TxFlowContext, type TxFlowContextType } from '../../TxFlowProvider' + +const ReviewNftBatch = ({ onSubmit, children }: ReviewTransactionProps): ReactElement => { + const { data } = useContext<TxFlowContextType<NftTransferParams>>(TxFlowContext) + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) const safeAddress = useSafeAddress() - const { tokens } = params + const { tokens = [] } = data || {} useEffect(() => { - if (txNonce !== undefined) { - setNonce(txNonce) - } - }, [txNonce, setNonce]) + if (!safeAddress || !data) return - useEffect(() => { - if (!safeAddress) return - - const calls = params.tokens.map((token) => { - return createNftTransferParams(safeAddress, params.recipient, token.id, token.address) + const calls = tokens.map((token) => { + return createNftTransferParams(safeAddress, data.recipient, token.id, token.address) }) const promise = calls.length > 1 ? createMultiSendCallOnlyTx(calls) : createTx(calls[0]) promise.then(setSafeTx).catch(setSafeTxError) - }, [safeAddress, params, setSafeTx, setSafeTxError]) + }, [safeAddress, tokens, data, setSafeTx, setSafeTxError]) return ( - <SignOrExecuteForm onSubmit={onSubmit}> - <Grid - container - sx={{ - gap: 1, - mb: 2, - }} - > - <Grid item md> - <Typography - variant="body2" - sx={{ - color: 'text.secondary', - }} - > - Send - </Typography> - </Grid> + <ReviewTransaction onSubmit={onSubmit} withDecodedData={false}> + <SendToBlock address={data?.recipient || ''} /> + + <FieldsGrid title={`NFT${maybePlural(tokens)}`}> + <NftItems tokens={tokens} /> + </FieldsGrid> - <Grid item xs md={10}> - <NftItems tokens={tokens} /> - </Grid> - </Grid> - <SendToBlock address={params.recipient} /> - </SignOrExecuteForm> + {children} + </ReviewTransaction> ) } diff --git a/apps/web/src/components/tx-flow/flows/NftTransfer/SendNftBatch.tsx b/apps/web/src/components/tx-flow/flows/NftTransfer/SendNftBatch.tsx index ab04d6f907..c595c5661d 100644 --- a/apps/web/src/components/tx-flow/flows/NftTransfer/SendNftBatch.tsx +++ b/apps/web/src/components/tx-flow/flows/NftTransfer/SendNftBatch.tsx @@ -1,4 +1,4 @@ -import { Box, Button, CardActions, Divider, FormControl, Grid, SvgIcon, Typography } from '@mui/material' +import { Box, Button, CardActions, Divider, FormControl, Stack, SvgIcon, Typography } from '@mui/material' import { type SafeCollectibleResponse } from '@safe-global/safe-gateway-typescript-sdk' import { FormProvider, useForm } from 'react-hook-form' import NftIcon from '@/public/images/common/nft.svg' @@ -7,6 +7,8 @@ import type { NftTransferParams } from '.' import ImageFallback from '@/components/common/ImageFallback' import TxCard from '../../common/TxCard' import commonCss from '@/components/tx-flow/common/styles.module.css' +import { useContext } from 'react' +import { TxFlowContext, type TxFlowContextType } from '../../TxFlowProvider' enum Field { recipient = 'recipient', @@ -14,52 +16,26 @@ enum Field { type FormData = Pick<NftTransferParams, Field.recipient> -type SendNftBatchProps = { - onSubmit: (data: NftTransferParams) => void - params: NftTransferParams -} - const NftItem = ({ image, name, description }: { image: string; name: string; description?: string }) => ( - <Grid - container - wrap="nowrap" - sx={{ - gap: 2, - alignItems: 'center', - }} - > - <Grid item> - <Box - sx={{ - width: 40, - height: 40, - }} - > - <ImageFallback - src={image} - fallbackSrc="" - fallbackComponent={<SvgIcon component={NftIcon} inheritViewBox sx={{ width: 1, height: 1 }} />} - alt={name} - height={40} - /> - </Box> - </Grid> + <Stack direction="row" spacing={1} flexWrap="nowrap" alignItems="flex-start"> + <Box flex={0}> + <ImageFallback + src={image} + fallbackSrc="" + fallbackComponent={<SvgIcon component={NftIcon} inheritViewBox sx={{ width: 1, height: 1 }} />} + alt={name} + height={40} + /> + </Box> - <Grid - item - sx={{ - overflow: 'hidden', - }} - > + <Box flex={1} minWidth={0} maxWidth={{ xl: 'calc(100% - 200px)' }}> <Typography data-testid="nft-item-name" variant="body2" - sx={{ - fontWeight: 700, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }} + fontWeight={700} + whiteSpace="nowrap" + overflow="hidden" + textOverflow="ellipsis" > {name} </Typography> @@ -67,28 +43,24 @@ const NftItem = ({ image, name, description }: { image: string; name: string; de {description && ( <Typography variant="body2" - sx={{ - color: 'text.secondary', - whiteSpace: 'nowrap', - display: 'block', - overflow: 'hidden', - textOverflow: 'ellipsis', - }} + color="text.secondary" + display="block" + whiteSpace="nowrap" + overflow="hidden" + textOverflow="ellipsis" > {description} </Typography> )} - </Grid> - </Grid> + </Box> + </Stack> ) export const NftItems = ({ tokens }: { tokens: SafeCollectibleResponse[] }) => { return ( - <Box + <Stack data-testid="nft-item-list" sx={{ - display: 'flex', - flexDirection: 'column', gap: 2, overflow: 'auto', maxHeight: '20vh', @@ -103,16 +75,17 @@ export const NftItems = ({ tokens }: { tokens: SafeCollectibleResponse[] }) => { description={`Token ID: ${token.id}${token.name ? ` - ${token.name}` : ''}`} /> ))} - </Box> + </Stack> ) } -const SendNftBatch = ({ params, onSubmit }: SendNftBatchProps) => { - const { tokens } = params +const SendNftBatch = () => { + const { data, onNext } = useContext<TxFlowContextType<NftTransferParams>>(TxFlowContext) + const { tokens = [] } = data || {} const formMethods = useForm<FormData>({ defaultValues: { - [Field.recipient]: params.recipient, + [Field.recipient]: data?.recipient, }, }) const { @@ -125,7 +98,7 @@ const SendNftBatch = ({ params, onSubmit }: SendNftBatchProps) => { const isAddressValid = !!recipient && !errors[Field.recipient] const onFormSubmit = (data: FormData) => { - onSubmit({ + onNext({ recipient: data.recipient, tokens, }) diff --git a/apps/web/src/components/tx-flow/flows/NftTransfer/index.tsx b/apps/web/src/components/tx-flow/flows/NftTransfer/index.tsx index 308357118c..8d30a48622 100644 --- a/apps/web/src/components/tx-flow/flows/NftTransfer/index.tsx +++ b/apps/web/src/components/tx-flow/flows/NftTransfer/index.tsx @@ -1,47 +1,38 @@ import type { SafeCollectibleResponse } from '@safe-global/safe-gateway-typescript-sdk' import NftIcon from '@/public/images/common/nft.svg' -import TxLayout from '@/components/tx-flow/common/TxLayout' -import useTxStepper from '../../useTxStepper' import SendNftBatch from './SendNftBatch' import ReviewNftBatch from './ReviewNftBatch' +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' +import { TxFlowStep } from '../../TxFlowStep' export type NftTransferParams = { recipient: string tokens: SafeCollectibleResponse[] } -type NftTransferFlowProps = Partial<NftTransferParams> & { - txNonce?: number -} +type NftTransferFlowProps = Partial<NftTransferParams> const defaultParams: NftTransferParams = { recipient: '', tokens: [], } -const NftTransferFlow = ({ txNonce, ...params }: NftTransferFlowProps) => { - const { data, step, nextStep, prevStep } = useTxStepper<NftTransferParams>({ - ...defaultParams, - ...params, - }) - - const steps = [ - <SendNftBatch key={0} params={data} onSubmit={(formData) => nextStep({ ...data, ...formData })} />, - - <ReviewNftBatch key={1} params={data} txNonce={txNonce} onSubmit={() => null} />, - ] - - return ( - <TxLayout - title={step === 0 ? 'New transaction' : 'Confirm transaction'} - subtitle="Send NFTs" - icon={NftIcon} - step={step} - onBack={prevStep} - > - {steps} - </TxLayout> - ) -} +const NftTransferFlow = (params: NftTransferFlowProps) => ( + <TxFlow + initialData={{ + ...defaultParams, + ...params, + }} + icon={NftIcon} + subtitle="Send NFTs" + eventCategory={TxFlowType.NFT_TRANSFER} + ReviewTransactionComponent={ReviewNftBatch} + > + <TxFlowStep title="New transaction"> + <SendNftBatch /> + </TxFlowStep> + </TxFlow> +) export default NftTransferFlow diff --git a/apps/web/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx b/apps/web/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx index 45981e7224..fee0b3f686 100644 --- a/apps/web/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx +++ b/apps/web/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx @@ -6,10 +6,9 @@ import type { ReactElement } from 'react' import useSafeInfo from '@/hooks/useSafeInfo' import { getRecoveryProposalTransactions } from '@/features/recovery/services/transaction' -import DecodedTx from '@/components/tx/DecodedTx' import ErrorMessage from '@/components/tx/ErrorMessage' import ConfirmationTitle, { ConfirmationTitleTypes } from '@/components/tx/SignOrExecuteForm/ConfirmationTitle' -import TxChecks from '@/components/tx/SignOrExecuteForm/TxChecks' +import TxChecks from '@/components/tx-flow/features/TxChecks/TxChecks' import TxCard from '../../common/TxCard' import { SafeTxContext } from '../../SafeTxProvider' import CheckWallet from '@/components/common/CheckWallet' @@ -21,9 +20,9 @@ import { selectDelayModifierByRecoverer } from '@/features/recovery/services/sel import useWallet from '@/hooks/wallets/useWallet' import useOnboard from '@/hooks/wallets/useOnboard' import { TxModalContext } from '../..' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { trackError, Errors } from '@/services/exceptions' -import { getPeriod } from '@/utils/date' +import { getPeriod } from '@safe-global/utils/utils/date' import useRecovery from '@/features/recovery/hooks/useRecovery' import { useIsValidRecoveryExecTransactionFromModule } from '@/features/recovery/hooks/useIsValidRecoveryExecution' import type { RecoverAccountFlowProps } from '.' @@ -36,6 +35,10 @@ import NetworkWarning from '@/components/new-safe/create/NetworkWarning' import { useGetTransactionDetailsQuery } from '@/store/api/gateway' import { skipToken } from '@reduxjs/toolkit/query' import useTxPreview from '@/components/tx/confirmation-views/useTxPreview' +import Summary from '@/components/transactions/TxDetails/Summary' +import useGasPrice from '@/hooks/useGasPrice' +import { useCurrentChain } from '@/hooks/useChains' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlowProps }): ReactElement | null { // Form state @@ -52,6 +55,8 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo const [data] = useRecovery() const recovery = data && selectDelayModifierByRecoverer(data, wallet?.address ?? '') const [, executionValidationError] = useIsValidRecoveryExecTransactionFromModule(recovery?.address, safeTx) + const [gasPrice] = useGasPrice() + const chain = useCurrentChain() const { data: txDetails } = useGetTransactionDetailsQuery(skipToken) const [txPreview] = useTxPreview(safeTx?.data, undefined, txDetails?.txId) @@ -74,7 +79,7 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo // On modal submit const onSubmit = async () => { - if (!recovery || !onboard || !wallet || !safeTx) { + if (!recovery || !onboard || !wallet || !safeTx || !gasPrice) { return } @@ -82,6 +87,14 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo setSubmitError(undefined) setIsRejectedByUser(false) + const isEIP1559 = chain && hasFeature(chain, FEATURES.EIP1559) + const overrides = isEIP1559 + ? { + maxFeePerGas: gasPrice?.maxFeePerGas?.toString(), + maxPriorityFeePerGas: gasPrice?.maxPriorityFeePerGas?.toString(), + } + : { gasPrice: gasPrice?.maxFeePerGas?.toString() } + try { await dispatchRecoveryProposal({ provider: wallet.provider, @@ -89,6 +102,7 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo safeTx, delayModifierAddress: recovery.address, signerAddress: wallet.address, + overrides, }) trackEvent({ ...RECOVERY_EVENTS.SUBMIT_RECOVERY_ATTEMPT }) } catch (_err) { @@ -131,12 +145,12 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo <Divider className={commonCss.nestedDivider} /> - <DecodedTx txDetails={txDetails} tx={safeTx} {...txPreview} /> + {txPreview && <Summary txDetails={txDetails} safeTxData={safeTx?.data} {...txPreview} />} <BlockaidBalanceChanges /> </TxCard> - <TxChecks executionOwner={safe.owners[0].value} /> + {safeTx && <TxChecks transaction={safeTx} executionOwner={safe.owners[0].value} />} <TxCard> <> diff --git a/apps/web/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx b/apps/web/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx index bc6865d513..49f0b81d9a 100644 --- a/apps/web/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx +++ b/apps/web/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx @@ -23,12 +23,12 @@ import AddressBookInput from '@/components/common/AddressBookInput' import { TOOLTIP_TITLES } from '../../common/constants' import InfoIcon from '@/public/images/notifications/info.svg' import useSafeInfo from '@/hooks/useSafeInfo' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import type { RecoverAccountFlowProps } from '.' -import type { AddressEx } from '@safe-global/safe-gateway-typescript-sdk' +import { type AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import commonCss from '@/components/tx-flow/common/styles.module.css' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' export function _isSameSetup({ oldOwners, @@ -36,9 +36,9 @@ export function _isSameSetup({ newOwners, newThreshold, }: { - oldOwners: Array<AddressEx> + oldOwners: Array<AddressInfo> oldThreshold: number - newOwners: Array<AddressEx> + newOwners: Array<AddressInfo> newThreshold: number }): boolean { if (oldThreshold !== newThreshold) { diff --git a/apps/web/src/components/tx-flow/flows/RecoverAccount/index.tsx b/apps/web/src/components/tx-flow/flows/RecoverAccount/index.tsx index 09ab544eb5..c1340ca94c 100644 --- a/apps/web/src/components/tx-flow/flows/RecoverAccount/index.tsx +++ b/apps/web/src/components/tx-flow/flows/RecoverAccount/index.tsx @@ -1,12 +1,11 @@ -import { START_RECOVERY_CATEGORY } from '@/services/analytics/events/recovery' import type { ReactElement } from 'react' import type { AddressEx } from '@safe-global/safe-gateway-typescript-sdk' - import TxLayout from '@/components/tx-flow/common/TxLayout' import SaveAddressIcon from '@/public/images/common/save-address.svg' import useTxStepper from '../../useTxStepper' import { RecoverAccountFlowReview } from './RecoverAccountFlowReview' import { RecoverAccountFlowSetup } from './RecoverAccountFlowSetup' +import { TxFlowType } from '@/services/analytics' export enum RecoverAccountFlowFields { owners = 'owners', @@ -25,7 +24,7 @@ function RecoverAccountFlow(): ReactElement { [RecoverAccountFlowFields.owners]: [{ value: '' }], [RecoverAccountFlowFields.threshold]: '1', }, - START_RECOVERY_CATEGORY, + TxFlowType.START_RECOVERY, ) const steps = [ diff --git a/apps/web/src/components/tx-flow/flows/RecoveryAttempt/RecoveryAttemptReview.tsx b/apps/web/src/components/tx-flow/flows/RecoveryAttempt/RecoveryAttemptReview.tsx index c684229203..2bde08a00d 100644 --- a/apps/web/src/components/tx-flow/flows/RecoveryAttempt/RecoveryAttemptReview.tsx +++ b/apps/web/src/components/tx-flow/flows/RecoveryAttempt/RecoveryAttemptReview.tsx @@ -12,10 +12,13 @@ import NetworkWarning from '@/components/new-safe/create/NetworkWarning' import { RecoveryValidationErrors } from '@/features/recovery/components/RecoveryValidationErrors' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' import { RecoveryDescription } from '@/features/recovery/components/RecoveryDescription' -import { useAsyncCallback } from '@/hooks/useAsync' +import { useAsyncCallback } from '@safe-global/utils/hooks/useAsync' import FieldsGrid from '@/components/tx/FieldsGrid' import EthHashInfo from '@/components/common/EthHashInfo' import { SafeTxContext } from '../../SafeTxProvider' +import useGasPrice from '@/hooks/useGasPrice' +import { useCurrentChain } from '@/hooks/useChains' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' type RecoveryAttemptReviewProps = { item: RecoveryQueueItem @@ -27,12 +30,22 @@ const RecoveryAttemptReview = ({ item }: RecoveryAttemptReviewProps) => { const { safe } = useSafeInfo() const { setTxFlow } = useContext(TxModalContext) const { setNonceNeeded } = useContext(SafeTxContext) + const [gasPrice] = useGasPrice() + const chain = useCurrentChain() const onFormSubmit = useCallback( async (e: SyntheticEvent) => { e.preventDefault() - if (!wallet) return + if (!wallet || !gasPrice) return + + const isEIP1559 = chain && hasFeature(chain, FEATURES.EIP1559) + const overrides = isEIP1559 + ? { + maxFeePerGas: gasPrice?.maxFeePerGas?.toString(), + maxPriorityFeePerGas: gasPrice?.maxPriorityFeePerGas?.toString(), + } + : { gasPrice: gasPrice?.maxFeePerGas?.toString() } try { await asyncCallback({ @@ -41,13 +54,14 @@ const RecoveryAttemptReview = ({ item }: RecoveryAttemptReviewProps) => { args: item.args, delayModifierAddress: item.address, signerAddress: wallet.address, + overrides, }) setTxFlow(undefined) } catch (err) { trackError(Errors._812, err) } }, - [asyncCallback, setTxFlow, wallet, safe, item.address, item.args], + [wallet, gasPrice, chain, asyncCallback, safe.chainId, item.args, item.address, setTxFlow], ) useEffect(() => { diff --git a/apps/web/src/components/tx-flow/flows/RejectTx/RejectTx.tsx b/apps/web/src/components/tx-flow/flows/RejectTx/RejectTx.tsx index 5e47cc5b50..211fe8ba15 100644 --- a/apps/web/src/components/tx-flow/flows/RejectTx/RejectTx.tsx +++ b/apps/web/src/components/tx-flow/flows/RejectTx/RejectTx.tsx @@ -1,25 +1,26 @@ import type { ReactElement } from 'react' import { Typography } from '@mui/material' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { createRejectTx } from '@/services/tx/tx-sender' import { useContext, useEffect } from 'react' import { SafeTxContext } from '../../SafeTxProvider' +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' +import type { ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' +import { TxFlowContext } from '../../TxFlowProvider' -type RejectTxProps = { - txNonce: number -} - -const RejectTx = ({ txNonce }: RejectTxProps): ReactElement => { +const RejectTx = ({ onSubmit, children }: ReviewTransactionProps): ReactElement => { + const { txNonce } = useContext(TxFlowContext) const { setSafeTx, setSafeTxError, setNonce } = useContext(SafeTxContext) useEffect(() => { + if (txNonce == undefined) return + setNonce(txNonce) createRejectTx(txNonce).then(setSafeTx).catch(setSafeTxError) }, [txNonce, setNonce, setSafeTx, setSafeTxError]) return ( - <SignOrExecuteForm isBatchable={false} isRejection> + <ReviewTransaction onSubmit={onSubmit}> <Typography mb={2}> To reject the transaction, a separate rejection transaction will be created to replace the original one. </Typography> @@ -31,7 +32,9 @@ const RejectTx = ({ txNonce }: RejectTxProps): ReactElement => { <Typography mb={2}> You will need to confirm the rejection transaction with your currently connected wallet. </Typography> - </SignOrExecuteForm> + + {children} + </ReviewTransaction> ) } diff --git a/apps/web/src/components/tx-flow/flows/RejectTx/index.tsx b/apps/web/src/components/tx-flow/flows/RejectTx/index.tsx index 6d6702c046..909c517945 100644 --- a/apps/web/src/components/tx-flow/flows/RejectTx/index.tsx +++ b/apps/web/src/components/tx-flow/flows/RejectTx/index.tsx @@ -1,17 +1,21 @@ -import type { ReactElement } from 'react' -import TxLayout from '../../common/TxLayout' +import { type ReactElement } from 'react' import RejectTx from './RejectTx' +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' type RejectTxProps = { txNonce: number } -const RejectTxFlow = ({ txNonce }: RejectTxProps): ReactElement => { - return ( - <TxLayout title="Confirm transaction" subtitle="Reject" step={0}> - <RejectTx txNonce={txNonce} /> - </TxLayout> - ) -} +const RejectTxFlow = ({ txNonce }: RejectTxProps): ReactElement => ( + <TxFlow + subtitle="Reject" + eventCategory={TxFlowType.REJECT_TX} + ReviewTransactionComponent={RejectTx} + isBatchable={false} + txNonce={txNonce} + isRejection + /> +) export default RejectTxFlow diff --git a/apps/web/src/components/tx-flow/flows/RemoveGuard/ReviewRemoveGuard.tsx b/apps/web/src/components/tx-flow/flows/RemoveGuard/ReviewRemoveGuard.tsx index 2b61d25a07..445fbbbeaf 100644 --- a/apps/web/src/components/tx-flow/flows/RemoveGuard/ReviewRemoveGuard.tsx +++ b/apps/web/src/components/tx-flow/flows/RemoveGuard/ReviewRemoveGuard.tsx @@ -1,18 +1,18 @@ -import { useContext, useEffect } from 'react' +import { useCallback, useContext, useEffect, type PropsWithChildren } from 'react' import { Typography } from '@mui/material' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import EthHashInfo from '@/components/common/EthHashInfo' import { Errors, logError } from '@/services/exceptions' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' import { createRemoveGuardTx } from '@/services/tx/tx-sender' import { type RemoveGuardFlowProps } from '.' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' -const onFormSubmit = () => { - trackEvent(SETTINGS_EVENTS.MODULES.REMOVE_GUARD) -} - -export const ReviewRemoveGuard = ({ params }: { params: RemoveGuardFlowProps }) => { +export const ReviewRemoveGuard = ({ + params, + onSubmit, + children, +}: PropsWithChildren<{ params: RemoveGuardFlowProps; onSubmit: () => void }>) => { const { setSafeTx, safeTxError, setSafeTxError } = useContext(SafeTxContext) useEffect(() => { @@ -25,9 +25,14 @@ export const ReviewRemoveGuard = ({ params }: { params: RemoveGuardFlowProps }) } }, [safeTxError]) + const onFormSubmit = useCallback(() => { + trackEvent(SETTINGS_EVENTS.MODULES.REMOVE_GUARD) + onSubmit() + }, [onSubmit]) + return ( - <SignOrExecuteForm onSubmit={onFormSubmit}> - <Typography sx={({ palette }) => ({ color: palette.primary.light })}>Transaction guard</Typography> + <ReviewTransaction onSubmit={onFormSubmit}> + <Typography color="primary.light">Transaction guard</Typography> <EthHashInfo address={params.address} showCopyButton hasExplorer shortAddress={false} /> @@ -35,6 +40,8 @@ export const ReviewRemoveGuard = ({ params }: { params: RemoveGuardFlowProps }) Once the transaction guard has been removed, checks by the transaction guard will not be conducted before or after any subsequent transactions. </Typography> - </SignOrExecuteForm> + + {children} + </ReviewTransaction> ) } diff --git a/apps/web/src/components/tx-flow/flows/RemoveGuard/index.tsx b/apps/web/src/components/tx-flow/flows/RemoveGuard/index.tsx index 2e96e6b471..d3369e8ea9 100644 --- a/apps/web/src/components/tx-flow/flows/RemoveGuard/index.tsx +++ b/apps/web/src/components/tx-flow/flows/RemoveGuard/index.tsx @@ -1,16 +1,27 @@ -import TxLayout from '@/components/tx-flow/common/TxLayout' -import { ReviewRemoveGuard } from '@/components/tx-flow/flows/RemoveGuard/ReviewRemoveGuard' +import { useContext } from 'react' +import { TxFlow } from '../../TxFlow' +import { TxFlowContext } from '../../TxFlowProvider' +import { TxFlowType } from '@/services/analytics' +import { ReviewRemoveGuard } from './ReviewRemoveGuard' +import { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' -// TODO: This can possibly be combined with the remove module type export type RemoveGuardFlowProps = { address: string } +const ReviewRemoveGuardStep = (props: ReviewTransactionProps) => { + const { data } = useContext(TxFlowContext) + return <ReviewRemoveGuard params={data} {...props} /> +} + const RemoveGuardFlow = ({ address }: RemoveGuardFlowProps) => { return ( - <TxLayout title="Confirm transaction" subtitle="Remove guard"> - <ReviewRemoveGuard params={{ address }} /> - </TxLayout> + <TxFlow + initialData={{ address }} + subtitle="Remove guard" + eventCategory={TxFlowType.REMOVE_GUARD} + ReviewTransactionComponent={ReviewRemoveGuardStep} + /> ) } diff --git a/apps/web/src/components/tx-flow/flows/RemoveModule/ReviewRemoveModule.tsx b/apps/web/src/components/tx-flow/flows/RemoveModule/ReviewRemoveModule.tsx index e45e50994d..0000e1958c 100644 --- a/apps/web/src/components/tx-flow/flows/RemoveModule/ReviewRemoveModule.tsx +++ b/apps/web/src/components/tx-flow/flows/RemoveModule/ReviewRemoveModule.tsx @@ -1,18 +1,18 @@ -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import { Grid, Typography } from '@mui/material' -import { useContext, useEffect } from 'react' +import { Typography } from '@mui/material' +import { useCallback, useContext, useEffect, type PropsWithChildren } from 'react' import { Errors, logError } from '@/services/exceptions' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' import { createRemoveModuleTx } from '@/services/tx/tx-sender' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import { type RemoveModuleFlowProps } from '.' import EthHashInfo from '@/components/common/EthHashInfo' +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' -const onFormSubmit = () => { - trackEvent(SETTINGS_EVENTS.MODULES.REMOVE_MODULE) -} - -export const ReviewRemoveModule = ({ params }: { params: RemoveModuleFlowProps }) => { +export const ReviewRemoveModule = ({ + params, + onSubmit, + children, +}: PropsWithChildren<{ params: RemoveModuleFlowProps; onSubmit: () => void }>) => { const { setSafeTx, safeTxError, setSafeTxError } = useContext(SafeTxContext) useEffect(() => { @@ -25,30 +25,23 @@ export const ReviewRemoveModule = ({ params }: { params: RemoveModuleFlowProps } } }, [safeTxError]) + const onFormSubmit = useCallback(() => { + trackEvent(SETTINGS_EVENTS.MODULES.REMOVE_MODULE) + onSubmit() + }, [onSubmit]) + return ( - <SignOrExecuteForm onSubmit={onFormSubmit}> - <Grid - container - sx={{ - gap: 1, - alignItems: 'center', - }} - > - <Grid item xs={2}> - Module - </Grid> - <Typography variant="body2" component="div"> - <EthHashInfo address={params.address} shortAddress={false} hasExplorer showCopyButton /> - </Typography> - </Grid> - <Typography - sx={{ - my: 2, - }} - > + <ReviewTransaction onSubmit={onFormSubmit}> + <Typography color="primary.light">Module</Typography> + + <EthHashInfo address={params.address} showCopyButton hasExplorer shortAddress={false} /> + + <Typography my={2}> 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 signers as well. </Typography> - </SignOrExecuteForm> + + {children} + </ReviewTransaction> ) } diff --git a/apps/web/src/components/tx-flow/flows/RemoveModule/index.tsx b/apps/web/src/components/tx-flow/flows/RemoveModule/index.tsx index 5332d8c0c5..c115070239 100644 --- a/apps/web/src/components/tx-flow/flows/RemoveModule/index.tsx +++ b/apps/web/src/components/tx-flow/flows/RemoveModule/index.tsx @@ -1,15 +1,27 @@ -import TxLayout from '@/components/tx-flow/common/TxLayout' +import { useContext } from 'react' +import { TxFlow } from '../../TxFlow' +import { TxFlowContext } from '../../TxFlowProvider' +import { TxFlowType } from '@/services/analytics' import { ReviewRemoveModule } from './ReviewRemoveModule' +import { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' export type RemoveModuleFlowProps = { address: string } +const ReviewRemoveModuleStep = (props: ReviewTransactionProps) => { + const { data } = useContext(TxFlowContext) + return <ReviewRemoveModule params={data} {...props} /> +} + const RemoveModuleFlow = ({ address }: RemoveModuleFlowProps) => { return ( - <TxLayout title="Confirm transaction" subtitle="Remove module"> - <ReviewRemoveModule params={{ address }} /> - </TxLayout> + <TxFlow + initialData={{ address }} + subtitle="Remove module" + eventCategory={TxFlowType.REMOVE_MODULE} + ReviewTransactionComponent={ReviewRemoveModuleStep} + /> ) } diff --git a/apps/web/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx b/apps/web/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx index 101eed8bc8..36199bb0ff 100644 --- a/apps/web/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx +++ b/apps/web/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx @@ -1,23 +1,21 @@ -import { useContext, useEffect } from 'react' -import { Typography, Divider, Box, Paper, SvgIcon } from '@mui/material' -import type { ReactElement } from 'react' +import { useCallback, useContext, useEffect } from 'react' +import type { ReactElement, PropsWithChildren } from 'react' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import useAddressBook from '@/hooks/useAddressBook' import useSafeInfo from '@/hooks/useSafeInfo' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' import { createRemoveOwnerTx } from '@/services/tx/tx-sender' -import MinusIcon from '@/public/images/common/minus.svg' import { SafeTxContext } from '../../SafeTxProvider' import type { RemoveOwnerFlowProps } from '.' -import EthHashInfo from '@/components/common/EthHashInfo' - -import commonCss from '@/components/tx-flow/common/styles.module.css' -import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' -import { maybePlural } from '@/utils/formatters' - -export const ReviewRemoveOwner = ({ params }: { params: RemoveOwnerFlowProps }): ReactElement => { - const addressBook = useAddressBook() +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' + +export const ReviewRemoveOwner = ({ + params, + onSubmit, + children, +}: PropsWithChildren<{ + params: RemoveOwnerFlowProps + onSubmit: () => void +}>): ReactElement => { const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) const { safe } = useSafeInfo() const { removedOwner, threshold } = params @@ -26,40 +24,11 @@ export const ReviewRemoveOwner = ({ params }: { params: RemoveOwnerFlowProps }): createRemoveOwnerTx({ ownerAddress: removedOwner.address, threshold }).then(setSafeTx).catch(setSafeTxError) }, [removedOwner.address, setSafeTx, setSafeTxError, threshold]) - const newOwnerLength = safe.owners.length - 1 - - const onFormSubmit = () => { + const onFormSubmit = useCallback(() => { trackEvent({ ...SETTINGS_EVENTS.SETUP.THRESHOLD, label: safe.threshold }) trackEvent({ ...SETTINGS_EVENTS.SETUP.OWNERS, label: safe.owners.length }) - } - - return ( - <SignOrExecuteForm onSubmit={onFormSubmit}> - <Paper sx={{ backgroundColor: ({ palette }) => palette.warning.background, p: 2 }}> - <Typography color="text.secondary" mb={2} display="flex" alignItems="center"> - <SvgIcon component={MinusIcon} inheritViewBox fontSize="small" sx={{ mr: 1 }} /> - Selected signer - </Typography> - <EthHashInfo - address={removedOwner.address} - name={addressBook[removedOwner.address]} - shortAddress={false} - showCopyButton - hasExplorer - /> - </Paper> - <ChangeSignerSetupWarning /> + onSubmit() + }, [onSubmit, safe.threshold, safe.owners]) - <Divider className={commonCss.nestedDivider} /> - <Box m={1}> - <Typography variant="body2" color="text.secondary" mb={0.5}> - Any transaction requires the confirmation of: - </Typography> - <Typography> - <b>{threshold}</b> out of <b>{newOwnerLength}</b> signer{maybePlural(newOwnerLength)} - </Typography> - </Box> - <Divider className={commonCss.nestedDivider} /> - </SignOrExecuteForm> - ) + return <ReviewTransaction onSubmit={onFormSubmit}>{children}</ReviewTransaction> } diff --git a/apps/web/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx b/apps/web/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx index 18de9819f6..32c8f97527 100644 --- a/apps/web/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx +++ b/apps/web/src/components/tx-flow/flows/RemoveOwner/SetThreshold.tsx @@ -11,7 +11,7 @@ import { TOOLTIP_TITLES } from '@/components/tx-flow/common/constants' import type { RemoveOwnerFlowProps } from '.' import commonCss from '@/components/tx-flow/common/styles.module.css' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' export const SetThreshold = ({ params, @@ -37,35 +37,16 @@ export const SetThreshold = ({ return ( <TxCard> <form onSubmit={onSubmitHandler}> - <Box - sx={{ - mb: 3, - }} - > - <Typography - sx={{ - mb: 2, - }} - > - Review the signer you want to remove from the active Safe Account: - </Typography> - {/* TODO: Update the EthHashInfo style from the replace owner PR */} + <Box mb={3}> + <Typography mb={2}>Review the signer you want to remove from the active Safe Account:</Typography> + <EthHashInfo address={params.removedOwner.address} shortAddress={false} showCopyButton hasExplorer /> </Box> <Divider className={commonCss.nestedDivider} /> - <Box - sx={{ - my: 3, - }} - > - <Typography - variant="h4" - sx={{ - fontWeight: 700, - }} - > + <Box my={3}> + <Typography variant="h4" fontWeight={700}> Threshold <Tooltip title={TOOLTIP_TITLES.THRESHOLD} arrow placement="top"> <span> diff --git a/apps/web/src/components/tx-flow/flows/RemoveOwner/index.tsx b/apps/web/src/components/tx-flow/flows/RemoveOwner/index.tsx index 1d450816ba..04b95150b8 100644 --- a/apps/web/src/components/tx-flow/flows/RemoveOwner/index.tsx +++ b/apps/web/src/components/tx-flow/flows/RemoveOwner/index.tsx @@ -1,9 +1,13 @@ -import TxLayout from '@/components/tx-flow/common/TxLayout' import useSafeInfo from '@/hooks/useSafeInfo' -import useTxStepper from '../../useTxStepper' import { ReviewRemoveOwner } from './ReviewRemoveOwner' import SaveAddressIcon from '@/public/images/common/save-address.svg' import { SetThreshold } from './SetThreshold' +import { useContext } from 'react' +import { TxFlowType } from '@/services/analytics' +import { TxFlowContext } from '../../TxFlowProvider' +import { TxFlow } from '../../TxFlow' +import { TxFlowStep } from '../../TxFlowStep' +import { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' type Owner = { address: string @@ -15,6 +19,16 @@ export type RemoveOwnerFlowProps = { threshold: number } +const SetThresholdStep = () => { + const { onNext, data } = useContext(TxFlowContext) + return <SetThreshold onSubmit={onNext} params={data} /> +} + +const ReviewOwnerStep = (props: ReviewTransactionProps) => { + const { data } = useContext(TxFlowContext) + return <ReviewRemoveOwner params={data} {...props} /> +} + const RemoveOwnerFlow = (props: Owner) => { const { safe } = useSafeInfo() @@ -23,23 +37,18 @@ const RemoveOwnerFlow = (props: Owner) => { threshold: Math.min(safe.threshold, safe.owners.length - 1), } - const { data, step, nextStep, prevStep } = useTxStepper<RemoveOwnerFlowProps>(defaultValues) - - const steps = [ - <SetThreshold key={0} params={data} onSubmit={(formData: any) => nextStep({ ...data, ...formData })} />, - <ReviewRemoveOwner key={1} params={data} />, - ] - return ( - <TxLayout - title={step === 0 ? 'New transaction' : 'Confirm transaction'} - subtitle="Remove signer" + <TxFlow + initialData={defaultValues} + eventCategory={TxFlowType.REMOVE_OWNER} icon={SaveAddressIcon} - step={step} - onBack={prevStep} + subtitle="Remove signer" + ReviewTransactionComponent={ReviewOwnerStep} > - {steps} - </TxLayout> + <TxFlowStep title="New transaction"> + <SetThresholdStep /> + </TxFlowStep> + </TxFlow> ) } diff --git a/apps/web/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowOverview.tsx b/apps/web/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowOverview.tsx index 94dd3951b4..0e07e1ecb9 100644 --- a/apps/web/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowOverview.tsx +++ b/apps/web/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowOverview.tsx @@ -1,16 +1,15 @@ import { Button, CardActions, Divider, Typography } from '@mui/material' -import type { ReactElement } from 'react' +import { useContext, type ReactElement } from 'react' import EthHashInfo from '@/components/common/EthHashInfo' import TxCard from '../../common/TxCard' import type { RecoveryFlowProps } from '.' import commonCss from '@/components/tx-flow/common/styles.module.css' +import { TxFlowContext } from '../../TxFlowProvider' -export function RemoveRecoveryFlowOverview({ - delayModifier, - onSubmit, -}: RecoveryFlowProps & { onSubmit: () => void }): ReactElement { +export function RemoveRecoveryFlowOverview({ delayModifier }: RecoveryFlowProps): ReactElement { + const { onNext } = useContext(TxFlowContext) return ( <TxCard> <Typography variant="body2"> @@ -42,7 +41,7 @@ export function RemoveRecoveryFlowOverview({ <Divider className={commonCss.nestedDivider} /> <CardActions sx={{ mt: '0 !important' }}> - <Button data-testid="next-btn" variant="contained" onClick={onSubmit}> + <Button data-testid="next-btn" variant="contained" onClick={onNext}> Next </Button> </CardActions> diff --git a/apps/web/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowReview.tsx b/apps/web/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowReview.tsx index 705e98f314..22d2d9f4af 100644 --- a/apps/web/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowReview.tsx +++ b/apps/web/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowReview.tsx @@ -1,28 +1,33 @@ import { trackEvent } from '@/services/analytics' import { RECOVERY_EVENTS } from '@/services/analytics/events/recovery' import { Typography } from '@mui/material' -import { useContext, useEffect } from 'react' -import type { ReactElement } from 'react' +import { useCallback, useContext, useEffect } from 'react' +import type { PropsWithChildren, ReactElement } from 'react' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { createRemoveModuleTx } from '@/services/tx/tx-sender' import { OwnerList } from '../../common/OwnerList' import { SafeTxContext } from '../../SafeTxProvider' import type { RecoveryFlowProps } from '.' +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' -const onSubmit = () => { - trackEvent({ ...RECOVERY_EVENTS.SUBMIT_RECOVERY_REMOVE }) -} - -export function RemoveRecoveryFlowReview({ delayModifier }: RecoveryFlowProps): ReactElement { +export function RemoveRecoveryFlowReview({ + delayModifier, + onSubmit, + children, +}: PropsWithChildren<RecoveryFlowProps & { onSubmit: () => void }>): ReactElement { const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) useEffect(() => { createRemoveModuleTx(delayModifier.address).then(setSafeTx).catch(setSafeTxError) }, [delayModifier.address, setSafeTx, setSafeTxError]) + const onFormSubmit = useCallback(() => { + trackEvent({ ...RECOVERY_EVENTS.SUBMIT_RECOVERY_REMOVE }) + onSubmit() + }, [onSubmit]) + return ( - <SignOrExecuteForm onSubmit={onSubmit}> + <ReviewTransaction onSubmit={onFormSubmit}> <Typography> This transaction will remove the recovery module from your Safe Account. You will no longer be able to recover your Safe Account once this transaction is executed. @@ -33,6 +38,8 @@ export function RemoveRecoveryFlowReview({ delayModifier }: RecoveryFlowProps): owners={delayModifier.recoverers.map((recoverer) => ({ value: recoverer }))} sx={{ bgcolor: ({ palette }) => `${palette.warning.background} !important` }} /> - </SignOrExecuteForm> + + {children} + </ReviewTransaction> ) } diff --git a/apps/web/src/components/tx-flow/flows/RemoveRecovery/index.tsx b/apps/web/src/components/tx-flow/flows/RemoveRecovery/index.tsx index bc10145c1a..747683c486 100644 --- a/apps/web/src/components/tx-flow/flows/RemoveRecovery/index.tsx +++ b/apps/web/src/components/tx-flow/flows/RemoveRecovery/index.tsx @@ -1,35 +1,34 @@ -import { REMOVE_RECOVERY_CATEGORY } from '@/services/analytics/events/recovery' -import type { ReactElement } from 'react' - -import TxLayout from '@/components/tx-flow/common/TxLayout' +import { useCallback, type ReactElement } from 'react' import RecoveryPlus from '@/public/images/common/recovery-plus.svg' -import useTxStepper from '../../useTxStepper' import { RemoveRecoveryFlowOverview } from './RemoveRecoveryFlowOverview' import { RemoveRecoveryFlowReview } from './RemoveRecoveryFlowReview' import type { RecoveryStateItem } from '@/features/recovery/services/recovery-state' +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' +import { TxFlowStep } from '../../TxFlowStep' +import type { ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' export type RecoveryFlowProps = { delayModifier: RecoveryStateItem } function RemoveRecoveryFlow({ delayModifier }: RecoveryFlowProps): ReactElement { - const { step, nextStep, prevStep } = useTxStepper<undefined>(undefined, REMOVE_RECOVERY_CATEGORY) - - const steps = [ - <RemoveRecoveryFlowOverview key={0} delayModifier={delayModifier} onSubmit={() => nextStep(undefined)} />, - <RemoveRecoveryFlowReview key={1} delayModifier={delayModifier} />, - ] + const RemoveRecoveryReviewStep = useCallback( + (props: ReviewTransactionProps) => <RemoveRecoveryFlowReview delayModifier={delayModifier} {...props} />, + [delayModifier], + ) return ( - <TxLayout - title={step === 0 ? 'Remove Account recovery' : 'Confirm transaction'} - subtitle="Remove Recoverer" + <TxFlow + eventCategory={TxFlowType.REMOVE_RECOVERY} icon={RecoveryPlus} - step={step} - onBack={prevStep} + subtitle="Remove Recoverer" + ReviewTransactionComponent={RemoveRecoveryReviewStep} > - {steps} - </TxLayout> + <TxFlowStep title="Remove Account recovery"> + <RemoveRecoveryFlowOverview delayModifier={delayModifier} /> + </TxFlowStep> + </TxFlow> ) } diff --git a/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/RemoveSpendingLimit.tsx b/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/RemoveSpendingLimit.tsx deleted file mode 100644 index 4ca459a050..0000000000 --- a/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/RemoveSpendingLimit.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import { - getSpendingLimitInterface, - getDeployedSpendingLimitModuleAddress, -} from '@/services/contracts/spendingLimitContracts' -import useChainId from '@/hooks/useChainId' -import { useContext, useEffect } from 'react' -import { SafeTxContext } from '../../SafeTxProvider' -import EthHashInfo from '@/components/common/EthHashInfo' -import { Grid, Typography } from '@mui/material' -import type { SpendingLimitState } from '@/store/spendingLimitsSlice' -import { relativeTime } from '@/utils/date' -import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' -import useBalances from '@/hooks/useBalances' -import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock' -import SpendingLimitLabel from '@/components/common/SpendingLimitLabel' -import { createTx } from '@/services/tx/tx-sender' -import useSafeInfo from '@/hooks/useSafeInfo' - -const onFormSubmit = () => { - trackEvent(SETTINGS_EVENTS.SPENDING_LIMIT.LIMIT_REMOVED) -} - -export const RemoveSpendingLimit = ({ params }: { params: SpendingLimitState }) => { - const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) - const chainId = useChainId() - const { safe } = useSafeInfo() - const { balances } = useBalances() - const token = balances.items.find((item) => item.tokenInfo.address === params.token.address) - - const amountInWei = params.amount - - useEffect(() => { - if (!safe.modules?.length) return - - const spendingLimitAddress = getDeployedSpendingLimitModuleAddress(chainId, safe.modules) - if (!spendingLimitAddress) return - - const spendingLimitInterface = getSpendingLimitInterface() - const txData = spendingLimitInterface.encodeFunctionData('deleteAllowance', [ - params.beneficiary, - params.token.address, - ]) - - const txParams = { - to: spendingLimitAddress, - value: '0', - data: txData, - } - - createTx(txParams).then(setSafeTx).catch(setSafeTxError) - }, [chainId, params.beneficiary, params.token, setSafeTx, setSafeTxError, safe.modules]) - - return ( - <SignOrExecuteForm onSubmit={onFormSubmit}> - {token && <SendAmountBlock amountInWei={amountInWei} tokenInfo={token.tokenInfo} title="Amount" />} - <Grid - container - sx={{ - gap: 1, - alignItems: 'center', - }} - > - <Grid item md> - <Typography - variant="body2" - sx={{ - color: 'text.secondary', - }} - > - Beneficiary - </Typography> - </Grid> - <Grid item md={10}> - <EthHashInfo - address={params.beneficiary} - showCopyButton - hasExplorer - shortAddress={false} - showAvatar={false} - /> - </Grid> - </Grid> - <Grid - container - sx={{ - gap: 1, - alignItems: 'center', - }} - > - <Grid item md> - <Typography - variant="body2" - sx={{ - color: 'text.secondary', - }} - > - Reset time - </Typography> - </Grid> - <Grid item md={10}> - <SpendingLimitLabel - label={relativeTime(params.lastResetMin, params.resetTimeMin)} - isOneTime={params.resetTimeMin === '0'} - /> - </Grid> - </Grid> - </SignOrExecuteForm> - ) -} diff --git a/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/RemoveSpendingLimitReview.tsx b/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/RemoveSpendingLimitReview.tsx new file mode 100644 index 0000000000..f08df4965a --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/RemoveSpendingLimitReview.tsx @@ -0,0 +1,53 @@ +import { + getSpendingLimitInterface, + getDeployedSpendingLimitModuleAddress, +} from '@/services/contracts/spendingLimitContracts' +import useChainId from '@/hooks/useChainId' +import { type PropsWithChildren, useCallback, useContext, useEffect } from 'react' +import { SafeTxContext } from '../../SafeTxProvider' +import type { SpendingLimitState } from '@/store/spendingLimitsSlice' +import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' +import { createTx } from '@/services/tx/tx-sender' +import useSafeInfo from '@/hooks/useSafeInfo' +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' + +export const RemoveSpendingLimitReview = ({ + params, + onSubmit, + children, +}: PropsWithChildren<{ + params: SpendingLimitState + onSubmit: () => void +}>) => { + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) + const chainId = useChainId() + const { safe } = useSafeInfo() + + useEffect(() => { + if (!safe.modules?.length) return + + const spendingLimitAddress = getDeployedSpendingLimitModuleAddress(chainId, safe.modules) + if (!spendingLimitAddress) return + + const spendingLimitInterface = getSpendingLimitInterface() + const txData = spendingLimitInterface.encodeFunctionData('deleteAllowance', [ + params.beneficiary, + params.token.address, + ]) + + const txParams = { + to: spendingLimitAddress, + value: '0', + data: txData, + } + + createTx(txParams).then(setSafeTx).catch(setSafeTxError) + }, [chainId, params.beneficiary, params.token, setSafeTx, setSafeTxError, safe.modules]) + + const onFormSubmit = useCallback(() => { + trackEvent(SETTINGS_EVENTS.SPENDING_LIMIT.LIMIT_REMOVED) + onSubmit() + }, [onSubmit]) + + return <ReviewTransaction onSubmit={onFormSubmit}>{children}</ReviewTransaction> +} diff --git a/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/index.tsx b/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/index.tsx index 64f377e3e0..44931ae5f9 100644 --- a/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/index.tsx +++ b/apps/web/src/components/tx-flow/flows/RemoveSpendingLimit/index.tsx @@ -1,13 +1,27 @@ -import TxLayout from '@/components/tx-flow/common/TxLayout' -import { RemoveSpendingLimit } from './RemoveSpendingLimit' +import { RemoveSpendingLimitReview } from 'src/components/tx-flow/flows/RemoveSpendingLimit/RemoveSpendingLimitReview' import type { SpendingLimitState } from '@/store/spendingLimitsSlice' import SaveAddressIcon from '@/public/images/common/save-address.svg' +import { useMemo } from 'react' +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' +import type ReviewTransaction from '@/components/tx/ReviewTransactionV2' const RemoveSpendingLimitFlow = ({ spendingLimit }: { spendingLimit: SpendingLimitState }) => { + const ReviewTransactionComponent = useMemo<typeof ReviewTransaction>( + () => + function ReviewRemoveSpendingLimit(props) { + return <RemoveSpendingLimitReview params={spendingLimit} {...props} /> + }, + [spendingLimit], + ) + return ( - <TxLayout title="Confirm transaction" subtitle="Remove spending limit" icon={SaveAddressIcon}> - <RemoveSpendingLimit params={spendingLimit} /> - </TxLayout> + <TxFlow + subtitle="Remove spending limit" + eventCategory={TxFlowType.REMOVE_SPENDING_LIMIT} + icon={SaveAddressIcon} + ReviewTransactionComponent={ReviewTransactionComponent} + /> ) } diff --git a/apps/web/src/components/tx-flow/flows/ReplaceOwner/index.tsx b/apps/web/src/components/tx-flow/flows/ReplaceOwner/index.tsx index ecc00f9efc..423bd2eb00 100644 --- a/apps/web/src/components/tx-flow/flows/ReplaceOwner/index.tsx +++ b/apps/web/src/components/tx-flow/flows/ReplaceOwner/index.tsx @@ -1,9 +1,13 @@ -import TxLayout from '@/components/tx-flow/common/TxLayout' -import useTxStepper from '@/components/tx-flow/useTxStepper' -import useSafeInfo from '@/hooks/useSafeInfo' -import { ReviewOwner } from '../AddOwner/ReviewOwner' -import { ChooseOwner, ChooseOwnerMode } from '../AddOwner/ChooseOwner' +import { ChooseOwner, ChooseOwnerMode } from '@/components/tx-flow/flows/AddOwner/ChooseOwner' +import { ReviewOwner } from '@/components/tx-flow/flows/AddOwner/ReviewOwner' import SaveAddressIcon from '@/public/images/common/save-address.svg' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useContext } from 'react' +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' +import { TxFlowStep } from '../../TxFlowStep' +import { TxFlowContext } from '../../TxFlowProvider' +import { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' type Owner = { address: string @@ -16,37 +20,44 @@ export type ReplaceOwnerFlowProps = { threshold: number } +const ChooseOwnerStep = () => { + const { onNext, data } = useContext(TxFlowContext) + + return <ChooseOwner onSubmit={onNext} params={data} mode={ChooseOwnerMode.REPLACE} /> +} + +const ReviewOwnerStep = (props: ReviewTransactionProps) => { + const { data } = useContext(TxFlowContext) + + return <ReviewOwner params={data} {...props} /> +} + const ReplaceOwnerFlow = ({ address }: { address: string }) => { - const { safe } = useSafeInfo() + const { + safe: { threshold }, + safeLoaded, + } = useSafeInfo() const defaultValues: ReplaceOwnerFlowProps = { newOwner: { address: '' }, removedOwner: { address }, - threshold: safe.threshold, + threshold, } - const { data, step, nextStep, prevStep } = useTxStepper<ReplaceOwnerFlowProps>(defaultValues) - - const steps = [ - <ChooseOwner - key={0} - params={data} - onSubmit={(formData) => nextStep({ ...data, ...formData })} - mode={ChooseOwnerMode.REPLACE} - />, - <ReviewOwner key={1} params={data} />, - ] + if (!safeLoaded) return null return ( - <TxLayout - title={step === 0 ? 'New transaction' : 'Confirm transaction'} - subtitle="Replace signer" + <TxFlow + initialData={defaultValues} + eventCategory={TxFlowType.REPLACE_OWNER} icon={SaveAddressIcon} - step={step} - onBack={prevStep} + subtitle="Replace signer" + ReviewTransactionComponent={ReviewOwnerStep} > - {steps} - </TxLayout> + <TxFlowStep title="New transaction"> + <ChooseOwnerStep /> + </TxFlowStep> + </TxFlow> ) } diff --git a/apps/web/src/components/tx-flow/flows/ReplaceTx/index.tsx b/apps/web/src/components/tx-flow/flows/ReplaceTx/index.tsx index cfeb969081..11e6e7ff80 100644 --- a/apps/web/src/components/tx-flow/flows/ReplaceTx/index.tsx +++ b/apps/web/src/components/tx-flow/flows/ReplaceTx/index.tsx @@ -18,13 +18,13 @@ import DeleteTxModal from './DeleteTxModal' import ExternalLink from '@/components/common/ExternalLink' import ChoiceButton from '@/components/common/ChoiceButton' import useWallet from '@/hooks/wallets/useWallet' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { AppRoutes } from '@/config/routes' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' import Track from '@/components/common/Track' import { REJECT_TX_EVENTS } from '@/services/analytics/events/reject-tx' import { useRecommendedNonce } from '@/components/tx/SignOrExecuteForm/hooks' +import { FEATURES } from '@safe-global/utils/utils/chains' const goToQueue = (router: NextRouter) => { if (router.pathname === AppRoutes.transactions.tx) { diff --git a/apps/web/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx b/apps/web/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx index 72bdb5f471..9d6353372c 100644 --- a/apps/web/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx +++ b/apps/web/src/components/tx-flow/flows/SafeAppsTx/ReviewSafeAppsTx.tsx @@ -1,39 +1,31 @@ -import useWallet from '@/hooks/wallets/useWallet' -import { useContext, useEffect, useMemo } from 'react' +import { useContext, useEffect } from 'react' import type { ReactElement } from 'react' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +import type { SafeTransaction } from '@safe-global/types-kit' import type { SafeAppsTxParams } from '.' -import { trackSafeAppTxCount } from '@/services/safe-apps/track-app-usage-count' -import { getTxOrigin } from '@/utils/transactions' -import { createMultiSendCallOnlyTx, createTx, dispatchSafeAppsTx } from '@/services/tx/tx-sender' -import useOnboard from '@/hooks/wallets/useOnboard' +import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import { isTxValid } from '@/components/safe-apps/utils' import ErrorMessage from '@/components/tx/ErrorMessage' -import { asError } from '@/services/exceptions/utils' +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' +import { type ReviewTransactionContentProps } from '@/components/tx/ReviewTransactionV2/ReviewTransactionContent' +import { getTxOrigin } from '@/utils/transactions' type ReviewSafeAppsTxProps = { safeAppsTx: SafeAppsTxParams - onSubmit?: (txId: string, safeTxHash: string) => void -} + onSubmit: () => void +} & ReviewTransactionContentProps const ReviewSafeAppsTx = ({ - safeAppsTx: { txs, requestId, params, appId, app }, + safeAppsTx: { txs, params, app }, onSubmit, + children, + ...props }: ReviewSafeAppsTxProps): ReactElement => { - const onboard = useOnboard() - const wallet = useWallet() - const { safeTx, setSafeTx, safeTxError, setSafeTxError, setTxOrigin } = useContext(SafeTxContext) + const { setSafeTx, safeTxError, setSafeTxError, setTxOrigin } = useContext(SafeTxContext) useHighlightHiddenTab() - useEffect(() => { - setTxOrigin(app?.url) - return () => setTxOrigin(undefined) - }, [setTxOrigin, app?.url]) - useEffect(() => { const createSafeTx = async (): Promise<SafeTransaction> => { const isMultiSend = txs.length > 1 @@ -48,35 +40,26 @@ const ReviewSafeAppsTx = ({ return tx } - createSafeTx().then(setSafeTx).catch(setSafeTxError) - }, [txs, setSafeTx, setSafeTxError, params]) - - const handleSubmit = async (txId: string) => { - if (!safeTx || !onboard || !wallet?.provider) return - trackSafeAppTxCount(Number(appId)) - - let safeTxHash = '' - try { - safeTxHash = await dispatchSafeAppsTx(safeTx, requestId, wallet.provider, txId) - } catch (error) { - setSafeTxError(asError(error)) - } - - onSubmit?.(txId, safeTxHash) - } + createSafeTx() + .then((tx) => { + setSafeTx(tx) + setTxOrigin(getTxOrigin(app)) + }) + .catch(setSafeTxError) + }, [txs, setSafeTx, setSafeTxError, setTxOrigin, app, params]) - const origin = useMemo(() => getTxOrigin(app), [app]) const error = !isTxValid(txs) return ( - <SignOrExecuteForm onSubmit={handleSubmit} origin={origin} showMethodCall> + <ReviewTransaction onSubmit={onSubmit} {...props}> {error ? ( <ErrorMessage error={safeTxError}> This Safe App initiated a transaction which cannot be processed. Please get in touch with the developer of this Safe App for more information. </ErrorMessage> ) : null} - </SignOrExecuteForm> + {children} + </ReviewTransaction> ) } diff --git a/apps/web/src/components/tx-flow/flows/SafeAppsTx/index.tsx b/apps/web/src/components/tx-flow/flows/SafeAppsTx/index.tsx index 6bb2476305..a672ecebf4 100644 --- a/apps/web/src/components/tx-flow/flows/SafeAppsTx/index.tsx +++ b/apps/web/src/components/tx-flow/flows/SafeAppsTx/index.tsx @@ -1,8 +1,13 @@ import type { BaseTransaction, RequestId, SendTransactionRequestParams } from '@safe-global/safe-apps-sdk' -import TxLayout from '@/components/tx-flow/common/TxLayout' import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' import ReviewSafeAppsTx from './ReviewSafeAppsTx' import { AppTitle } from '@/components/tx-flow/flows/SignMessage' +import { useCallback } from 'react' +import { type SubmitCallback, TxFlow } from '../../TxFlow' +import { type ReviewTransactionContentProps } from '@/components/tx/ReviewTransactionV2/ReviewTransactionContent' +import { dispatchSafeAppsTx } from '@/services/tx/tx-sender' +import { trackSafeAppTxCount } from '@/services/safe-apps/track-app-usage-count' +import { getSafeTxHashFromTxId } from '@/utils/transactions' export type SafeAppsTxParams = { appId?: string @@ -19,14 +24,38 @@ const SafeAppsTxFlow = ({ data: SafeAppsTxParams onSubmit?: (txId: string, safeTxHash: string) => void }) => { + const ReviewTransactionComponent = useCallback( + (props: ReviewTransactionContentProps) => { + return <ReviewSafeAppsTx safeAppsTx={data} {...props} /> + }, + [data], + ) + + const handleSubmit: SubmitCallback = useCallback( + (args) => { + if (!args || !args.txId) { + return + } + + const safeTxHash = getSafeTxHashFromTxId(args.txId) + + if (!safeTxHash) { + return + } + + trackSafeAppTxCount(Number(data.appId)) + dispatchSafeAppsTx({ safeAppRequestId: data.requestId, txId: args.txId, safeTxHash }) + onSubmit?.(args.txId, safeTxHash) + }, + [data.appId, data.requestId, onSubmit], + ) + return ( - <TxLayout - title="Confirm transaction" + <TxFlow + onSubmit={handleSubmit} subtitle={<AppTitle name={data.app?.name} logoUri={data.app?.iconUrl} txs={data.txs} />} - step={0} - > - <ReviewSafeAppsTx safeAppsTx={data} onSubmit={onSubmit} /> - </TxLayout> + ReviewTransactionComponent={ReviewTransactionComponent} + /> ) } diff --git a/apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx b/apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx index 9d57e86fa4..87d0e954f3 100644 --- a/apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx +++ b/apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.test.tsx @@ -1,9 +1,9 @@ +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import type Safe from '@safe-global/protocol-kit' import { act } from 'react' import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import { hexlify, zeroPadValue, toUtf8Bytes } from 'ethers' -import type { SafeInfo, SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' -import { SafeMessageListItemType } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import SignMessage from './SignMessage' import * as useIsWrongChainHook from '@/hooks/useIsWrongChain' @@ -18,7 +18,7 @@ import * as sdk from '@/hooks/coreSDK/safeCoreSDK' import { render, 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' +import { generateSafeMessageHash } from '@safe-global/utils/utils/safe-messages' import { getSafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import { chainBuilder } from '@/tests/builders/chains' @@ -39,16 +39,17 @@ const mockOnboardState = { label: 'Wallet 1', icon: '', provider: mockProvider, - chains: [{ id: '0x5' }], + chains: [{ id: '0x5', namespace: 'evm' }], accounts: [ { address: '0x1234567890123456789012345678901234567890', ens: null, + uns: null, balance: null, }, ], }, - ] as WalletState[], + ] as unknown as WalletState[], accountCenter: { enabled: true, }, @@ -228,7 +229,7 @@ describe('SignMessage', () => { const proposalSpy = jest.spyOn(sender, 'dispatchSafeMsgProposal').mockImplementation(() => Promise.resolve()) const mockMessageHash = '0x456' const msg = { - type: SafeMessageListItemType.MESSAGE, + type: 'MESSAGE', messageHash: mockMessageHash, confirmations: [ { @@ -239,7 +240,7 @@ describe('SignMessage', () => { ], confirmationsRequired: 2, confirmationsSubmitted: 1, - } as unknown as SafeMessage + } as unknown as MessageItem ;(getSafeMessage as jest.Mock).mockResolvedValue(msg) @@ -283,7 +284,7 @@ describe('SignMessage', () => { messageText, ) const msg = { - type: SafeMessageListItemType.MESSAGE, + type: 'MESSAGE', messageHash, confirmations: [ { @@ -294,7 +295,7 @@ describe('SignMessage', () => { ], confirmationsRequired: 2, confirmationsSubmitted: 1, - } as unknown as SafeMessage + } as unknown as MessageItem jest.spyOn(useSafeMessage, 'default').mockReturnValueOnce([msg, jest.fn, undefined]) @@ -326,7 +327,7 @@ describe('SignMessage', () => { confirmationsRequired: 2, confirmationsSubmitted: 2, preparedSignature: '0x789', - } as unknown as SafeMessage + } as unknown as MessageItem ;(getSafeMessage as jest.Mock).mockResolvedValue(newMsg) @@ -441,7 +442,7 @@ describe('SignMessage', () => { messageText, ) const msg = { - type: SafeMessageListItemType.MESSAGE, + type: 'MESSAGE', messageHash, confirmations: [ { @@ -452,7 +453,7 @@ describe('SignMessage', () => { ], confirmationsRequired: 2, confirmationsSubmitted: 1, - } as unknown as SafeMessage + } as unknown as MessageItem jest.spyOn(useSafeMessage, 'default').mockReturnValue([msg, jest.fn, undefined]) @@ -530,7 +531,7 @@ describe('SignMessage', () => { messageText, ) const msg = { - type: SafeMessageListItemType.MESSAGE, + type: 'MESSAGE', messageHash, confirmations: [ { @@ -541,7 +542,7 @@ describe('SignMessage', () => { ], confirmationsRequired: 2, confirmationsSubmitted: 1, - } as unknown as SafeMessage + } as unknown as MessageItem ;(getSafeMessage as jest.Mock).mockResolvedValue(msg) jest.spyOn(useSafeMessage, 'default').mockReturnValue([msg, jest.fn(), undefined]) @@ -594,7 +595,7 @@ describe('SignMessage', () => { messageText, ) const msg = { - type: SafeMessageListItemType.MESSAGE, + type: 'MESSAGE', messageHash, confirmations: [ { @@ -611,7 +612,7 @@ describe('SignMessage', () => { confirmationsRequired: 2, confirmationsSubmitted: 2, preparedSignature: '0x678', - } as unknown as SafeMessage + } as unknown as MessageItem jest.spyOn(useSafeMessage, 'default').mockReturnValue([msg, jest.fn(), undefined]) ;(getSafeMessage as jest.Mock).mockResolvedValue(msg) diff --git a/apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.tsx b/apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.tsx index 13879c8e1d..3f5473a7ad 100644 --- a/apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.tsx +++ b/apps/web/src/components/tx-flow/flows/SignMessage/SignMessage.tsx @@ -1,3 +1,4 @@ +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { Grid, Button, @@ -14,9 +15,7 @@ import { import { useTheme } from '@mui/material/styles' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import { useContext, useEffect } 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' import EthHashInfo from '@/components/common/EthHashInfo' import RequiredIcon from '@/public/images/messages/required.svg' @@ -43,7 +42,7 @@ import { TX_EVENTS, TX_TYPES } from '@/services/analytics/events/transactions' import { SafeTxContext } from '../../SafeTxProvider' import RiskConfirmationError from '@/components/tx/SignOrExecuteForm/RiskConfirmationError' import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext' -import { isBlindSigningPayload, isEIP712TypedData } from '@/utils/safe-messages' +import { isBlindSigningPayload, isEIP712TypedData } from '@safe-global/utils/utils/safe-messages' import ApprovalEditor from '@/components/tx/ApprovalEditor' import { ErrorBoundary } from '@sentry/react' import { isWalletRejection } from '@/utils/wallets' @@ -58,9 +57,9 @@ import { Blockaid } from '@/components/tx/security/blockaid' import CheckWallet from '@/components/common/CheckWallet' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' import { getDomainHash, getSafeMessageMessageHash } from '@/utils/safe-hashes' -import type { SafeVersion } from '@safe-global/safe-core-sdk-types' +import type { SafeVersion } from '@safe-global/types-kit' -const createSkeletonMessage = (confirmationsRequired: number): SafeMessage => { +const createSkeletonMessage = (confirmationsRequired: number): MessageItem => { return { confirmations: [], confirmationsRequired, @@ -74,8 +73,8 @@ const createSkeletonMessage = (confirmationsRequired: number): SafeMessage => { proposedBy: { value: '', }, - status: SafeMessageStatus.NEEDS_CONFIRMATION, - type: SafeMessageListItemType.MESSAGE, + status: 'NEEDS_CONFIRMATION', + type: 'MESSAGE', } } @@ -220,7 +219,7 @@ const BlindSigningWarning = ({ ) } -const SuccessCard = ({ safeMessage, onContinue }: { safeMessage: SafeMessage; onContinue: () => void }) => { +const SuccessCard = ({ safeMessage, onContinue }: { safeMessage: MessageItem; onContinue: () => void }) => { return ( <TxCard> <Typography @@ -242,7 +241,7 @@ const SuccessCard = ({ safeMessage, onContinue }: { safeMessage: SafeMessage; on ) } -type BaseProps = Pick<SafeMessage, 'logoUri' | 'name' | 'message'> +type BaseProps = Pick<MessageItem, 'logoUri' | 'name' | 'message'> export type SignMessageProps = BaseProps & { origin?: string diff --git a/apps/web/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx b/apps/web/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx index 59fbd945b0..db25d1ccd2 100644 --- a/apps/web/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx +++ b/apps/web/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.test.tsx @@ -2,7 +2,7 @@ import { Methods } from '@safe-global/safe-apps-sdk' import * as web3 from '@/hooks/wallets/web3' import * as useSafeInfo from '@/hooks/useSafeInfo' import { render, screen } from '@/tests/test-utils' -import * as execThroughRoleHooks from '@/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks' +import * as execThroughRoleHooks from '@/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/hooks' import type { TransactionPreview } from '@safe-global/safe-gateway-typescript-sdk' import { SafeAppAccessPolicyTypes } from '@safe-global/safe-gateway-typescript-sdk' import ReviewSignMessageOnChain from '@/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain' @@ -14,6 +14,7 @@ import type { SafeTxContextParams } from '../../SafeTxProvider' import { SafeTxContext } from '../../SafeTxProvider' import { createSafeTx } from '@/tests/builders/safeTx' import * as useTxPreviewHooks from '@/components/tx/confirmation-views/useTxPreview' +import { SlotProvider } from '../../slots' jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([]) describe('ReviewSignMessageOnChain', () => { @@ -21,7 +22,7 @@ describe('ReviewSignMessageOnChain', () => { jest.spyOn(useTxPreviewHooks, 'default').mockReturnValue([ { txInfo: {}, - txData: {}, + txData: { to: { value: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67' } }, } as TransactionPreview, undefined, false, @@ -46,66 +47,69 @@ describe('ReviewSignMessageOnChain', () => { } as SafeTxContextParams } > - <ReviewSignMessageOnChain - app={{ - id: 73, - url: 'https://app.com', - name: 'App', - iconUrl: 'https://app.com/icon.png', - description: 'App description', - chainIds: ['1'], - tags: [], - features: [], - socialProfiles: [], - developerWebsite: '', - accessControl: { - type: SafeAppAccessPolicyTypes.NoRestrictions, - }, - }} - requestId="73" - message={{ - types: { - Vote: [ - { - name: 'from', - type: 'address', - }, - { - name: 'space', - type: 'string', - }, - { - name: 'timestamp', - type: 'uint64', - }, - { - name: 'proposal', - type: 'bytes32', - }, - { - name: 'choice', - type: 'uint32', - }, - ], - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - ], - }, - domain: { - name: 'snapshot', - version: '0.1.4', - }, - message: { - from: '0x292bacf82268e143f5195af6928693699e31f911', - space: 'fabien.eth', - timestamp: '1663592967', - proposal: '0xbe992f0a433d2dbe2e0cee579e5e1bdb625cdcb3a14357ea990c6cdc3e129991', - choice: '1', - }, - }} - method={Methods.signTypedMessage} - /> + <SlotProvider> + <ReviewSignMessageOnChain + app={{ + id: 73, + url: 'https://app.com', + name: 'App', + iconUrl: 'https://app.com/icon.png', + description: 'App description', + chainIds: ['1'], + tags: [], + features: [], + socialProfiles: [], + developerWebsite: '', + accessControl: { + type: SafeAppAccessPolicyTypes.NoRestrictions, + }, + }} + requestId="73" + message={{ + types: { + Vote: [ + { + name: 'from', + type: 'address', + }, + { + name: 'space', + type: 'string', + }, + { + name: 'timestamp', + type: 'uint64', + }, + { + name: 'proposal', + type: 'bytes32', + }, + { + name: 'choice', + type: 'uint32', + }, + ], + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + ], + }, + domain: { + name: 'snapshot', + version: '0.1.4', + }, + message: { + from: '0x292bacf82268e143f5195af6928693699e31f911', + space: 'fabien.eth', + timestamp: '1663592967', + proposal: '0xbe992f0a433d2dbe2e0cee579e5e1bdb625cdcb3a14357ea990c6cdc3e129991', + choice: '1', + }, + }} + method={Methods.signTypedMessage} + onSubmit={() => {}} + /> + </SlotProvider> </SafeTxContext.Provider>, ) }) diff --git a/apps/web/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx b/apps/web/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx index 1cc055aa17..7f6a97bfe0 100644 --- a/apps/web/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx +++ b/apps/web/src/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain.tsx @@ -1,4 +1,3 @@ -import useWallet from '@/hooks/wallets/useWallet' import type { ReactElement } from 'react' import { useContext, useEffect, useState } from 'react' import { useMemo } from 'react' @@ -7,40 +6,36 @@ import { Box } from '@mui/system' import { Typography, SvgIcon } from '@mui/material' import WarningIcon from '@/public/images/notifications/warning.svg' import { type EIP712TypedData, Methods, type RequestId } from '@safe-global/safe-apps-sdk' -import { OperationType } from '@safe-global/safe-core-sdk-types' +import { OperationType } from '@safe-global/types-kit' import SendFromBlock from '@/components/tx/SendFromBlock' import { InfoDetails } from '@/components/transactions/InfoDetails' import EthHashInfo from '@/components/common/EthHashInfo' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' 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 { createTx } from '@/services/tx/tx-sender' import useSafeInfo from '@/hooks/useSafeInfo' import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab' import { type SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' -import { asError } from '@/services/exceptions/utils' -import { isEIP712TypedData } from '@/utils/safe-messages' +import { isEIP712TypedData } from '@safe-global/utils/utils/safe-messages' import ApprovalEditor from '@/components/tx/ApprovalEditor' import { ErrorBoundary } from '@sentry/react' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { HexEncodedData } from '@/components/transactions/HexEncodedData' +import ReviewTransaction, { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' export type SignMessageOnChainProps = { app?: SafeAppData requestId: RequestId message: string | EIP712TypedData method: Methods.signMessage | Methods.signTypedMessage -} +} & ReviewTransactionProps -const ReviewSignMessageOnChain = ({ message, method, requestId }: SignMessageOnChainProps): ReactElement => { +const ReviewSignMessageOnChain = ({ message, method, children, ...props }: SignMessageOnChainProps): ReactElement => { const { safe } = useSafeInfo() - const onboard = useOnboard() - const wallet = useWallet() const { safeTx, setSafeTx, setSafeTxError } = useContext(SafeTxContext) useHighlightHiddenTab() @@ -56,7 +51,7 @@ const ReviewSignMessageOnChain = ({ message, method, requestId }: SignMessageOnC useEffect(() => { if (!readOnlySignMessageLibContract) return - readOnlySignMessageLibContract.getAddress().then(setSignMessageAddress) + setSignMessageAddress(readOnlySignMessageLibContract.getAddress()) }, [readOnlySignMessageLibContract]) const [decodedMessage, readableMessage] = useMemo(() => { @@ -72,10 +67,12 @@ const ReviewSignMessageOnChain = ({ message, method, requestId }: SignMessageOnC useEffect(() => { let txData - if (!readOnlySignMessageLibContract) return + if (!readOnlySignMessageLibContract || !signMessageAddress) return if (isTextMessage) { - txData = readOnlySignMessageLibContract.encode('signMessage', [hashMessage(getDecodedMessage(message))]) + txData = readOnlySignMessageLibContract.encode('signMessage', [ + hashMessage(getDecodedMessage(message)) as `0x${string}`, + ]) } else if (isTypedMessage) { const typesCopy = { ...message.types } @@ -107,18 +104,8 @@ const ReviewSignMessageOnChain = ({ message, method, requestId }: SignMessageOnC signMessageAddress, ]) - const handleSubmit = async () => { - if (!safeTx || !onboard || !wallet) return - - try { - await dispatchSafeAppsTx(safeTx, requestId, wallet.provider) - } catch (error) { - setSafeTxError(asError(error)) - } - } - return ( - <SignOrExecuteForm onSubmit={handleSubmit}> + <ReviewTransaction {...props}> <SendFromBlock /> <InfoDetails title="Interact with SignMessageLib"> @@ -133,7 +120,7 @@ const ReviewSignMessageOnChain = ({ message, method, requestId }: SignMessageOnC {safeTx && ( <Box pb={1}> - <HexEncodedData title="Data (hex-encoded)" hexData={safeTx.data.data} /> + <HexEncodedData title="Data:" hexData={safeTx.data.data} /> </Box> )} @@ -152,7 +139,9 @@ const ReviewSignMessageOnChain = ({ message, method, requestId }: SignMessageOnC Signing a message with your Safe Account requires a transaction on the blockchain </Typography> </Box> - </SignOrExecuteForm> + + {children} + </ReviewTransaction> ) } diff --git a/apps/web/src/components/tx-flow/flows/SignMessageOnChain/index.tsx b/apps/web/src/components/tx-flow/flows/SignMessageOnChain/index.tsx index bcfc3c7672..d41917eecf 100644 --- a/apps/web/src/components/tx-flow/flows/SignMessageOnChain/index.tsx +++ b/apps/web/src/components/tx-flow/flows/SignMessageOnChain/index.tsx @@ -1,18 +1,46 @@ -import TxLayout from '@/components/tx-flow/common/TxLayout' import { AppTitle } from '@/components/tx-flow/flows/SignMessage' import ReviewSignMessageOnChain, { type SignMessageOnChainProps, } from '@/components/tx-flow/flows/SignMessageOnChain/ReviewSignMessageOnChain' +import { useCallback } from 'react' +import { TxFlowType } from '@/services/analytics' +import { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' +import { type SubmitCallback, TxFlow } from '../../TxFlow' +import { dispatchSafeAppsTx } from '@/services/tx/tx-sender' +import { getSafeTxHashFromTxId } from '@/utils/transactions' + +const SignMessageOnChainFlow = ({ props }: { props: Omit<SignMessageOnChainProps, 'onSubmit'> }) => { + const { requestId } = props + const ReviewComponent = useCallback( + (reviewTxProps: ReviewTransactionProps) => { + return <ReviewSignMessageOnChain {...props} {...reviewTxProps} /> + }, + [props], + ) + + const handleSubmit: SubmitCallback = useCallback( + async (args) => { + if (!args?.txId) { + return + } + const safeTxHash = getSafeTxHashFromTxId(args.txId) + + if (!safeTxHash) { + return + } + + await dispatchSafeAppsTx({ safeAppRequestId: requestId, safeTxHash, txId: args.txId }) + }, + [requestId], + ) -const SignMessageOnChainFlow = ({ props }: { props: SignMessageOnChainProps }) => { return ( - <TxLayout - title="Confirm message" + <TxFlow subtitle={<AppTitle name={props.app?.name} logoUri={props.app?.iconUrl} />} - step={0} - > - <ReviewSignMessageOnChain {...props} /> - </TxLayout> + eventCategory={TxFlowType.SIGN_MESSAGE_ON_CHAIN} + ReviewTransactionComponent={ReviewComponent} + onSubmit={handleSubmit} + /> ) } diff --git a/apps/web/src/components/tx-flow/flows/SuccessScreen/index.tsx b/apps/web/src/components/tx-flow/flows/SuccessScreen/index.tsx index b70605ac20..0649a98493 100644 --- a/apps/web/src/components/tx-flow/flows/SuccessScreen/index.tsx +++ b/apps/web/src/components/tx-flow/flows/SuccessScreen/index.tsx @@ -17,6 +17,10 @@ import { DefaultStatus } from '@/components/tx-flow/flows/SuccessScreen/statuses import { isSwapTransferOrderTxInfo } from '@/utils/transaction-guards' import { getTxLink } from '@/utils/tx-link' import useTxDetails from '@/hooks/useTxDetails' +import { usePredictSafeAddressFromTxDetails } from '@/hooks/usePredictSafeAddressFromTxDetails' +import { AppRoutes } from '@/config/routes' +import { NESTED_SAFE_EVENTS, NESTED_SAFE_LABELS } from '@/services/analytics/events/nested-safes' +import Track from '@/components/common/Track' interface Props { /** The ID assigned to the transaction in the client-gateway */ @@ -37,6 +41,7 @@ const SuccessScreen = ({ txId, txHash }: Props) => { const txLink = chain && txId && getTxLink(txId, chain, safeAddress) const [txDetails] = useTxDetails(txId) const isSwapOrder = txDetails && isSwapTransferOrderTxInfo(txDetails.txInfo) + const [predictedSafeAddress] = usePredictSafeAddressFromTxDetails(txDetails) useEffect(() => { if (!pendingTxHash) return @@ -66,13 +71,13 @@ const SuccessScreen = ({ txId, txHash }: Props) => { case PendingStatus.PROCESSING: case PendingStatus.RELAYING: // status can only have these values if txId & pendingTx are defined - StatusComponent = <ProcessingStatus txId={txId!} pendingTx={pendingTx!} /> + StatusComponent = <ProcessingStatus txId={txId!} pendingTx={pendingTx!} willDeploySafe={!!predictedSafeAddress} /> break case PendingStatus.INDEXING: - StatusComponent = <IndexingStatus /> + StatusComponent = <IndexingStatus willDeploySafe={!!predictedSafeAddress} /> break default: - StatusComponent = <DefaultStatus error={error} /> + StatusComponent = <DefaultStatus error={error} willDeploySafe={!!predictedSafeAddress} /> } return ( @@ -121,11 +126,30 @@ const SuccessScreen = ({ txId, txHash }: Props) => { </Link> )} - {!isSwapOrder && ( - <Button data-testid="finish-transaction-btn" variant="contained" size="small" onClick={onClose}> - Finish - </Button> - )} + {!isSwapOrder && + (predictedSafeAddress ? ( + <Track {...NESTED_SAFE_EVENTS.OPEN_NESTED_SAFE} label={NESTED_SAFE_LABELS.success_screen}> + <Link + href={{ pathname: AppRoutes.home, query: { safe: `${chain?.shortName}:${predictedSafeAddress}` } }} + passHref + legacyBehavior + > + <Button + data-testid="open-nested-safe-btn" + variant="contained" + size="small" + onClick={onClose} + disabled={!isSuccess} + > + Go to Nested Safe + </Button> + </Link> + </Track> + ) : ( + <Button data-testid="finish-transaction-btn" variant="contained" size="small" onClick={onClose}> + Finish + </Button> + ))} </div> </Container> ) diff --git a/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx b/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx index 3166e284a6..ae7763c38b 100644 --- a/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx +++ b/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx @@ -4,15 +4,17 @@ import css from '@/components/tx-flow/flows/SuccessScreen/styles.module.css' import { isTimeoutError } from '@/utils/ethers-utils' const TRANSACTION_FAILED = 'Transaction failed' +const NESTED_SAFE_SUCCESSFUL = 'Nested Safe was created' const TRANSACTION_SUCCESSFUL = 'Transaction was successful' type Props = { error: undefined | Error + willDeploySafe: boolean } -export const DefaultStatus = ({ error }: Props) => ( +export const DefaultStatus = ({ error, willDeploySafe: isCreatingSafe }: Props) => ( <Box px={3} mt={3}> <Typography data-testid="transaction-status" variant="h6" mt={2} fontWeight={700}> - {error ? TRANSACTION_FAILED : TRANSACTION_SUCCESSFUL} + {error ? TRANSACTION_FAILED : !isCreatingSafe ? TRANSACTION_SUCCESSFUL : NESTED_SAFE_SUCCESSFUL} </Typography> {error && ( <Box className={classNames(css.instructions, error ? css.errorBg : css.infoBg)}> diff --git a/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx b/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx index 7b98f3da14..da696ef718 100644 --- a/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx +++ b/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx @@ -2,10 +2,10 @@ import { Box, Typography } from '@mui/material' import classNames from 'classnames' import css from '@/components/tx-flow/flows/SuccessScreen/styles.module.css' -export const IndexingStatus = () => ( +export const IndexingStatus = ({ willDeploySafe: isCreatingSafe }: { willDeploySafe: boolean }) => ( <Box px={3} mt={3}> <Typography data-testid="transaction-status" variant="h6" mt={2} fontWeight={700}> - Transaction was processed + {!isCreatingSafe ? 'Transaction' : 'Nested Safe'} was processed </Typography> <Box className={classNames(css.instructions, css.infoBg)}> <Typography variant="body2"> It is now being indexed.</Typography> diff --git a/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx b/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx index 58a97a052f..4660a6a8d7 100644 --- a/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx +++ b/apps/web/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx @@ -6,14 +6,15 @@ import { PendingStatus, type PendingTx } from '@/store/pendingTxsSlice' type Props = { txId: string pendingTx: PendingTx + willDeploySafe: boolean } -export const ProcessingStatus = ({ txId, pendingTx }: Props) => ( +export const ProcessingStatus = ({ txId, pendingTx, willDeploySafe: isCreatingSafe }: Props) => ( <Box px={3} mt={3}> <Typography data-testid="transaction-status" variant="h6" mt={2} fontWeight={700}> - Transaction is now processing + {!isCreatingSafe ? 'Transaction is now processing' : 'Nested Safe is now being created'} </Typography> <Typography variant="body2" mb={3}> - The transaction was confirmed and is now being processed. + {!isCreatingSafe ? 'The transaction' : 'Your Nested Safe'} was confirmed and is now being processed. </Typography> <Box> {pendingTx.status === PendingStatus.PROCESSING && ( diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/CSVAirdropAppModal/index.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/CSVAirdropAppModal/index.tsx new file mode 100644 index 0000000000..f2d4dfcd55 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/CSVAirdropAppModal/index.tsx @@ -0,0 +1,55 @@ +import ModalDialog from '@/components/common/ModalDialog' +import { AppRoutes } from '@/config/routes' +import CSVAirdropLogo from '@/public/images/apps/csv-airdrop-app-logo.svg' +import { Button, DialogActions, DialogContent, Grid, Typography } from '@mui/material' +import Link from 'next/link' +import { useRouter } from 'next/router' +import type { ReactElement } from 'react' + +const CSVAirdropAppModal = ({ onClose, appUrl }: { onClose: () => void; appUrl?: string }): ReactElement => { + const router = useRouter() + + return ( + <ModalDialog + data-testid="csvairdrop-dialog" + open + onClose={onClose} + dialogTitle="Limit reached" + hideChainIndicator + maxWidth="xs" + > + <DialogContent sx={{ mt: 3, textAlign: 'center' }}> + <Grid> + <CSVAirdropLogo /> + <Typography fontWeight="bold" sx={{ mt: 2, mb: 2 }}> + Use CSV Airdrop + </Typography> + <Typography variant="body2"> + You've reached the limit of 5 recipients. To add more use CSV Airdrop, where you can simply upload you + CSV file and send to endless number of recipients. + </Typography> + </Grid> + </DialogContent> + {appUrl && ( + <DialogActions style={{ textAlign: 'center', display: 'block' }}> + <Link + href={{ + pathname: AppRoutes.apps.open, + query: { + safe: router.query.safe, + appUrl, + }, + }} + passHref + > + <Button variant="contained" data-testid="open-app-btn"> + Open CSV Airdrop + </Button> + </Link> + </DialogActions> + )} + </ModalDialog> + ) +} + +export default CSVAirdropAppModal diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx index 271cfd03d0..300e6b565e 100644 --- a/apps/web/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx @@ -1,19 +1,46 @@ -import { useTokenAmount, useVisibleTokens } from '@/components/tx-flow/flows/TokenTransfer/utils' -import { type ReactElement, useContext, useEffect } from 'react' +import { useVisibleTokens } from '@/components/tx-flow/flows/TokenTransfer/utils' +import { type ReactElement, useContext, useEffect, useMemo, useState } from 'react' import { type TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { FormProvider, useForm } from 'react-hook-form' -import { Button, CardActions, Divider, FormControl, Grid, Typography } from '@mui/material' +import { FormProvider, useFieldArray, useForm } from 'react-hook-form' +import { + Alert, + AlertTitle, + Box, + Button, + CardActions, + Divider, + Grid, + Link, + Stack, + SvgIcon, + Typography, +} from '@mui/material' import TokenIcon from '@/components/common/TokenIcon' -import AddressBookInput from '@/components/common/AddressBookInput' -import SpendingLimitRow from '@/components/tx-flow/flows/TokenTransfer/SpendingLimitRow' -import { TokenTransferFields, type TokenTransferParams, TokenTransferType } from '.' +import AddIcon from '@/public/images/common/add.svg' +import { + type MultiTokenTransferParams, + TokenTransferFields, + MultiTokenTransferFields, + TokenTransferType, + MultiTransfersFields, +} from '.' import TxCard from '../../common/TxCard' -import { formatVisualAmount } from '@/utils/formatters' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' import commonCss from '@/components/tx-flow/common/styles.module.css' -import TokenAmountInput from '@/components/common/TokenAmountInput' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import { useHasPermission } from '@/permissions/hooks/useHasPermission' -import { Permission } from '@/permissions/types' +import { Permission } from '@/permissions/config' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import RecipientRow from './RecipientRow' +import { SafeAppsName } from '@/config/constants' +import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' +import CSVAirdropAppModal from './CSVAirdropAppModal' +import { InsufficientFundsValidationError } from '@/components/common/TokenAmountInput' +import { useHasFeature } from '@/hooks/useChains' +import Track from '@/components/common/Track' +import { MODALS_EVENTS } from '@/services/analytics' +import { FEATURES } from '@safe-global/utils/utils/chains' +import { TxFlowContext, type TxFlowContextType } from '../../TxFlowProvider' export const AutocompleteItem = (item: { tokenInfo: TokenInfo; balance: string }): ReactElement => ( <Grid @@ -25,7 +52,7 @@ export const AutocompleteItem = (item: { tokenInfo: TokenInfo; balance: string } > <TokenIcon logoUri={item.tokenInfo.logoUri} key={item.tokenInfo.address} tokenSymbol={item.tokenInfo.symbol} /> - <Grid item xs> + <Grid item xs data-testid="token-item"> <Typography variant="body2" sx={{ @@ -42,20 +69,23 @@ export const AutocompleteItem = (item: { tokenInfo: TokenInfo; balance: string } </Grid> ) -export const CreateTokenTransfer = ({ - params, - onSubmit, - txNonce, -}: { - params: TokenTransferParams - onSubmit: (data: TokenTransferParams) => void +const MAX_RECIPIENTS = 5 + +export type CreateTokenTransferProps = { txNonce?: number -}): ReactElement => { +} + +export const CreateTokenTransfer = ({ txNonce }: CreateTokenTransferProps): ReactElement => { const disableSpendingLimit = txNonce !== undefined + const [csvAirdropModalOpen, setCsvAirdropModalOpen] = useState<boolean>(false) + const [maxRecipientsInfo, setMaxRecipientsInfo] = useState<boolean>(false) const canCreateStandardTx = useHasPermission(Permission.CreateTransaction) const canCreateSpendingLimitTx = useHasPermission(Permission.CreateSpendingLimitTransaction) const balancesItems = useVisibleTokens() - const { setNonce, setNonceNeeded } = useContext(SafeTxContext) + const { setNonce } = useContext(SafeTxContext) + const [safeApps] = useRemoteSafeApps({ name: SafeAppsName.CSV }) + const isMassPayoutsEnabled = useHasFeature(FEATURES.MASS_PAYOUTS) + const { onNext, data } = useContext(TxFlowContext) as TxFlowContextType<MultiTokenTransferParams> useEffect(() => { if (txNonce !== undefined) { @@ -63,71 +93,164 @@ export const CreateTokenTransfer = ({ } }, [setNonce, txNonce]) - const formMethods = useForm<TokenTransferParams>({ + const formMethods = useForm<MultiTokenTransferParams>({ defaultValues: { - ...params, - [TokenTransferFields.type]: disableSpendingLimit + ...data, + [MultiTransfersFields.type]: disableSpendingLimit ? TokenTransferType.multiSig : canCreateSpendingLimitTx && !canCreateStandardTx ? TokenTransferType.spendingLimit - : params.type, - [TokenTransferFields.tokenAddress]: - canCreateSpendingLimitTx && !canCreateStandardTx ? balancesItems[0]?.tokenInfo.address : params.tokenAddress, + : data?.type, + recipients: + data?.recipients.map(({ tokenAddress, ...rest }) => ({ + ...rest, + [TokenTransferFields.tokenAddress]: + canCreateSpendingLimitTx && !canCreateStandardTx ? balancesItems[0]?.tokenInfo.address : tokenAddress, + })) || [], }, mode: 'onChange', delayError: 500, }) + const { handleSubmit, control, watch, formState } = formMethods + + const hasInsufficientFunds = useMemo( + () => + !!formState.errors.recipients && + formState.errors.recipients.some?.((item) => item?.amount?.message === InsufficientFundsValidationError), + [formState], + ) + + const type = watch(MultiTransfersFields.type) + const { - handleSubmit, - watch, - formState: { errors }, - } = formMethods + fields: recipientFields, + append, + remove, + } = useFieldArray({ control, name: MultiTokenTransferFields.recipients }) - const recipient = watch(TokenTransferFields.recipient) - const tokenAddress = watch(TokenTransferFields.tokenAddress) - const type = watch(TokenTransferFields.type) + const canAddMoreRecipients = useMemo(() => recipientFields.length < MAX_RECIPIENTS, [recipientFields]) - const selectedToken = balancesItems.find((item) => item.tokenInfo.address === tokenAddress) - const { totalAmount, spendingLimitAmount } = useTokenAmount(selectedToken) + const addRecipient = (): void => { + if (!canAddMoreRecipients) { + setCsvAirdropModalOpen(true) + return + } - const canCreateSpendingLimitTxWithToken = useHasPermission(Permission.CreateSpendingLimitTransaction, { - token: selectedToken?.tokenInfo, - }) + if (recipientFields.length === 1) { + setMaxRecipientsInfo(true) + } - const isSpendingLimitType = type === TokenTransferType.spendingLimit + append({ + recipient: '', + tokenAddress: ZERO_ADDRESS, + amount: '', + }) + } - const maxAmount = isSpendingLimitType && totalAmount > spendingLimitAmount ? spendingLimitAmount : totalAmount + const removeRecipient = (index: number): void => { + if (recipientFields.length > 1) { + remove(index) + } + } - const isAddressValid = !!recipient && !errors[TokenTransferFields.recipient] + const csvAirdropAppUrl = safeApps?.[0]?.url - useEffect(() => { - setNonceNeeded(!isSpendingLimitType || spendingLimitAmount === 0n) - }, [setNonceNeeded, isSpendingLimitType, spendingLimitAmount]) + const CsvAirdropLink = () => ( + <Link sx={{ cursor: 'pointer' }} onClick={() => setCsvAirdropModalOpen(true)}> + CSV Airdrop + </Link> + ) + + const canBatch = isMassPayoutsEnabled && type === TokenTransferType.multiSig return ( <TxCard> <FormProvider {...formMethods}> - <form onSubmit={handleSubmit(onSubmit)} className={commonCss.form}> - <FormControl fullWidth sx={{ mt: 1 }}> - <AddressBookInput name={TokenTransferFields.recipient} canAdd={isAddressValid} /> - </FormControl> - - <TokenAmountInput balances={balancesItems} selectedToken={selectedToken} maxAmount={maxAmount} /> - - {!disableSpendingLimit && canCreateSpendingLimitTxWithToken && ( - <FormControl fullWidth sx={{ mt: 3 }}> - <SpendingLimitRow availableAmount={spendingLimitAmount} selectedToken={selectedToken?.tokenInfo} /> - </FormControl> - )} - - <Divider className={commonCss.nestedDivider} /> - - <CardActions> - <Button variant="contained" type="submit"> - Next - </Button> - </CardActions> + <form onSubmit={handleSubmit(onNext)} className={commonCss.form}> + <Stack spacing={3}> + <Stack spacing={8}> + {recipientFields.map((field, index) => ( + <RecipientRow + key={field.id} + removable={recipientFields.length > 1} + fieldArray={{ name: MultiTokenTransferFields.recipients, index }} + remove={removeRecipient} + disableSpendingLimit={disableSpendingLimit || recipientFields.length > 1} + /> + ))} + </Stack> + + {canBatch && ( + <> + <Stack direction="row" alignItems="center" justifyContent="space-between" mb={4}> + <Track {...MODALS_EVENTS.ADD_RECIPIENT}> + <Button + data-testid="add-recipient-btn" + variant="text" + onClick={addRecipient} + disabled={!canAddMoreRecipients} + startIcon={<SvgIcon component={AddIcon} inheritViewBox fontSize="small" />} + size="large" + > + Add recipient + </Button> + </Track> + <Typography + data-testid="recipients-count" + variant="body2" + color={canAddMoreRecipients ? 'primary' : 'error.main'} + >{`${recipientFields.length}/${MAX_RECIPIENTS}`}</Typography> + </Stack> + + {hasInsufficientFunds && ( + <Alert data-testid="insufficient-balance-error" severity="error"> + <AlertTitle>Insufficient balance</AlertTitle> + <Typography variant="body2"> + The total amount assigned to all recipients exceeds your available balance. Please adjust the + amounts you want to send. + </Typography> + </Alert> + )} + + {canAddMoreRecipients && maxRecipientsInfo && !!csvAirdropAppUrl && ( + <Alert severity="info" onClose={() => setMaxRecipientsInfo(false)}> + <Typography variant="body2"> + If you want to add more than {MAX_RECIPIENTS} recipients, use <CsvAirdropLink /> + </Typography> + </Alert> + )} + + {!canAddMoreRecipients && ( + <Alert data-testid="max-recipients-reached" severity="warning"> + <Typography variant="body2"> + No more recipients can be added. + {!!csvAirdropAppUrl && ( + <> + <br /> + Please use <CsvAirdropLink /> + </> + )} + </Typography> + </Alert> + )} + + {csvAirdropModalOpen && ( + <CSVAirdropAppModal onClose={() => setCsvAirdropModalOpen(false)} appUrl={csvAirdropAppUrl} /> + )} + </> + )} + + <Box> + <Divider className={commonCss.nestedDivider} /> + + <CardActions> + <Button variant="contained" type="submit"> + Next + </Button> + </CardActions> + </Box> + </Stack> </form> </FormProvider> </TxCard> diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/RecipientRow/index.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/RecipientRow/index.tsx new file mode 100644 index 0000000000..04e76fb108 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/RecipientRow/index.tsx @@ -0,0 +1,129 @@ +import AddressBookInput from '@/components/common/AddressBookInput' +import TokenAmountInput from '@/components/common/TokenAmountInput' +import { useVisibleBalances } from '@/hooks/useVisibleBalances' +import DeleteIcon from '@/public/images/common/delete.svg' +import { Box, Button, FormControl, Stack, SvgIcon } from '@mui/material' +import { get, useFormContext } from 'react-hook-form' +import type { FieldArrayPath, FieldPath } from 'react-hook-form' +import type { MultiTokenTransferParams, TokenTransferParams } from '..' +import { MultiTokenTransferFields, TokenTransferFields, TokenTransferType } from '..' +import { useTokenAmount } from '../utils' +import { useHasPermission } from '@/permissions/hooks/useHasPermission' +import { Permission } from '@/permissions/config' +import { useCallback, useContext, useEffect, useMemo } from 'react' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import SpendingLimitRow from '../SpendingLimitRow' +import { useSelector } from 'react-redux' +import { selectSpendingLimits } from '@/store/spendingLimitsSlice' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import Track from '@/components/common/Track' +import { MODALS_EVENTS } from '@/services/analytics' + +const getFieldName = ( + field: keyof TokenTransferParams, + { name, index }: RecipientRowProps['fieldArray'], +): FieldPath<MultiTokenTransferParams> => `${name}.${index}.${field}` + +type RecipientRowProps = { + disableSpendingLimit: boolean + fieldArray: { name: FieldArrayPath<MultiTokenTransferParams>; index: number } + removable?: boolean + remove?: (index: number) => void +} + +export const RecipientRow = ({ fieldArray, removable = true, remove, disableSpendingLimit }: RecipientRowProps) => { + const { balances } = useVisibleBalances() + const spendingLimits = useSelector(selectSpendingLimits) + + const { + formState: { errors }, + trigger, + watch, + } = useFormContext<MultiTokenTransferParams>() + + const { setNonceNeeded } = useContext(SafeTxContext) + + const recipientFieldName = getFieldName(TokenTransferFields.recipient, fieldArray) + + const type = watch(MultiTokenTransferFields.type) + const recipient = watch(recipientFieldName) + const tokenAddress = watch(getFieldName(TokenTransferFields.tokenAddress, fieldArray)) + + const selectedToken = balances.items.find((item) => sameAddress(item.tokenInfo.address, tokenAddress)) + + const { totalAmount, spendingLimitAmount } = useTokenAmount(selectedToken) + + const isAddressValid = !!recipient && !get(errors, recipientFieldName) + + const canCreateSpendingLimitTxWithToken = useHasPermission(Permission.CreateSpendingLimitTransaction, { + tokenAddress, + }) + + const isSpendingLimitType = type === TokenTransferType.spendingLimit + + const spendingLimitBalances = useMemo( + () => + balances.items.filter(({ tokenInfo }) => + spendingLimits.find((sl) => sameAddress(sl.token.address, tokenInfo.address)), + ), + [balances.items, spendingLimits], + ) + + const maxAmount = isSpendingLimitType && totalAmount > spendingLimitAmount ? spendingLimitAmount : totalAmount + + const onRemove = useCallback(() => { + remove?.(fieldArray.index) + trigger(MultiTokenTransferFields.recipients) + }, [remove, fieldArray.index, trigger]) + + useEffect(() => { + setNonceNeeded(!isSpendingLimitType || spendingLimitAmount === 0n) + }, [setNonceNeeded, isSpendingLimitType, spendingLimitAmount]) + + return ( + <> + <Stack spacing={1}> + <Stack spacing={2}> + <FormControl fullWidth> + <AddressBookInput name={recipientFieldName} canAdd={isAddressValid} /> + </FormControl> + + <FormControl fullWidth> + <TokenAmountInput + fieldArray={fieldArray} + balances={isSpendingLimitType ? spendingLimitBalances : balances.items} + selectedToken={selectedToken} + maxAmount={maxAmount} + deps={[MultiTokenTransferFields.recipients]} + /> + </FormControl> + + {!disableSpendingLimit && canCreateSpendingLimitTxWithToken && ( + <FormControl fullWidth> + <SpendingLimitRow availableAmount={spendingLimitAmount} selectedToken={selectedToken?.tokenInfo} /> + </FormControl> + )} + </Stack> + + {removable && ( + <Box> + <Track {...MODALS_EVENTS.REMOVE_RECIPIENT}> + <Button + data-testid="remove-recipient-btn" + onClick={onRemove} + aria-label="Remove recipient" + variant="text" + startIcon={<SvgIcon component={DeleteIcon} inheritViewBox fontSize="small" />} + size="compact" + > + Remove recipient + </Button> + </Track> + </Box> + )} + </Stack> + </> + ) +} + +export default RecipientRow diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewRecipientRow.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewRecipientRow.tsx new file mode 100644 index 0000000000..b56f6b4df5 --- /dev/null +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewRecipientRow.tsx @@ -0,0 +1,31 @@ +import { useMemo } from 'react' +import useBalances from '@/hooks/useBalances' +import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock' +import SendToBlock from '@/components/tx/SendToBlock' +import type { TokenTransferParams } from '.' +import { safeParseUnits } from '@safe-global/utils/utils/formatters' +import { Stack } from '@mui/material' +import { sameAddress } from '@safe-global/utils/utils/addresses' + +const ReviewRecipientRow = ({ params, name }: { params: TokenTransferParams; name: string }) => { + const { balances } = useBalances() + + const token = useMemo( + () => balances.items.find(({ tokenInfo }) => sameAddress(tokenInfo.address, params.tokenAddress)), + [balances.items, params.tokenAddress], + ) + + const amountInWei = useMemo( + () => safeParseUnits(params.amount, token?.tokenInfo.decimals)?.toString() || '0', + [params.amount, token?.tokenInfo.decimals], + ) + + return ( + <Stack gap={2}> + {token && <SendAmountBlock amountInWei={amountInWei} tokenInfo={token.tokenInfo} />} + <SendToBlock address={params.recipient} name={name} avatarSize={32} /> + </Stack> + ) +} + +export default ReviewRecipientRow diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx index 828d0cbe7a..df1f3e530b 100644 --- a/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx.tsx @@ -1,7 +1,7 @@ import useWallet from '@/hooks/wallets/useWallet' import type { ReactElement, SyntheticEvent } from 'react' import { useContext, useMemo, useState } from 'react' -import { type BigNumberish, type BytesLike, parseUnits } from 'ethers' +import { type BigNumberish, type BytesLike } from 'ethers' import { Button, CardActions, Typography } from '@mui/material' import SendToBlock from '@/components/tx/SendToBlock' import { type TokenTransferParams } from '@/components/tx-flow/flows/TokenTransfer/index' @@ -20,13 +20,13 @@ import { dispatchSpendingLimitTxExecution } from '@/services/tx/tx-sender' import { getTxOptions } from '@/utils/transactions' import { MODALS_EVENTS, trackEvent } from '@/services/analytics' import useOnboard from '@/hooks/wallets/useOnboard' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import TxCard from '@/components/tx-flow/common/TxCard' import { TxModalContext } from '@/components/tx-flow' -import { type SubmitCallback } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' +import { type SubmitCallback } from '@/components/tx/SignOrExecuteForm/SignOrExecuteFormV2' import { TX_EVENTS, TX_TYPES } from '@/services/analytics/events/transactions' import { isWalletRejection } from '@/utils/wallets' -import { safeParseUnits } from '@/utils/formatters' +import { safeParseUnits } from '@safe-global/utils/utils/formatters' import CheckWallet from '@/components/common/CheckWallet' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' @@ -70,20 +70,13 @@ const ReviewSpendingLimitTx = ({ safeAddress, token: spendingLimit?.token.address || ZERO_ADDRESS, to: params.recipient, - amount: parseUnits(params.amount, token?.tokenInfo.decimals).toString(), + amount: amountInWei, paymentToken: ZERO_ADDRESS, payment: 0, delegate: spendingLimit?.beneficiary || ZERO_ADDRESS, signature: EMPTY_DATA, }), - [ - params.amount, - params.recipient, - safeAddress, - spendingLimit?.beneficiary, - spendingLimit?.token, - token?.tokenInfo.decimals, - ], + [amountInWei, params.recipient, safeAddress, spendingLimit?.beneficiary, spendingLimit?.token], ) const { gasLimit, gasLimitLoading } = useSpendingLimitGas(txParams) diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx index 0c161bff10..507a629a55 100644 --- a/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer.tsx @@ -1,56 +1,75 @@ -import { useContext, useEffect, useMemo } from 'react' +import { type PropsWithChildren, useContext, useEffect, useMemo } from 'react' import useBalances from '@/hooks/useBalances' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import SendAmountBlock from '@/components/tx-flow/flows/TokenTransfer/SendAmountBlock' -import SendToBlock from '@/components/tx/SendToBlock' import { createTokenTransferParams } from '@/services/tx/tokenTransferParams' -import { createTx } from '@/services/tx/tx-sender' -import type { TokenTransferParams } from '.' +import { createMultiSendCallOnlyTx } from '@/services/tx/tx-sender' +import type { MultiTokenTransferParams } from '.' import { SafeTxContext } from '../../SafeTxProvider' -import { safeParseUnits } from '@/utils/formatters' -import type { SubmitCallback } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' +import type { MetaTransactionData } from '@safe-global/types-kit' +import { Divider, Stack } from '@mui/material' +import ReviewRecipientRow from './ReviewRecipientRow' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import ReviewTransaction from '@/components/tx/ReviewTransactionV2' const ReviewTokenTransfer = ({ params, onSubmit, txNonce, -}: { - params: TokenTransferParams - onSubmit: SubmitCallback + children, +}: PropsWithChildren<{ + params?: MultiTokenTransferParams + onSubmit: () => void txNonce?: number -}) => { - const { setSafeTx, setSafeTxError, setNonce } = useContext(SafeTxContext) +}>) => { + const { setSafeTx, setSafeTxError, setNonce, setIsMassPayout } = useContext(SafeTxContext) const { balances } = useBalances() - const token = balances.items.find((item) => item.tokenInfo.address === params.tokenAddress) - const amountInWei = useMemo( - () => safeParseUnits(params.amount, token?.tokenInfo.decimals)?.toString() || '0', - [params.amount, token?.tokenInfo.decimals], - ) + const recipients = useMemo(() => params?.recipients || [], [params?.recipients]) + + useEffect(() => { + setIsMassPayout(recipients.length > 1) + }, [recipients, setIsMassPayout]) useEffect(() => { if (txNonce !== undefined) { setNonce(txNonce) } - if (!token) return + const calls = recipients + .map((recipient) => { + const token = balances.items.find((item) => sameAddress(item.tokenInfo.address, recipient.tokenAddress)) - const txParams = createTokenTransferParams( - params.recipient, - params.amount, - token.tokenInfo.decimals, - token.tokenInfo.address, - ) + if (!token) return - createTx(txParams, txNonce).then(setSafeTx).catch(setSafeTxError) - }, [params, txNonce, token, setNonce, setSafeTx, setSafeTxError]) + return createTokenTransferParams( + recipient.recipient, + recipient.amount, + token?.tokenInfo.decimals, + recipient.tokenAddress, + ) + }) + .filter((transfer): transfer is MetaTransactionData => !!transfer) + + createMultiSendCallOnlyTx(calls).then(setSafeTx).catch(setSafeTxError) + }, [recipients, txNonce, setNonce, balances, setSafeTx, setSafeTxError]) return ( - <SignOrExecuteForm onSubmit={onSubmit}> - {token && <SendAmountBlock amountInWei={amountInWei} tokenInfo={token.tokenInfo} />} + <ReviewTransaction onSubmit={onSubmit}> + {recipients.length > 1 && ( + <Stack divider={<Divider />} gap={2}> + {recipients.map((recipient, index) => ( + <ReviewRecipientRow + params={recipient} + key={`${recipient.recipient}_${index}`} + name={`Recipient ${index + 1}`} + /> + ))} + </Stack> + )} + + {recipients.length > 1 && <Divider />} - <SendToBlock address={params.recipient} /> - </SignOrExecuteForm> + {children} + </ReviewTransaction> ) } diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx index da38fa4593..7557fca022 100644 --- a/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/ReviewTokenTx.tsx @@ -1,23 +1,18 @@ -import { type ReactElement } from 'react' -import { type TokenTransferParams, TokenTransferType } from '@/components/tx-flow/flows/TokenTransfer/index' +import { useContext, type ReactElement, type PropsWithChildren } from 'react' +import { type MultiTokenTransferParams, TokenTransferType } from '@/components/tx-flow/flows/TokenTransfer/index' import ReviewTokenTransfer from '@/components/tx-flow/flows/TokenTransfer/ReviewTokenTransfer' import ReviewSpendingLimitTx from '@/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx' +import { TxFlowContext, type TxFlowContextType } from '../../TxFlowProvider' -const ReviewTokenTx = ({ - params, - onSubmit, - txNonce, -}: { - params: TokenTransferParams - onSubmit: () => void - txNonce?: number -}): ReactElement => { - const isSpendingLimitTx = params.type === TokenTransferType.spendingLimit +const ReviewTokenTx = (props: PropsWithChildren<{ onSubmit: () => void; txNonce?: number }>): ReactElement => { + const { data } = useContext(TxFlowContext) as TxFlowContextType<MultiTokenTransferParams> + const isSpendingLimitTx = data?.type === TokenTransferType.spendingLimit - return isSpendingLimitTx ? ( - <ReviewSpendingLimitTx params={params} onSubmit={onSubmit} /> + return isSpendingLimitTx && data?.recipients.length === 1 ? ( + // TODO: Allow batched spending limit txs + <ReviewSpendingLimitTx params={data.recipients[0]} onSubmit={props.onSubmit} /> ) : ( - <ReviewTokenTransfer params={params} onSubmit={onSubmit} txNonce={txNonce} /> + <ReviewTokenTransfer params={data} {...props} /> ) } diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/SendAmountBlock.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/SendAmountBlock.tsx index 6cc22ea22b..ace4631255 100644 --- a/apps/web/src/components/tx-flow/flows/TokenTransfer/SendAmountBlock.tsx +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/SendAmountBlock.tsx @@ -3,13 +3,13 @@ import { type TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' import { Box, Typography } from '@mui/material' import TokenIcon from '@/components/common/TokenIcon' import FieldsGrid from '@/components/tx/FieldsGrid' -import { formatVisualAmount } from '@/utils/formatters' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' const SendAmountBlock = ({ amountInWei, tokenInfo, children, - title = 'Send:', + title = 'Send', }: { /** Amount in WEI */ amountInWei: number | string @@ -19,27 +19,17 @@ const SendAmountBlock = ({ }) => { return ( <FieldsGrid title={title}> - <Box - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1, - }} - > + <Box display="flex" alignItems="center" gap={1}> <TokenIcon logoUri={tokenInfo.logoUri} tokenSymbol={tokenInfo.symbol} /> - <Typography - sx={{ - fontWeight: 'bold', - }} - > + <Typography variant="body2" fontWeight="bold"> {tokenInfo.symbol} </Typography> {children} - <Typography data-testid="token-amount"> - {formatVisualAmount(amountInWei, tokenInfo.decimals, tokenInfo.decimals)} + <Typography variant="body2" data-testid="token-amount"> + {formatVisualAmount(amountInWei, tokenInfo.decimals, tokenInfo.decimals ?? 0)} </Typography> </Box> </FieldsGrid> diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/SpendingLimitRow/index.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/SpendingLimitRow/index.tsx index 70bdbd1b1c..233d3a61f3 100644 --- a/apps/web/src/components/tx-flow/flows/TokenTransfer/SpendingLimitRow/index.tsx +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/SpendingLimitRow/index.tsx @@ -1,19 +1,19 @@ import { FormControl, FormControlLabel, InputLabel, Radio, RadioGroup, SvgIcon, Tooltip } from '@mui/material' import { Controller, useFormContext } from 'react-hook-form' import classNames from 'classnames' -import { safeFormatUnits } from '@/utils/formatters' +import { safeFormatUnits } from '@safe-global/utils/utils/formatters' import type { TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { TokenTransferFields, TokenTransferType } from '@/components/tx-flow/flows/TokenTransfer' +import { MultiTransfersFields, TokenTransferType } from '@/components/tx-flow/flows/TokenTransfer' import InfoIcon from '@/public/images/notifications/info.svg' import ExternalLink from '@/components/common/ExternalLink' -import { HelpCenterArticle } from '@/config/constants' import css from './styles.module.css' import { TokenAmountFields } from '@/components/common/TokenAmountInput' import { useContext, useEffect } from 'react' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import { useHasPermission } from '@/permissions/hooks/useHasPermission' -import { Permission } from '@/permissions/types' +import { Permission } from '@/permissions/config' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' const SpendingLimitRow = ({ availableAmount, @@ -24,7 +24,9 @@ const SpendingLimitRow = ({ }) => { const { control, trigger, resetField } = useFormContext() const canCreateStandardTx = useHasPermission(Permission.CreateTransaction) - const canCreateSpendingLimitTx = useHasPermission(Permission.CreateSpendingLimitTransaction, { token: selectedToken }) + const canCreateSpendingLimitTx = useHasPermission(Permission.CreateSpendingLimitTransaction, { + tokenAddress: selectedToken?.address, + }) const { setNonceNeeded } = useContext(SafeTxContext) const formattedAmount = safeFormatUnits(availableAmount, selectedToken?.decimals) @@ -32,7 +34,7 @@ const SpendingLimitRow = ({ useEffect(() => { return () => { // reset the field value to default when the component is unmounted - resetField(TokenTransferFields.type) + resetField(MultiTransfersFields.type) } }, [resetField]) @@ -44,7 +46,7 @@ const SpendingLimitRow = ({ <Controller rules={{ required: true }} control={control} - name={TokenTransferFields.type} + name={MultiTransfersFields.type} render={({ field: { onChange, ...field } }) => ( <RadioGroup row diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/__tests__/CreateTokenTransfer.test.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/__tests__/CreateTokenTransfer.test.tsx index 210728d316..ed4f0054f8 100644 --- a/apps/web/src/components/tx-flow/flows/TokenTransfer/__tests__/CreateTokenTransfer.test.tsx +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/__tests__/CreateTokenTransfer.test.tsx @@ -1,17 +1,25 @@ import { TokenTransferType } from '@/components/tx-flow/flows/TokenTransfer' -import { CreateTokenTransfer } from '@/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer' +import { + CreateTokenTransfer, + type CreateTokenTransferProps, +} from '@/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer' import * as tokenUtils from '@/components/tx-flow/flows/TokenTransfer/utils' import * as useHasPermission from '@/permissions/hooks/useHasPermission' -import { Permission } from '@/permissions/types' +import { Permission } from '@/permissions/config' import { render } from '@/tests/test-utils' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import TxFlowProvider from '@/components/tx-flow/TxFlowProvider' describe('CreateTokenTransfer', () => { const mockParams = { - recipient: '', - tokenAddress: ZERO_ADDRESS, - amount: '', + recipients: [ + { + recipient: '', + tokenAddress: ZERO_ADDRESS, + amount: '', + }, + ], type: TokenTransferType.multiSig, } @@ -22,14 +30,26 @@ describe('CreateTokenTransfer', () => { useHasPermissionSpy.mockReturnValue(true) }) + const renderCreateTokenTransfer = ( + props: CreateTokenTransferProps = {}, + options: Parameters<typeof render>[1] = undefined, + ) => { + return render( + <TxFlowProvider step={0} data={mockParams} prevStep={() => {}} nextStep={jest.fn()}> + <CreateTokenTransfer {...props} /> + </TxFlowProvider>, + options, + ) + } + it('should display a token amount input', () => { - const { getByText } = render(<CreateTokenTransfer params={mockParams} onSubmit={jest.fn()} />) + const { getByText } = renderCreateTokenTransfer() expect(getByText('Amount')).toBeInTheDocument() }) it('should display a recipient input', () => { - const { getAllByText } = render(<CreateTokenTransfer params={mockParams} onSubmit={jest.fn()} />) + const { getAllByText } = renderCreateTokenTransfer() expect(getAllByText('Recipient address')[0]).toBeInTheDocument() }) @@ -41,31 +61,34 @@ describe('CreateTokenTransfer', () => { const tokenAddress = ZERO_ADDRESS - const { getByText } = render(<CreateTokenTransfer params={mockParams} onSubmit={jest.fn()} />, { - initialReduxState: { - balances: { - loading: false, - data: { - fiatTotal: '0', - items: [ - { - balance: '10', - tokenInfo: { - address: tokenAddress, - decimals: 18, - logoUri: 'someurl', - name: 'Test token', - symbol: 'TST', - type: TokenType.ERC20, + const { getByText } = renderCreateTokenTransfer( + {}, + { + initialReduxState: { + balances: { + loading: false, + data: { + fiatTotal: '0', + items: [ + { + balance: '10', + tokenInfo: { + address: tokenAddress, + decimals: 18, + logoUri: 'someurl', + name: 'Test token', + symbol: 'TST', + type: TokenType.ERC20, + }, + fiatBalance: '10', + fiatConversion: '1', }, - fiatBalance: '10', - fiatConversion: '1', - }, - ], + ], + }, }, }, }, - }) + ) expect(getByText('Send as')).toBeInTheDocument() @@ -74,14 +97,14 @@ describe('CreateTokenTransfer', () => { it('should not display a type selection if user does not have `CreateSpendingLimitTransaction` permission', () => { useHasPermissionSpy.mockReturnValueOnce(false) - const { queryByText } = render(<CreateTokenTransfer params={mockParams} onSubmit={jest.fn()} txNonce={1} />) + const { queryByText } = renderCreateTokenTransfer({ txNonce: 1 }) expect(queryByText('Send as')).not.toBeInTheDocument() expect(useHasPermissionSpy).toHaveBeenCalledWith(Permission.CreateSpendingLimitTransaction) }) it('should not display a type selection if there is a txNonce', () => { - const { queryByText } = render(<CreateTokenTransfer params={mockParams} onSubmit={jest.fn()} txNonce={1} />) + const { queryByText } = renderCreateTokenTransfer({ txNonce: 1 }) expect(queryByText('Send as')).not.toBeInTheDocument() }) diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/index.tsx b/apps/web/src/components/tx-flow/flows/TokenTransfer/index.tsx index 89b99fd8cd..40634d7af6 100644 --- a/apps/web/src/components/tx-flow/flows/TokenTransfer/index.tsx +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/index.tsx @@ -1,10 +1,12 @@ -import TxLayout from '@/components/tx-flow/common/TxLayout' -import useTxStepper from '../../useTxStepper' import CreateTokenTransfer from './CreateTokenTransfer' import ReviewTokenTx from '@/components/tx-flow/flows/TokenTransfer/ReviewTokenTx' import AssetsIcon from '@/public/images/sidebar/assets.svg' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { TokenAmountFields } from '@/components/common/TokenAmountInput' +import { useMemo } from 'react' +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' +import { TxFlowStep } from '../../TxFlowStep' export enum TokenTransferType { multiSig = 'multiSig', @@ -13,7 +15,6 @@ export enum TokenTransferType { enum Fields { recipient = 'recipient', - type = 'type', } export const TokenTransferFields = { ...Fields, ...TokenAmountFields } @@ -22,47 +23,62 @@ export type TokenTransferParams = { [TokenTransferFields.recipient]: string [TokenTransferFields.tokenAddress]: string [TokenTransferFields.amount]: string - [TokenTransferFields.type]: TokenTransferType } -type TokenTransferFlowProps = Partial<TokenTransferParams> & { - txNonce?: number +export enum MultiTransfersFields { + recipients = 'recipients', + type = 'type', } -const defaultParams: TokenTransferParams = { - recipient: '', - tokenAddress: ZERO_ADDRESS, - amount: '', - type: TokenTransferType.multiSig, +export const MultiTokenTransferFields = { ...MultiTransfersFields } + +export type MultiTokenTransferParams = { + [MultiTransfersFields.recipients]: TokenTransferParams[] + [MultiTransfersFields.type]: TokenTransferType } -const TokenTransferFlow = ({ txNonce, ...params }: TokenTransferFlowProps) => { - const { data, step, nextStep, prevStep } = useTxStepper<TokenTransferParams>({ - ...defaultParams, - ...params, - }) +type MultiTokenTransferFlowProps = { + recipients?: Partial<TokenTransferParams>[] + txNonce?: number +} - const steps = [ - <CreateTokenTransfer - key={0} - params={data} - txNonce={txNonce} - onSubmit={(formData) => nextStep({ ...data, ...formData })} - />, +const defaultParams: MultiTokenTransferParams = { + recipients: [ + { + recipient: '', + tokenAddress: ZERO_ADDRESS, + amount: '', + }, + ], + type: TokenTransferType.multiSig, +} - <ReviewTokenTx key={1} params={data} txNonce={txNonce} onSubmit={() => null} />, - ] +const TokenTransferFlow = ({ txNonce, ...params }: MultiTokenTransferFlowProps) => { + const initialData = useMemo<MultiTokenTransferParams>( + () => ({ + ...defaultParams, + recipients: params.recipients + ? params.recipients.map((recipient) => ({ + ...defaultParams.recipients[0], + ...recipient, + })) + : defaultParams.recipients, + }), + [params.recipients], + ) return ( - <TxLayout - title={step === 0 ? 'New transaction' : 'Confirm transaction'} - subtitle="Send tokens" + <TxFlow + initialData={initialData} icon={AssetsIcon} - step={step} - onBack={prevStep} + subtitle="Send tokens" + eventCategory={TxFlowType.TOKEN_TRANSFER} + ReviewTransactionComponent={ReviewTokenTx} > - {steps} - </TxLayout> + <TxFlowStep title="New transaction"> + <CreateTokenTransfer txNonce={txNonce} /> + </TxFlowStep> + </TxFlow> ) } diff --git a/apps/web/src/components/tx-flow/flows/TokenTransfer/utils.ts b/apps/web/src/components/tx-flow/flows/TokenTransfer/utils.ts index 68a92b2f87..01a946c5e6 100644 --- a/apps/web/src/components/tx-flow/flows/TokenTransfer/utils.ts +++ b/apps/web/src/components/tx-flow/flows/TokenTransfer/utils.ts @@ -4,7 +4,7 @@ import { useVisibleBalances } from '@/hooks/useVisibleBalances' import useWallet from '@/hooks/wallets/useWallet' import { useAppSelector } from '@/store' import { selectSpendingLimits } from '@/store/spendingLimitsSlice' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { type SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' export const useTokenAmount = (selectedToken: SafeBalanceResponse['items'][0] | undefined) => { diff --git a/apps/web/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx b/apps/web/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx index 14e8652231..7d41e88558 100644 --- a/apps/web/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx +++ b/apps/web/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx @@ -4,10 +4,10 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { createUpdateSafeTxs } from '@/services/tx/safeUpdateParams' import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' import { SafeTxContext } from '../../SafeTxProvider' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' +import ReviewTransaction, { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' -export const UpdateSafeReview = () => { +export const UpdateSafeReview = (props: ReviewTransactionProps) => { const { safe, safeLoaded } = useSafeInfo() const chain = useCurrentChain() const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) @@ -21,5 +21,5 @@ export const UpdateSafeReview = () => { safeTxPromise.then(setSafeTx).catch(setSafeTxError) }, [safe, safeLoaded, chain, setSafeTx, setSafeTxError]) - return <SignOrExecuteForm /> + return <ReviewTransaction {...props} /> } diff --git a/apps/web/src/components/tx-flow/flows/UpdateSafe/index.tsx b/apps/web/src/components/tx-flow/flows/UpdateSafe/index.tsx index 082272d015..85d39abdc7 100644 --- a/apps/web/src/components/tx-flow/flows/UpdateSafe/index.tsx +++ b/apps/web/src/components/tx-flow/flows/UpdateSafe/index.tsx @@ -1,12 +1,16 @@ -import TxLayout from '@/components/tx-flow/common/TxLayout' import { UpdateSafeReview } from './UpdateSafeReview' import SettingsIcon from '@/public/images/sidebar/settings.svg' +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' const UpdateSafeFlow = () => { return ( - <TxLayout title="Confirm transaction" subtitle="Update Safe Account version" icon={SettingsIcon}> - <UpdateSafeReview /> - </TxLayout> + <TxFlow + subtitle="Update Safe Account version" + icon={SettingsIcon} + eventCategory={TxFlowType.UPDATE_SAFE} + ReviewTransactionComponent={UpdateSafeReview} + /> ) } diff --git a/apps/web/src/components/tx-flow/flows/UpsertRecovery/RecovererSmartContractWarning.tsx b/apps/web/src/components/tx-flow/flows/UpsertRecovery/RecovererSmartContractWarning.tsx index b341658c6d..5770862ce6 100644 --- a/apps/web/src/components/tx-flow/flows/UpsertRecovery/RecovererSmartContractWarning.tsx +++ b/apps/web/src/components/tx-flow/flows/UpsertRecovery/RecovererSmartContractWarning.tsx @@ -10,7 +10,7 @@ import { isSmartContractWallet } from '@/utils/wallets' import useDebounce from '@/hooks/useDebounce' import useSafeInfo from '@/hooks/useSafeInfo' import { UpsertRecoveryFlowFields } from '.' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import addressBookInputCss from '@/components/common/AddressBookInput/styles.module.css' diff --git a/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowIntro.tsx b/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowIntro.tsx index 0871cd5839..c9ccd20643 100644 --- a/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowIntro.tsx +++ b/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowIntro.tsx @@ -9,6 +9,8 @@ import RecoveryExecution from '@/public/images/transactions/recovery-execution.s import css from './styles.module.css' import commonCss from '@/components/tx-flow/common/styles.module.css' +import { useContext } from 'react' +import { TxFlowContext } from '../../TxFlowProvider' const RecoverySteps = [ { @@ -35,7 +37,8 @@ const RecoverySteps = [ }, ] -export function UpsertRecoveryFlowIntro({ onSubmit }: { onSubmit: () => void }): ReactElement { +export function UpsertRecoveryFlowIntro(): ReactElement { + const { onNext, data } = useContext(TxFlowContext) return ( <TxCard> <Grid @@ -75,7 +78,7 @@ export function UpsertRecoveryFlowIntro({ onSubmit }: { onSubmit: () => void }): </Grid> <Divider className={commonCss.nestedDivider} /> <CardActions sx={{ mt: 'var(--space-1) !important' }}> - <Button data-testid="next-btn" variant="contained" onClick={onSubmit}> + <Button data-testid="next-btn" variant="contained" onClick={() => onNext(data)}> Next </Button> </CardActions> diff --git a/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowReview.tsx b/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowReview.tsx index 169dcfa17e..85d8c23a5e 100644 --- a/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowReview.tsx +++ b/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowReview.tsx @@ -1,94 +1,50 @@ import { SvgIcon, Tooltip, Typography } from '@mui/material' -import { getSafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { useContext, useEffect } from 'react' import type { ReactElement } from 'react' import EthHashInfo from '@/components/common/EthHashInfo' import { TxDataRow } from '@/components/transactions/TxDetails/Summary/TxDataRow' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' -import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import useSafeInfo from '@/hooks/useSafeInfo' import InfoIcon from '@/public/images/notifications/info.svg' -import { trackEvent } from '@/services/analytics' -import { RECOVERY_EVENTS } from '@/services/analytics/events/recovery' import { Errors, logError } from '@/services/exceptions' import { getRecoveryUpsertTransactions } from '@/features/recovery/services/setup' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' -import { isSmartContractWallet } from '@/utils/wallets' import { UpsertRecoveryFlowFields } from '.' import { TOOLTIP_TITLES } from '../../common/constants' import { useRecoveryPeriods } from './useRecoveryPeriods' import type { UpsertRecoveryFlowProps } from '.' import { isCustomDelaySelected } from './utils' +import { TxFlowContext, type TxFlowContextType } from '../../TxFlowProvider' +import ReviewTransaction, { type ReviewTransactionProps } from '@/components/tx/ReviewTransactionV2' +import ErrorMessage from '@/components/tx/ErrorMessage' -enum AddressType { - EOA = 'EOA', - Safe = 'Safe', - Other = 'Other', -} - -const getAddressType = async (address: string, chainId: string) => { - const isSmartContract = await isSmartContractWallet(chainId, address) - if (!isSmartContract) return AddressType.EOA - - const isSafeContract = await getSafeInfo(chainId, address) - if (isSafeContract) return AddressType.Safe - - return AddressType.Other -} - -const onSubmit = async ( - isEdit: boolean, - params: Omit<UpsertRecoveryFlowProps, 'customDelay' | 'selectedDelay'>, - chainId: string, -) => { - const addressType = await getAddressType(params.recoverer, chainId) - const creationEvent = isEdit ? RECOVERY_EVENTS.SUBMIT_RECOVERY_EDIT : RECOVERY_EVENTS.SUBMIT_RECOVERY_CREATE - const settings = `delay_${params.delay},expiry_${params.expiry},type_${addressType}` - - trackEvent({ ...creationEvent }) - trackEvent({ ...RECOVERY_EVENTS.RECOVERY_SETTINGS, label: settings }) -} - -export function UpsertRecoveryFlowReview({ - params, - moduleAddress, -}: { - params: UpsertRecoveryFlowProps - moduleAddress?: string -}): ReactElement { +export function UpsertRecoveryFlowReview({ children, ...props }: ReviewTransactionProps): ReactElement { const web3ReadOnly = useWeb3ReadOnly() const { safe, safeAddress } = useSafeInfo() const { setSafeTx, safeTxError, setSafeTxError } = useContext(SafeTxContext) const periods = useRecoveryPeriods() - const { recoverer, expiry, delay, customDelay, selectedDelay } = params - const isCustomDelay = isCustomDelaySelected(selectedDelay) - - const expiryLabel = periods.expiration.find(({ value }) => value === params[UpsertRecoveryFlowFields.expiry])!.label - const delayLabel = isCustomDelay - ? `${customDelay} days` - : periods.delay.find(({ value }) => value === selectedDelay)?.label + const { data } = useContext<TxFlowContextType<UpsertRecoveryFlowProps>>(TxFlowContext) useEffect(() => { - if (!web3ReadOnly) { + if (!web3ReadOnly || !data) { return } getRecoveryUpsertTransactions({ - ...params, + ...data, provider: web3ReadOnly, chainId: safe.chainId, safeAddress, - moduleAddress, }) .then((transactions) => { return transactions.length > 1 ? createMultiSendCallOnlyTx(transactions) : createTx(transactions[0]) }) .then(setSafeTx) .catch(setSafeTxError) - }, [moduleAddress, params, safe.chainId, safeAddress, setSafeTx, setSafeTxError, web3ReadOnly]) + }, [data, safe.chainId, safeAddress, setSafeTx, setSafeTxError, web3ReadOnly]) useEffect(() => { if (safeTxError) { @@ -96,12 +52,25 @@ export function UpsertRecoveryFlowReview({ } }, [safeTxError]) - const isEdit = !!moduleAddress + const isEdit = !!data?.moduleAddress + + if (!data) { + return <ErrorMessage>No data provided</ErrorMessage> + } + + const { recoverer, customDelay, selectedDelay } = data + + const isCustomDelay = isCustomDelaySelected(selectedDelay ?? '') + + const expiryLabel = periods.expiration.find(({ value }) => value === data?.[UpsertRecoveryFlowFields.expiry])!.label + const delayLabel = isCustomDelay + ? `${customDelay} days` + : periods.delay.find(({ value }) => value === selectedDelay)?.label return ( - <SignOrExecuteForm onSubmit={() => onSubmit(isEdit, { recoverer, expiry, delay }, safe.chainId)}> + <ReviewTransaction {...props}> <Typography> - This transaction will {moduleAddress ? 'update' : 'enable'} the Account recovery feature once executed. + This transaction will {isEdit ? 'update' : 'enable'} the Account recovery feature once executed. </Typography> <TxDataRow title="Trusted Recoverer"> @@ -151,6 +120,8 @@ export function UpsertRecoveryFlowReview({ {expiryLabel} </TxDataRow> )} - </SignOrExecuteForm> + + {children} + </ReviewTransaction> ) } diff --git a/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowSettings.tsx b/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowSettings.tsx index b3f6b20b0c..604bfa6a37 100644 --- a/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowSettings.tsx +++ b/apps/web/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowSettings.tsx @@ -18,19 +18,19 @@ import { import ExpandLessIcon from '@mui/icons-material/ExpandLess' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import { useForm, FormProvider, Controller } from 'react-hook-form' -import { useState } from 'react' +import { useContext, useState } from 'react' import type { ReactElement } from 'react' import TxCard from '../../common/TxCard' import { useRecoveryPeriods } from './useRecoveryPeriods' import { UpsertRecoveryFlowFields, type UpsertRecoveryFlowProps } from '.' import AddressBookInput from '@/components/common/AddressBookInput' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import useSafeInfo from '@/hooks/useSafeInfo' import InfoIcon from '@/public/images/notifications/info.svg' import { RecovererWarning } from './RecovererSmartContractWarning' import ExternalLink from '@/components/common/ExternalLink' -import { BRAND_NAME, HelpCenterArticle, HelperCenterArticleTitles } from '@/config/constants' +import { BRAND_NAME } from '@/config/constants' import { TOOLTIP_TITLES } from '../../common/constants' import Track from '@/components/common/Track' import type { RecoveryStateItem } from '@/features/recovery/services/recovery-state' @@ -39,23 +39,38 @@ import commonCss from '@/components/tx-flow/common/styles.module.css' import css from './styles.module.css' import NumberField from '@/components/common/NumberField' import { getDelay, isCustomDelaySelected } from './utils' +import { HelpCenterArticle, HelperCenterArticleTitles } from '@safe-global/utils/config/constants' +import { TxFlowContext, type TxFlowContextType } from '../../TxFlowProvider' +import { isSmartContractWallet } from '@/utils/wallets' +import { getSafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import useChainId from '@/hooks/useChainId' -export function UpsertRecoveryFlowSettings({ - params, - delayModifier, - onSubmit, -}: { - params: UpsertRecoveryFlowProps - delayModifier?: RecoveryStateItem - onSubmit: (formData: UpsertRecoveryFlowProps) => void -}): ReactElement { +enum AddressType { + EOA = 'EOA', + Safe = 'Safe', + Other = 'Other', +} + +const getAddressType = async (address: string, chainId: string) => { + const isSmartContract = await isSmartContractWallet(chainId, address) + if (!isSmartContract) return AddressType.EOA + + const isSafeContract = await getSafeInfo(chainId, address) + if (isSafeContract) return AddressType.Safe + + return AddressType.Other +} + +export function UpsertRecoveryFlowSettings({ delayModifier }: { delayModifier?: RecoveryStateItem }): ReactElement { + const chainId = useChainId() const { safeAddress } = useSafeInfo() - const [showAdvanced, setShowAdvanced] = useState(params[UpsertRecoveryFlowFields.expiry] !== '0') + const { data, onNext } = useContext<TxFlowContextType<UpsertRecoveryFlowProps>>(TxFlowContext) + const [showAdvanced, setShowAdvanced] = useState(data?.[UpsertRecoveryFlowFields.expiry] !== '0') const [understandsRisk, setUnderstandsRisk] = useState(false) const periods = useRecoveryPeriods() const formMethods = useForm<UpsertRecoveryFlowProps>({ - defaultValues: params, + defaultValues: data, mode: 'onChange', }) @@ -96,8 +111,17 @@ export function UpsertRecoveryFlowSettings({ const isDisabled = !understandsRisk || !isDirty || !!customDelayState.error - const handleSubmit = () => { - onSubmit({ expiry, delay, customDelay, selectedDelay, recoverer }) + const isEdit = !!delayModifier + + const handleSubmit = async () => { + const addressType = await getAddressType(recoverer, chainId) + const creationEvent = isEdit ? RECOVERY_EVENTS.SUBMIT_RECOVERY_EDIT : RECOVERY_EVENTS.SUBMIT_RECOVERY_CREATE + const settings = `delay_${delay},expiry_${expiry},type_${addressType}` + + trackEvent({ ...creationEvent }) + trackEvent({ ...RECOVERY_EVENTS.RECOVERY_SETTINGS, label: settings }) + + onNext({ expiry, delay, customDelay, selectedDelay, recoverer, moduleAddress: data?.moduleAddress }) } return ( diff --git a/apps/web/src/components/tx-flow/flows/UpsertRecovery/index.tsx b/apps/web/src/components/tx-flow/flows/UpsertRecovery/index.tsx index f00004e8d1..87cf57dea2 100644 --- a/apps/web/src/components/tx-flow/flows/UpsertRecovery/index.tsx +++ b/apps/web/src/components/tx-flow/flows/UpsertRecovery/index.tsx @@ -1,16 +1,13 @@ -import { SETUP_RECOVERY_CATEGORY } from '@/services/analytics/events/recovery' import type { ReactElement } from 'react' - -import TxLayout from '@/components/tx-flow/common/TxLayout' import RecoveryPlus from '@/public/images/common/recovery-plus.svg' -import useTxStepper from '../../useTxStepper' import { UpsertRecoveryFlowReview as UpsertRecoveryFlowReview } from './UpsertRecoveryFlowReview' import { UpsertRecoveryFlowSettings as UpsertRecoveryFlowSettings } from './UpsertRecoveryFlowSettings' import { UpsertRecoveryFlowIntro as UpsertRecoveryFlowIntro } from './UpsertRecoveryFlowIntro' import { DAY_IN_SECONDS } from './useRecoveryPeriods' import type { RecoveryState } from '@/features/recovery/services/recovery-state' - -const Subtitles = ['How does recovery work?', 'Set up recovery settings', 'Set up account recovery'] +import { TxFlowType } from '@/services/analytics' +import { TxFlow } from '../../TxFlow' +import { TxFlowStep } from '../../TxFlowStep' export enum UpsertRecoveryFlowFields { recoverer = 'recoverer', @@ -18,6 +15,7 @@ export enum UpsertRecoveryFlowFields { customDelay = 'customDelay', selectedDelay = 'selectedDelay', expiry = 'expiry', + moduleAddress = 'moduleAddress', } export type UpsertRecoveryFlowProps = { @@ -26,47 +24,35 @@ export type UpsertRecoveryFlowProps = { [UpsertRecoveryFlowFields.customDelay]: string [UpsertRecoveryFlowFields.selectedDelay]: string [UpsertRecoveryFlowFields.expiry]: string + [UpsertRecoveryFlowFields.moduleAddress]?: string } function UpsertRecoveryFlow({ delayModifier }: { delayModifier?: RecoveryState[number] }): ReactElement { - const { data, step, nextStep, prevStep } = useTxStepper<UpsertRecoveryFlowProps>( - { - [UpsertRecoveryFlowFields.recoverer]: delayModifier?.recoverers?.[0] ?? '', - [UpsertRecoveryFlowFields.delay]: '', - [UpsertRecoveryFlowFields.selectedDelay]: delayModifier?.delay?.toString() ?? `${DAY_IN_SECONDS * 28}`, // 28 days in seconds - [UpsertRecoveryFlowFields.customDelay]: '', - [UpsertRecoveryFlowFields.expiry]: delayModifier?.expiry?.toString() ?? '0', - }, - SETUP_RECOVERY_CATEGORY, - ) - - const steps = [ - <UpsertRecoveryFlowIntro key={0} onSubmit={() => nextStep(data)} />, - <UpsertRecoveryFlowSettings - key={1} - params={data} - delayModifier={delayModifier} - onSubmit={(formData) => nextStep({ ...data, ...formData })} - />, - <UpsertRecoveryFlowReview key={2} params={data} moduleAddress={delayModifier?.address} />, - ] - - const isIntro = step === 0 - - const icon = isIntro ? undefined : RecoveryPlus + const initialData = { + [UpsertRecoveryFlowFields.recoverer]: delayModifier?.recoverers?.[0] ?? '', + [UpsertRecoveryFlowFields.delay]: '', + [UpsertRecoveryFlowFields.selectedDelay]: delayModifier?.delay?.toString() ?? `${DAY_IN_SECONDS * 28}`, // 28 days in seconds + [UpsertRecoveryFlowFields.customDelay]: '', + [UpsertRecoveryFlowFields.expiry]: delayModifier?.expiry?.toString() ?? '0', + [UpsertRecoveryFlowFields.moduleAddress]: delayModifier?.address, + } return ( - <TxLayout + <TxFlow + initialData={initialData} + eventCategory={TxFlowType.SETUP_RECOVERY} + ReviewTransactionComponent={UpsertRecoveryFlowReview} + icon={RecoveryPlus} title="Account recovery" - subtitle={Subtitles[step]} - icon={icon} - step={step} - onBack={prevStep} - hideNonce={isIntro} - hideProgress={isIntro} + subtitle="Set up account recovery" > - {steps} - </TxLayout> + <TxFlowStep title="Account recovery" subtitle="How does recovery work" hideNonce hideProgress> + <UpsertRecoveryFlowIntro /> + </TxFlowStep> + <TxFlowStep title="Account recovery" subtitle="Set up recovery settings" icon={RecoveryPlus}> + <UpsertRecoveryFlowSettings /> + </TxFlowStep> + </TxFlow> ) } diff --git a/apps/web/src/components/tx-flow/flows/index.ts b/apps/web/src/components/tx-flow/flows/index.ts index b842818db7..7432cd4ed2 100644 --- a/apps/web/src/components/tx-flow/flows/index.ts +++ b/apps/web/src/components/tx-flow/flows/index.ts @@ -25,5 +25,6 @@ export const SuccessScreenFlow = dynamic(() => import('./SuccessScreen')) export const NestedTxSuccessScreenFlow = dynamic(() => import('./NestedTxSuccessScreen')) export const TokenTransferFlow = dynamic(() => import('./TokenTransfer')) export const UpdateSafeFlow = dynamic(() => import('./UpdateSafe')) +export const MigrateSafeL2Flow = dynamic(() => import('./MigrateSafeL2')) export const UpsertRecoveryFlow = dynamic(() => import('./UpsertRecovery')) export const RecoveryAttemptFlow = dynamic(() => import('./RecoveryAttempt')) diff --git a/apps/web/src/components/tx-flow/index.tsx b/apps/web/src/components/tx-flow/index.tsx index be1f3d66c4..cecc0c04a4 100644 --- a/apps/web/src/components/tx-flow/index.tsx +++ b/apps/web/src/components/tx-flow/index.tsx @@ -1,10 +1,8 @@ -import { createContext, type ReactElement, type ReactNode, useState, useEffect, useCallback, useRef } from 'react' -import { usePathname } from 'next/navigation' +import { createContext, type ReactElement, type ReactNode, useState, useCallback, useRef } from 'react' import TxModalDialog from '@/components/common/TxModalDialog' import { SuccessScreenFlow, NestedTxSuccessScreenFlow } from './flows' -import useSafeAddress from '@/hooks/useSafeAddress' -import useChainId from '@/hooks/useChainId' import { useWalletContext } from '@/hooks/wallets/useWallet' +import { usePreventNavigation } from '@/hooks/usePreventNavigation' const noop = () => {} @@ -20,7 +18,6 @@ export const TxModalContext = createContext<TxModalContextType>({ setFullWidth: noop, }) -// TODO: Rename TxModalProvider, setTxFlow, TxModalDialog to not contain Tx since it can be used for any type of modal as a global provider const confirmClose = () => { return confirm('Closing this window will discard your current progress.') } @@ -30,21 +27,19 @@ export const TxModalProvider = ({ children }: { children: ReactNode }): ReactEle const [fullWidth, setFullWidth] = useState<boolean>(false) const shouldWarn = useRef<boolean>(true) const onClose = useRef<() => void>(noop) - const safeId = useChainId() + useSafeAddress() - const prevSafeId = useRef<string>(safeId ?? '') - const pathname = usePathname() - const prevPathname = useRef<string | null>(pathname) const { setSignerAddress } = useWalletContext() ?? {} const handleModalClose = useCallback(() => { if (shouldWarn.current && !confirmClose()) { - return + return false } onClose.current() onClose.current = noop setFlow(undefined) setSignerAddress?.(undefined) + + return true }, [setSignerAddress]) // Open a new tx flow, close the previous one if any @@ -70,17 +65,7 @@ export const TxModalProvider = ({ children }: { children: ReactNode }): ReactEle [], ) - // Close the modal when the user navigates to a different Safe or route - useEffect(() => { - if (safeId === prevSafeId.current && pathname === prevPathname.current) return - - prevSafeId.current = safeId - prevPathname.current = pathname - - if (txFlow) { - handleModalClose() - } - }, [txFlow, safeId, pathname, handleModalClose]) + usePreventNavigation(txFlow ? handleModalClose : undefined) return ( <TxModalContext.Provider value={{ txFlow, setTxFlow, setFullWidth }}> diff --git a/apps/web/src/components/tx-flow/slots/Slot.tsx b/apps/web/src/components/tx-flow/slots/Slot.tsx new file mode 100644 index 0000000000..37f2cefaab --- /dev/null +++ b/apps/web/src/components/tx-flow/slots/Slot.tsx @@ -0,0 +1,33 @@ +import type { PropsWithChildren, ReactElement } from 'react' +import { type SlotComponentProps, type SlotName, useSlot } from '@/components/tx-flow/slots' + +export type SlotProps<T extends SlotName> = PropsWithChildren< + { + name: T + id?: string + } & Omit<SlotComponentProps<T>, 'slotId'> +> + +/** + * Slot component for rendering components in specific slots. + * It takes a slot name and an optional id to identify the slot. + * If there are registered components for the slot, it renders them. + * Otherwise, it renders the children passed to it as fallback. + */ +export const Slot = <T extends SlotName>({ name, id, children, ...rest }: SlotProps<T>): ReactElement => { + const slotItems = useSlot(name, id) + + if (slotItems.length === 0) { + return <>{children}</> + } + + const props = { ...rest, slotId: id } as unknown as SlotComponentProps<T> + + return ( + <> + {slotItems.map(({ Component, id }, i) => ( + <Component {...props} key={`slot-${name}-${i}-${id}`} /> + ))} + </> + ) +} diff --git a/apps/web/src/components/tx-flow/slots/SlotProvider.tsx b/apps/web/src/components/tx-flow/slots/SlotProvider.tsx new file mode 100644 index 0000000000..d0bda040de --- /dev/null +++ b/apps/web/src/components/tx-flow/slots/SlotProvider.tsx @@ -0,0 +1,119 @@ +import React, { + createContext, + type ReactNode, + type ComponentType, + useState, + useCallback, + type PropsWithChildren, +} from 'react' +import type { SubmitCallback } from '../TxFlow' + +export enum SlotName { + Submit = 'submit', + ComboSubmit = 'combo-submit', + Feature = 'feature', + Footer = 'footer', + Sidebar = 'sidebar', +} + +type SlotComponentPropsMap = { + [SlotName.Submit]: PropsWithChildren<{ + onSubmit?: () => void + onSubmitSuccess?: SubmitCallback + }> + [SlotName.ComboSubmit]: PropsWithChildren<{ + onSubmit?: () => void + onSubmitSuccess?: SubmitCallback + options: { label: string; id: string }[] + onChange: (option: string) => void + disabled?: boolean + }> +} + +type BaseSlotComponentProps = { + slotId: string +} + +export type SlotComponentProps<T extends SlotName> = T extends keyof SlotComponentPropsMap + ? SlotComponentPropsMap[T] & BaseSlotComponentProps + : BaseSlotComponentProps + +type SlotContextType = { + registerSlot: <T extends SlotName>(args: { + slotName: T + id: string + Component: SlotItem<T>['Component'] + label?: SlotItem<T>['label'] + }) => void + unregisterSlot: (slotName: SlotName, id: string) => void + getSlot: <T extends SlotName>(slotName: T, id?: string) => SlotItem<T>[] + getSlotIds: (slotName: SlotName) => string[] +} + +export type SlotItem<S extends SlotName> = { + Component: ComponentType<SlotComponentProps<S>> + id: string + label: string +} + +type SlotStore = { + [K in SlotName]?: { + [id: string]: SlotItem<K> | null + } +} + +export const SlotContext = createContext<SlotContextType | null>(null) + +/** + * SlotProvider is a context provider for managing slots in the transaction flow. + * It allows components to register and unregister themselves in specific slots, + * and provides a way to retrieve the components registered in a slot. + */ +export const SlotProvider = ({ children }: { children: ReactNode }) => { + const [slots, setSlots] = useState<SlotStore>({}) + + const registerSlot = useCallback<SlotContextType['registerSlot']>(({ slotName, id, Component, label }) => { + setSlots((prevSlots) => ({ + ...prevSlots, + [slotName]: { ...prevSlots[slotName], [id]: { Component, label: label || id, id } }, + })) + }, []) + + const unregisterSlot = useCallback((slotName: SlotName, id: string) => { + setSlots((prevSlots) => ({ + ...prevSlots, + [slotName]: { ...prevSlots[slotName], [id]: null }, + })) + }, []) + + const getSlot = useCallback( + <T extends SlotName>(slotName: T, id?: string): SlotItem<T>[] => { + const slot = slots[slotName] + + if (id) { + const slotItem = slot?.[id] + if (slotItem) { + return [slotItem] + } + } + + return Object.values(slot || {}).filter((component) => !!component) as SlotItem<T>[] + }, + [slots], + ) + + const getSlotIds = useCallback( + (slotName: SlotName): string[] => { + const slot = slots[slotName] + if (!slot) return [] + return Object.keys(slot).filter((id) => !!slot?.[id]) + }, + [slots], + ) + + return ( + <SlotContext.Provider value={{ registerSlot, unregisterSlot, getSlot, getSlotIds }}> + {children} + </SlotContext.Provider> + ) +} diff --git a/apps/web/src/components/tx-flow/slots/hooks/index.ts b/apps/web/src/components/tx-flow/slots/hooks/index.ts new file mode 100644 index 0000000000..dc6164266c --- /dev/null +++ b/apps/web/src/components/tx-flow/slots/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './useRegisterSlot' +export * from './useSlotContext' +export * from './useSlot' +export * from './useSlotIds' diff --git a/apps/web/src/components/tx-flow/slots/hooks/useRegisterSlot.ts b/apps/web/src/components/tx-flow/slots/hooks/useRegisterSlot.ts new file mode 100644 index 0000000000..923c9c3d9e --- /dev/null +++ b/apps/web/src/components/tx-flow/slots/hooks/useRegisterSlot.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react' +import type { SlotItem, SlotName } from '../SlotProvider' +import { useSlotContext } from './useSlotContext' + +export type UseRegisterSlotProps<T extends SlotName> = { + slotName: T + id: string + Component: SlotItem<T>['Component'] + label?: SlotItem<T>['label'] + condition?: boolean +} + +/** + * Custom hook to register a slot with a condition. + * This is useful for conditionally rendering components in specific slots. + */ +export const useRegisterSlot = <T extends SlotName>({ + slotName, + id, + Component, + label, + condition = true, +}: UseRegisterSlotProps<T>) => { + const { registerSlot, unregisterSlot } = useSlotContext() + + useEffect(() => { + if (condition) { + registerSlot({ slotName, id, Component, label }) + } else { + unregisterSlot(slotName, id) + } + + return () => { + unregisterSlot(slotName, id) + } + }, [condition, registerSlot, unregisterSlot, slotName, Component, label, id]) +} diff --git a/apps/web/src/components/tx-flow/slots/hooks/useSlot.ts b/apps/web/src/components/tx-flow/slots/hooks/useSlot.ts new file mode 100644 index 0000000000..dba9eb6ce3 --- /dev/null +++ b/apps/web/src/components/tx-flow/slots/hooks/useSlot.ts @@ -0,0 +1,9 @@ +import { useMemo } from 'react' +import type { SlotName, SlotItem } from '../SlotProvider' +import { useSlotContext } from './useSlotContext' + +export const useSlot = <T extends SlotName>(slotName: T, id?: string): SlotItem<T>[] => { + const { getSlot } = useSlotContext() + const slot = useMemo(() => getSlot(slotName, id), [getSlot, slotName, id]) + return slot +} diff --git a/apps/web/src/components/tx-flow/slots/hooks/useSlotContext.ts b/apps/web/src/components/tx-flow/slots/hooks/useSlotContext.ts new file mode 100644 index 0000000000..3124300c90 --- /dev/null +++ b/apps/web/src/components/tx-flow/slots/hooks/useSlotContext.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react' +import { SlotContext } from '../SlotProvider' + +export const useSlotContext = () => { + const context = useContext(SlotContext) + if (!context) { + throw new Error('useSlotContext must be used within a SlotProvider') + } + return context +} diff --git a/apps/web/src/components/tx-flow/slots/hooks/useSlotIds.ts b/apps/web/src/components/tx-flow/slots/hooks/useSlotIds.ts new file mode 100644 index 0000000000..3e1cf3a4fa --- /dev/null +++ b/apps/web/src/components/tx-flow/slots/hooks/useSlotIds.ts @@ -0,0 +1,9 @@ +import { useMemo } from 'react' +import type { SlotName } from '../SlotProvider' +import { useSlotContext } from './useSlotContext' + +export const useSlotIds = <T extends SlotName>(slotName: T): string[] => { + const { getSlotIds } = useSlotContext() + const slotIds = useMemo(() => getSlotIds(slotName), [getSlotIds, slotName]) + return slotIds +} diff --git a/apps/web/src/components/tx-flow/slots/index.ts b/apps/web/src/components/tx-flow/slots/index.ts new file mode 100644 index 0000000000..7159826479 --- /dev/null +++ b/apps/web/src/components/tx-flow/slots/index.ts @@ -0,0 +1,4 @@ +export * from './Slot' +export * from './SlotProvider' +export * from './hooks' +export * from './withSlot' diff --git a/apps/web/src/components/tx-flow/slots/withSlot.tsx b/apps/web/src/components/tx-flow/slots/withSlot.tsx new file mode 100644 index 0000000000..0bce6dbefe --- /dev/null +++ b/apps/web/src/components/tx-flow/slots/withSlot.tsx @@ -0,0 +1,28 @@ +import type { PropsWithChildren } from 'react' +import type { SlotName } from './SlotProvider' +import { useRegisterSlot, type UseRegisterSlotProps } from './hooks' +import type { FEATURES } from '@/utils/featureToggled' +import { useHasFeature } from '@/hooks/useChains' + +/** + * Higher-order component to register a slot with a condition. + * This is useful for conditionally rendering components in specific slots. + */ +export const withSlot = <T extends SlotName>({ + Component, + label, + slotName, + id, + useSlotCondition = () => true, + feature, +}: Omit<UseRegisterSlotProps<T>, 'condition'> & { + useSlotCondition?: () => boolean + feature?: FEATURES +}) => { + return ({ children }: PropsWithChildren) => { + const shouldRegisterSlot = useSlotCondition() + const isFeatureEnabled = feature ? useHasFeature(feature) : true + useRegisterSlot({ slotName, id, Component, label, condition: shouldRegisterSlot && isFeatureEnabled }) + return children + } +} diff --git a/apps/web/src/components/tx-flow/useTxStepper.tsx b/apps/web/src/components/tx-flow/useTxStepper.tsx index b91900a37a..c7f6cf0cd4 100644 --- a/apps/web/src/components/tx-flow/useTxStepper.tsx +++ b/apps/web/src/components/tx-flow/useTxStepper.tsx @@ -6,8 +6,9 @@ const useTxStepper = <T extends unknown>(initialData: T, eventCategory?: string) const [data, setData] = useState<T>(initialData) const nextStep = useCallback( - (entireData: T) => { - setData(entireData) + (entireData?: T) => { + if (entireData) setData(entireData) + setStep((prevStep) => { if (eventCategory) { trackEvent({ action: MODAL_NAVIGATION.Next, category: eventCategory, label: prevStep }) diff --git a/apps/web/src/components/tx/AdvancedParams/AdvancedParamsForm.tsx b/apps/web/src/components/tx/AdvancedParams/AdvancedParamsForm.tsx index 516bdae5e2..53545dcddf 100644 --- a/apps/web/src/components/tx/AdvancedParams/AdvancedParamsForm.tsx +++ b/apps/web/src/components/tx/AdvancedParams/AdvancedParamsForm.tsx @@ -1,14 +1,15 @@ import { type SyntheticEvent } from 'react' import { Button, DialogActions, FormControl, Grid, Typography, DialogContent } from '@mui/material' import { FormProvider, useForm } from 'react-hook-form' -import { safeFormatUnits, safeParseUnits } from '@/utils/formatters' -import { FLOAT_REGEX } from '@/utils/validation' +import { safeFormatUnits, safeParseUnits } from '@safe-global/utils/utils/formatters' +import { FLOAT_REGEX } from '@safe-global/utils/utils/validation' import ModalDialog from '@/components/common/ModalDialog' import { AdvancedField, type AdvancedParameters } from './types' import GasLimitInput from './GasLimitInput' import ExternalLink from '@/components/common/ExternalLink' import NumberField from '@/components/common/NumberField' -import { HelpCenterArticle } from '@/config/constants' + +import { HelpCenterArticle } from '@safe-global/utils/config/constants' type AdvancedParamsFormProps = { params: AdvancedParameters diff --git a/apps/web/src/components/tx/AdvancedParams/index.tsx b/apps/web/src/components/tx/AdvancedParams/index.tsx index c5d60f46f1..080adee036 100644 --- a/apps/web/src/components/tx/AdvancedParams/index.tsx +++ b/apps/web/src/components/tx/AdvancedParams/index.tsx @@ -1,10 +1,10 @@ import GasParams from '@/components/tx/GasParams' import { useHasFeature } from '@/hooks/useChains' import { MODALS_EVENTS, trackEvent } from '@/services/analytics' -import { FEATURES } from '@/utils/chains' import { useState } from 'react' import AdvancedParamsForm from './AdvancedParamsForm' import { type AdvancedParameters } from './types' +import { FEATURES } from '@safe-global/utils/utils/chains' type Props = { params: AdvancedParameters diff --git a/apps/web/src/components/tx/AdvancedParams/useUserNonce.ts b/apps/web/src/components/tx/AdvancedParams/useUserNonce.ts index a5524cb528..48aa9a20ec 100644 --- a/apps/web/src/components/tx/AdvancedParams/useUserNonce.ts +++ b/apps/web/src/components/tx/AdvancedParams/useUserNonce.ts @@ -1,4 +1,4 @@ -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useWallet from '@/hooks/wallets/useWallet' import { getUserNonce } from '@/hooks/wallets/web3' diff --git a/apps/web/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx b/apps/web/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx index 04de8962d7..ebd2f5ada4 100644 --- a/apps/web/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx +++ b/apps/web/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx @@ -2,16 +2,16 @@ import { safeSignatureBuilder, safeTxBuilder } from '@/tests/builders/safeTx' import { act, fireEvent, getAllByTitle, render, waitFor } from '@/tests/test-utils' import ApprovalEditor from '.' import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' -import { OperationType } from '@safe-global/safe-core-sdk-types' +import { OperationType } from '@safe-global/types-kit' import * as approvalInfos from '@/components/tx/ApprovalEditor/hooks/useApprovalInfos' import { createMockSafeTransaction } from '@/tests/transactions' import { faker } from '@faker-js/faker' import { encodeMultiSendData } from '@safe-global/protocol-kit' -import { ERC20__factory, Multi_send__factory } from '@/types/contracts' +import { ERC20__factory, Multi_send__factory } from '@safe-global/utils/types/contracts' import { getAndValidateSafeSDK } from '@/services/tx/tx-sender/sdk' import { parseUnits } from 'ethers' -import { checksumAddress } from '@/utils/addresses' - +import { checksumAddress } from '@safe-global/utils/utils/addresses' +import { type Balances } from '@safe-global/store/gateway/AUTO_GENERATED/balances' jest.mock('@/services/tx/tx-sender/sdk', () => ({ getAndValidateSafeSDK: jest.fn().mockReturnValue({ createTransaction: jest.fn(), @@ -164,28 +164,30 @@ describe('ApprovalEditor', () => { operation: OperationType.DelegateCall, }) + const mockBalances: Balances = { + fiatTotal: '0', + items: [ + { + balance: '10', + tokenInfo: { + address: tokenAddress, + decimals: 18, + logoUri: 'someurl', + name: 'Test token', + symbol: 'TST', + type: TokenType.ERC20, + }, + fiatBalance: '10', + fiatConversion: '1', + }, + ], + } + const result = render(<ApprovalEditor safeTransaction={mockSafeTx} />, { initialReduxState: { balances: { + data: mockBalances, loading: false, - data: { - fiatTotal: '0', - items: [ - { - balance: '10', - tokenInfo: { - address: tokenAddress, - decimals: 18, - logoUri: 'someurl', - name: 'Test token', - symbol: 'TST', - type: TokenType.ERC20, - }, - fiatBalance: '10', - fiatConversion: '1', - }, - ], - }, }, }, }) @@ -272,28 +274,30 @@ describe('ApprovalEditor', () => { operation: OperationType.DelegateCall, }) + const mockBalances: Balances = { + fiatTotal: '0', + items: [ + { + balance: '10', + tokenInfo: { + address: tokenAddress, + decimals: 18, + logoUri: 'someurl', + name: 'Test token', + symbol: 'TST', + type: TokenType.ERC20, + }, + fiatBalance: '10', + fiatConversion: '1', + }, + ], + } + const result = render(<ApprovalEditor safeTransaction={mockSafeTx} />, { initialReduxState: { balances: { + data: mockBalances, loading: false, - data: { - fiatTotal: '0', - items: [ - { - balance: '10', - tokenInfo: { - address: tokenAddress, - decimals: 18, - logoUri: 'someurl', - name: 'Test token', - symbol: 'TST', - type: TokenType.ERC20, - }, - fiatBalance: '10', - fiatConversion: '1', - }, - ], - }, }, }, }) diff --git a/apps/web/src/components/tx/ApprovalEditor/ApprovalItem.tsx b/apps/web/src/components/tx/ApprovalEditor/ApprovalItem.tsx index c11f6c0a73..085943c793 100644 --- a/apps/web/src/components/tx/ApprovalEditor/ApprovalItem.tsx +++ b/apps/web/src/components/tx/ApprovalEditor/ApprovalItem.tsx @@ -1,17 +1,22 @@ import TokenIcon from '@/components/common/TokenIcon' import css from '@/components/tx/ApprovalEditor/styles.module.css' -import type { Approval } from '@/services/security/modules/ApprovalModule' +import type { Approval } from '@safe-global/utils/services/security/modules/ApprovalModule' import { Box, Stack, Typography } from '@mui/material' import { TokenType } from '@safe-global/safe-gateway-typescript-sdk/dist/types/common' import type { ApprovalInfo } from './hooks/useApprovalInfos' import { PSEUDO_APPROVAL_VALUES } from './utils/approvals' -import { formatAmountPrecise } from '@/utils/formatNumber' +import { formatAmountPrecise } from '@safe-global/utils/utils/formatNumber' +import { type Balance } from '@safe-global/store/gateway/AUTO_GENERATED/balances' -export const approvalMethodDescription: Record<Approval['method'], (symbol: string, type?: TokenType) => string> = { - approve: (symbol: string, type?: TokenType) => +export const approvalMethodDescription: Record< + Approval['method'], + (symbol: string, type?: Balance['tokenInfo']['type']) => string +> = { + approve: (symbol: string, type?: Balance['tokenInfo']['type']) => type === TokenType.ERC721 ? `Allow to transfer ${symbol}` : `Set ${symbol} allowance to`, increaseAllowance: (symbol: string) => `Increase ${symbol} allowance by`, Permit2: (symbol: string) => `Give permission to spend ${symbol}`, + Permit: (symbol: string) => `Give permission to spend ${symbol}`, } const ApprovalItem = ({ @@ -50,7 +55,7 @@ const ApprovalItem = ({ ) : ( <Typography data-testid="token-amount"> {tokenInfo.type === TokenType.ERC20 - ? formatAmountPrecise(amount, tokenInfo.decimals) + ? formatAmountPrecise(amount, tokenInfo.decimals ?? 0) : `#${rawAmount.toString()}`} </Typography> )} diff --git a/apps/web/src/components/tx/ApprovalEditor/ApprovalValueField.tsx b/apps/web/src/components/tx/ApprovalEditor/ApprovalValueField.tsx index f8fe9687d2..5c59d935b0 100644 --- a/apps/web/src/components/tx/ApprovalEditor/ApprovalValueField.tsx +++ b/apps/web/src/components/tx/ApprovalEditor/ApprovalValueField.tsx @@ -1,5 +1,5 @@ import NumberField from '@/components/common/NumberField' -import { validateAmount, validateDecimalLength } from '@/utils/validation' +import { validateAmount, validateDecimalLength } from '@safe-global/utils/utils/validation' import { Autocomplete, type MenuItemProps, MenuItem } from '@mui/material' import { useController, useFormContext } from 'react-hook-form' import type { ApprovalInfo } from './hooks/useApprovalInfos' diff --git a/apps/web/src/components/tx/ApprovalEditor/SpenderField.tsx b/apps/web/src/components/tx/ApprovalEditor/SpenderField.tsx index fe6e6901be..241691a6cb 100644 --- a/apps/web/src/components/tx/ApprovalEditor/SpenderField.tsx +++ b/apps/web/src/components/tx/ApprovalEditor/SpenderField.tsx @@ -2,7 +2,7 @@ import EthHashInfo from '@/components/common/EthHashInfo' import { Stack, Typography, useMediaQuery, useTheme } from '@mui/material' import css from './styles.module.css' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useChainId from '@/hooks/useChainId' import { getContract } from '@safe-global/safe-gateway-typescript-sdk' diff --git a/apps/web/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.test.ts b/apps/web/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.test.ts index 1e2d51bc58..bdaebb0e97 100644 --- a/apps/web/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.test.ts +++ b/apps/web/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.test.ts @@ -1,17 +1,21 @@ +import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { renderHook } from '@/tests/test-utils' -import { zeroPadValue, Interface } from 'ethers' +import { Interface, zeroPadValue } from 'ethers' import { type ApprovalInfo, useApprovalInfos } from '@/components/tx/ApprovalEditor/hooks/useApprovalInfos' import { waitFor } from '@testing-library/react' import { createMockSafeTransaction } from '@/tests/transactions' -import { OperationType } from '@safe-global/safe-core-sdk-types' -import { ERC20__factory, Multi_send__factory } from '@/types/contracts' +import { OperationType } from '@safe-global/types-kit' +import { ERC20__factory, Multi_send__factory } from '@safe-global/utils/types/contracts' import * as balances from '@/hooks/useBalances' -import { type EIP712TypedData, TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' import * as getTokenInfo from '@/utils/tokens' import { faker } from '@faker-js/faker' import { PSEUDO_APPROVAL_VALUES } from '../utils/approvals' import { encodeMultiSendData } from '@safe-global/protocol-kit' -import { checksumAddress } from '@/utils/addresses' +import { checksumAddress } from '@safe-global/utils/utils/addresses' +import { UNLIMITED_PERMIT2_AMOUNT } from '@safe-global/utils/utils/tokens' +import { type Balance } from '@safe-global/store/gateway/AUTO_GENERATED/balances' +import { type Erc20Token } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' const ERC20_INTERFACE = ERC20__factory.createInterface() @@ -234,9 +238,106 @@ describe('useApprovalInfos', () => { }) }) + it('returns an ApprovalInfo for Permit (EIP 2612) message', async () => { + const tokenAddress = faker.finance.ethereumAddress() + const mockBalanceItem = { + balance: '40000', + fiatBalance: '1', + fiatConversion: '1', + tokenInfo: { + address: tokenAddress, + decimals: 2, + logoUri: '', + name: 'USDC', + symbol: 'USDC', + type: TokenType.ERC20, + }, + } as const + const spenderAddress = faker.finance.ethereumAddress() + const mockMessage: TypedData = { + types: { + EIP712Domain: [ + { + name: 'name', + type: 'string', + }, + { + name: 'chainId', + type: 'uint256', + }, + { + name: 'verifyingContract', + type: 'address', + }, + ], + Permit: [ + { + name: 'owner', + type: 'address', + }, + { + name: 'spender', + type: 'address', + }, + { + name: 'value', + type: 'uint256', + }, + { + name: 'nonce', + type: 'uint256', + }, + { + name: 'deadline', + type: 'uint256', + }, + ], + }, + domain: { + name: 'USDC', + chainId: 137, + verifyingContract: tokenAddress, + }, + message: { + owner: faker.finance.ethereumAddress(), + spender: spenderAddress, + value: BigInt(2000), + nonce: BigInt(0), + deadline: BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), + }, + primaryType: 'Permit', + } as const + + jest + .spyOn(balances, 'default') + .mockReturnValue({ balances: { fiatTotal: '0', items: [mockBalanceItem] }, error: undefined, loading: false }) + + const { result } = renderHook(() => useApprovalInfos({ safeMessage: mockMessage })) + + const mockApproval: ApprovalInfo = { + amount: BigInt(2000), + amountFormatted: '20', + spender: spenderAddress, + tokenAddress: tokenAddress.toLowerCase(), + tokenInfo: expect.objectContaining({ + address: tokenAddress, + decimals: 2, + symbol: 'USDC', + type: TokenType.ERC20, + }), + method: 'Permit', + transactionIndex: 0, + } + + await waitFor(() => { + expect(result.current).toEqual([[mockApproval], undefined, false]) + }) + }) + it('returns an ApprovalInfo for Permit2 PermitSingle message', async () => { const spenderAddress = faker.finance.ethereumAddress() - const mockMessage: EIP712TypedData = { + const mockMessage: TypedData = { + primaryType: 'PermitSingle', types: { EIP712Domain: [ { @@ -305,7 +406,7 @@ describe('useApprovalInfos', () => { const { result } = renderHook(() => useApprovalInfos({ safeMessage: mockMessage })) const mockApproval: ApprovalInfo = { - amount: BigInt(getTokenInfo.UNLIMITED_PERMIT2_AMOUNT), + amount: BigInt(UNLIMITED_PERMIT2_AMOUNT), amountFormatted: PSEUDO_APPROVAL_VALUES.UNLIMITED, spender: spenderAddress, tokenAddress: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'.toLowerCase(), @@ -324,7 +425,8 @@ describe('useApprovalInfos', () => { const token1 = faker.finance.ethereumAddress() const token2 = faker.finance.ethereumAddress() - const mockMessage: EIP712TypedData = { + const mockMessage: TypedData = { + primaryType: 'PermitBatch', types: { EIP712Domain: [ { @@ -402,7 +504,7 @@ describe('useApprovalInfos', () => { const expectedApprovals: ApprovalInfo[] = [ { - amount: BigInt(getTokenInfo.UNLIMITED_PERMIT2_AMOUNT), + amount: BigInt(UNLIMITED_PERMIT2_AMOUNT), amountFormatted: PSEUDO_APPROVAL_VALUES.UNLIMITED, spender: spenderAddress, tokenAddress: token1.toLowerCase(), @@ -411,7 +513,7 @@ describe('useApprovalInfos', () => { transactionIndex: 0, }, { - amount: BigInt(getTokenInfo.UNLIMITED_PERMIT2_AMOUNT), + amount: BigInt(UNLIMITED_PERMIT2_AMOUNT), amountFormatted: PSEUDO_APPROVAL_VALUES.UNLIMITED, spender: spenderAddress, tokenAddress: token2.toLowerCase(), @@ -427,7 +529,7 @@ describe('useApprovalInfos', () => { }) it('returns an ApprovalInfo with token infos if the token exists in balances', async () => { - const mockBalanceItem = { + const mockBalanceItem: Balance = { balance: '40', fiatBalance: '40', fiatConversion: '1', @@ -470,7 +572,7 @@ describe('useApprovalInfos', () => { }) it('fetches token info for an approval if its missing', async () => { - const mockTokenInfo = { + const mockTokenInfo: Omit<Erc20Token, 'name' | 'logoUri'> = { address: '0x0000000000000000000000000000000000000123', symbol: 'HT', decimals: 18, @@ -478,7 +580,7 @@ describe('useApprovalInfos', () => { } const fetchMock = jest .spyOn(getTokenInfo, 'getERC20TokenInfoOnChain') - .mockReturnValue(Promise.resolve(mockTokenInfo)) + .mockReturnValue(Promise.resolve([mockTokenInfo])) const testInterface = new Interface(['function approve(address, uint256)']) const mockSafeTx = createMockSafeTransaction({ diff --git a/apps/web/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.ts b/apps/web/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.ts index 4a010e8c83..db78e04c37 100644 --- a/apps/web/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.ts +++ b/apps/web/src/components/tx/ApprovalEditor/hooks/useApprovalInfos.ts @@ -1,22 +1,19 @@ -import useAsync from '@/hooks/useAsync' +import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' +import useAsync from '@safe-global/utils/hooks/useAsync' import useBalances from '@/hooks/useBalances' -import { type Approval, ApprovalModule } from '@/services/security/modules/ApprovalModule' -import { sameAddress } from '@/utils/addresses' -import { - getERC20TokenInfoOnChain, - getErc721Symbol, - isErc721Token, - UNLIMITED_APPROVAL_AMOUNT, - UNLIMITED_PERMIT2_AMOUNT, -} from '@/utils/tokens' -import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' -import { type EIP712TypedData, type TokenInfo, TokenType } from '@safe-global/safe-gateway-typescript-sdk' -import { formatUnits } from 'ethers' +import { type Approval, ApprovalModule } from '@safe-global/utils/services/security/modules/ApprovalModule' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import { getERC20TokenInfoOnChain, getErc721Symbol, isErc721Token } from '@/utils/tokens' +import { type SafeTransaction } from '@safe-global/types-kit' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' import { useMemo } from 'react' import { PSEUDO_APPROVAL_VALUES } from '../utils/approvals' +import { safeFormatUnits } from '@safe-global/utils/utils/formatters' +import { UNLIMITED_APPROVAL_AMOUNT, UNLIMITED_PERMIT2_AMOUNT } from '@safe-global/utils/utils/tokens' +import { type Balance } from '@safe-global/store/gateway/AUTO_GENERATED/balances' export type ApprovalInfo = { - tokenInfo: (Omit<TokenInfo, 'logoUri' | 'name'> & { logoUri?: string }) | undefined + tokenInfo: (Omit<Balance['tokenInfo'], 'logoUri' | 'name'> & { logoUri?: string }) | undefined tokenAddress: string spender: any amount: any @@ -26,11 +23,13 @@ export type ApprovalInfo = { transactionIndex: number } +const DEFAULT_DECIMALS = 18 + const ApprovalModuleInstance = new ApprovalModule() export const useApprovalInfos = (payload: { safeTransaction?: SafeTransaction - safeMessage?: EIP712TypedData + safeMessage?: TypedData }): [ApprovalInfo[] | undefined, Error | undefined, boolean] => { const { safeTransaction, safeMessage } = payload const { balances } = useBalances() @@ -51,13 +50,13 @@ export const useApprovalInfos = (payload: { return Promise.all( approvals.payload.map(async (approval) => { - let tokenInfo: Omit<TokenInfo, 'name' | 'logoUri'> | undefined = balances.items.find((item) => + let tokenInfo: Omit<Balance['tokenInfo'], 'name' | 'logoUri'> | undefined = balances.items.find((item) => sameAddress(item.tokenInfo.address, approval.tokenAddress), )?.tokenInfo if (!tokenInfo) { try { - tokenInfo = await getERC20TokenInfoOnChain(approval.tokenAddress) + tokenInfo = (await getERC20TokenInfoOnChain(approval.tokenAddress))?.[0] } catch (e) { const isErc721 = await isErc721Token(approval.tokenAddress) const symbol = await getErc721Symbol(approval.tokenAddress) @@ -74,7 +73,7 @@ export const useApprovalInfos = (payload: { const amountFormatted = UNLIMITED_APPROVAL_AMOUNT == approval.amount || UNLIMITED_PERMIT2_AMOUNT == approval.amount ? PSEUDO_APPROVAL_VALUES.UNLIMITED - : formatUnits(approval.amount, tokenInfo?.decimals) + : safeFormatUnits(approval.amount, tokenInfo?.decimals ?? DEFAULT_DECIMALS) return { ...approval, tokenInfo, amountFormatted } }), diff --git a/apps/web/src/components/tx/ApprovalEditor/index.tsx b/apps/web/src/components/tx/ApprovalEditor/index.tsx index 4db1b58768..4f07244e6e 100644 --- a/apps/web/src/components/tx/ApprovalEditor/index.tsx +++ b/apps/web/src/components/tx/ApprovalEditor/index.tsx @@ -1,10 +1,11 @@ +import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import Approvals from '@/components/tx/ApprovalEditor/Approvals' import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' import { decodeSafeTxToBaseTransactions } from '@/utils/transactions' import { Alert, Box, Skeleton, Typography } from '@mui/material' -import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' -import { type EIP712TypedData, TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeTransaction } from '@safe-global/types-kit' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' import { useContext } from 'react' import { ApprovalEditorForm } from './ApprovalEditorForm' import { useApprovalInfos } from './hooks/useApprovalInfos' @@ -36,7 +37,7 @@ export const ApprovalEditor = ({ safeMessage, }: { safeTransaction?: SafeTransaction - safeMessage?: EIP712TypedData + safeMessage?: TypedData }) => { const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) const [readableApprovals, error, loading] = useApprovalInfos({ safeTransaction, safeMessage }) diff --git a/apps/web/src/components/tx/ApprovalEditor/utils/approvals.ts b/apps/web/src/components/tx/ApprovalEditor/utils/approvals.ts index d96b434ca6..26fd298ac3 100644 --- a/apps/web/src/components/tx/ApprovalEditor/utils/approvals.ts +++ b/apps/web/src/components/tx/ApprovalEditor/utils/approvals.ts @@ -1,13 +1,14 @@ -import { ERC20__factory } from '@/types/contracts' -import { UNLIMITED_APPROVAL_AMOUNT } from '@/utils/tokens' import type { BaseTransaction } from '@safe-global/safe-apps-sdk' import type { DecodedDataResponse } from '@safe-global/safe-gateway-typescript-sdk' -import { parseUnits, id } from 'ethers' +import { parseUnits } from 'ethers' import { EMPTY_DATA } from '@safe-global/protocol-kit/dist/src/utils/constants' import { type ApprovalInfo } from '../hooks/useApprovalInfos' - -export const APPROVAL_SIGNATURE_HASH = id('approve(address,uint256)').slice(0, 10) -export const INCREASE_ALLOWANCE_SIGNATURE_HASH = id('increaseAllowance(address,uint256)').slice(0, 10) +import { UNLIMITED_APPROVAL_AMOUNT } from '@safe-global/utils/utils/tokens' +import { + APPROVAL_SIGNATURE_HASH, + ERC20_INTERFACE, + INCREASE_ALLOWANCE_SIGNATURE_HASH, +} from '@safe-global/utils/components/tx/ApprovalEditor/utils/approvals' const MULTISEND_METHOD = 'multiSend' @@ -18,8 +19,6 @@ const TRANSACTIONS_PARAM = 'transactions' const ADDRESS_TYPE = 'address' const UINT256_TYPE = 'uint256' -const ERC20_INTERFACE = ERC20__factory.createInterface() - export enum PSEUDO_APPROVAL_VALUES { UNLIMITED = 'Unlimited amount', } @@ -98,7 +97,7 @@ export const updateApprovalTxs = ( return tx } const decimals = approvalInfo.tokenInfo.decimals - const newAmountWei = parseApprovalAmount(newApproval, decimals) + const newAmountWei = parseApprovalAmount(newApproval, decimals ?? 0) if (tx.data.startsWith(APPROVAL_SIGNATURE_HASH)) { return { to: approvalInfo.tokenAddress, diff --git a/apps/web/src/components/tx/DecodedTx/HelpTooltip.tsx b/apps/web/src/components/tx/ColorCodedTxAccordion/HelpTooltip.tsx similarity index 62% rename from apps/web/src/components/tx/DecodedTx/HelpTooltip.tsx rename to apps/web/src/components/tx/ColorCodedTxAccordion/HelpTooltip.tsx index a979bc26ac..a102e2fc85 100644 --- a/apps/web/src/components/tx/DecodedTx/HelpTooltip.tsx +++ b/apps/web/src/components/tx/ColorCodedTxAccordion/HelpTooltip.tsx @@ -1,17 +1,15 @@ import { Tooltip, SvgIcon } from '@mui/material' import InfoIcon from '@/public/images/notifications/info.svg' import ExternalLink from '@/components/common/ExternalLink' -import { HelpCenterArticle } from '@/config/constants' -const HelpToolTip = () => ( +import { HelpCenterArticle } from '@safe-global/utils/config/constants' + +const HelpTooltip = () => ( <Tooltip title={ <> - Learn more about{' '} - <ExternalLink href={HelpCenterArticle.ADVANCED_PARAMS} title="Learn more about advanced details"> - advanced details - </ExternalLink> - . + Always verify transaction details.{' '} + <ExternalLink href={HelpCenterArticle.VERIFY_TX_DETAILS}>Learn more</ExternalLink>. </> } arrow @@ -33,4 +31,4 @@ const HelpToolTip = () => ( </Tooltip> ) -export default HelpToolTip +export default HelpTooltip diff --git a/apps/web/src/components/tx/ColorCodedTxAccordion/index.tsx b/apps/web/src/components/tx/ColorCodedTxAccordion/index.tsx new file mode 100644 index 0000000000..d855ef52a1 --- /dev/null +++ b/apps/web/src/components/tx/ColorCodedTxAccordion/index.tsx @@ -0,0 +1,138 @@ +import type { ReactNode } from 'react' +import { type SyntheticEvent, type ReactElement, memo, useMemo } from 'react' +import { isNativeTokenTransfer, isTransferTxInfo } from '@/utils/transaction-guards' +import { + Accordion, + accordionClasses, + AccordionDetails, + AccordionSummary, + accordionSummaryClasses, + Box, + Stack, + styled, + Typography, +} from '@mui/material' +import { TransactionInfoType, type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { trackEvent, MODALS_EVENTS } from '@/services/analytics' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import accordionCss from '@/styles/accordion.module.css' +import HelpTooltip from './HelpTooltip' +import { useDarkMode } from '@/hooks/useDarkMode' + +enum ColorLevel { + info = 'info', + warning = 'warning', + success = 'success', +} + +const TX_INFO_LEVEL = { + [ColorLevel.warning]: [TransactionInfoType.SETTINGS_CHANGE], + [ColorLevel.success]: [ + TransactionInfoType.TRANSFER, + TransactionInfoType.SWAP_TRANSFER, + TransactionInfoType.TWAP_ORDER, + TransactionInfoType.NATIVE_STAKING_DEPOSIT, + ], +} + +const TxInfoColors: Record<ColorLevel, { main: string; mainDark?: string; background: string; border?: string }> = { + [ColorLevel.info]: { main: 'info.dark', background: 'info.background' }, + [ColorLevel.warning]: { main: 'warning.main', background: 'warning.background', border: 'warning.light' }, + [ColorLevel.success]: { + main: 'success.main', + mainDark: 'primary.main', + background: 'background.light', + border: 'success.light', + }, +} + +const getMethodLevel = (txInfo?: TransactionInfoType): ColorLevel => { + if (!txInfo) { + return ColorLevel.info + } + + const methodLevels = Object.keys(TX_INFO_LEVEL) as (keyof typeof TX_INFO_LEVEL)[] + return (methodLevels.find((key) => TX_INFO_LEVEL[key].includes(txInfo)) as ColorLevel) || ColorLevel.info +} + +const toCssVar = (color: string) => `var(--color-${color.replace('.', '-')})` + +const StyledAccordion = styled(Accordion)<{ color?: ColorLevel }>(({ color = ColorLevel.info }) => { + const { main, border, background } = TxInfoColors[color] + return { + [`&.${accordionClasses.expanded}.${accordionClasses.root}, &:hover.${accordionClasses.root}`]: { + borderColor: toCssVar(border || main), + }, + [`&.${accordionClasses.expanded} > * > .${accordionSummaryClasses.root}`]: { + backgroundColor: toCssVar(background), + }, + } +}) + +type DecodedTxProps = { + txInfo?: TransactionDetails['txInfo'] + txData?: TransactionDetails['txData'] + children: ReactNode + defaultExpanded?: boolean +} + +export const Divider = () => ( + <Box + borderBottom="1px solid var(--color-border-light)" + width="calc(100% + 32px)" + my={2} + sx={{ ml: '-16px !important' }} + /> +) + +const onChangeExpand = (_: SyntheticEvent, expanded: boolean) => { + trackEvent({ ...MODALS_EVENTS.TX_DETAILS, label: expanded ? 'Open' : 'Close' }) +} + +const ColorCodedTxAccordion = ({ txInfo, txData, children, defaultExpanded }: DecodedTxProps): ReactElement => { + const isDarkMode = useDarkMode() + const decodedData = txData?.dataDecoded + const level = useMemo(() => getMethodLevel(txInfo?.type), [txInfo?.type]) + const colors = TxInfoColors[level] + + const methodLabel = + txInfo && isTransferTxInfo(txInfo) && isNativeTokenTransfer(txInfo.transferInfo) + ? 'native transfer' + : decodedData?.method + + return ( + <StyledAccordion elevation={0} onChange={onChangeExpand} color={level} defaultExpanded={defaultExpanded}> + <AccordionSummary + data-testid="decoded-tx-summary" + expandIcon={<ExpandMoreIcon />} + className={accordionCss.accordion} + > + <Stack direction="row" justifyContent="space-between" alignItems="center" width="100%"> + <Typography variant="subtitle2" fontWeight={700} data-testid="tx-advanced-details"> + Transaction details + <HelpTooltip /> + </Typography> + + {methodLabel && ( + <Typography + component="span" + variant="body2" + alignContent="center" + color={isDarkMode ? (colors.mainDark ?? colors.main) : colors.main} + py={0.5} + px={1} + borderRadius={0.5} + bgcolor={colors.background} + > + {methodLabel} + </Typography> + )} + </Stack> + </AccordionSummary> + + <AccordionDetails data-testid="decoded-tx-details">{children}</AccordionDetails> + </StyledAccordion> + ) +} + +export default memo(ColorCodedTxAccordion) diff --git a/apps/web/src/components/tx/ConfirmTxDetails/JsonView.tsx b/apps/web/src/components/tx/ConfirmTxDetails/JsonView.tsx new file mode 100644 index 0000000000..4a783b61e3 --- /dev/null +++ b/apps/web/src/components/tx/ConfirmTxDetails/JsonView.tsx @@ -0,0 +1,22 @@ +import { useMemo } from 'react' +import { Stack, Box, Typography } from '@mui/material' +import CopyButton from '@/components/common/CopyButton' + +const containerSx = { backgroundColor: 'background.paper', borderRadius: 1, padding: 2 } +const codeSx = { wordWrap: 'break-word', whiteSpace: 'pre-wrap' } + +export const JsonView = ({ data }: { data: unknown }) => { + const json = useMemo(() => JSON.stringify(data, null, 2), [data]) + + return ( + <Stack sx={containerSx}> + <Box alignSelf="flex-end" m={-1}> + <CopyButton text={json} /> + </Box> + + <Typography variant="caption" component="code" sx={codeSx}> + {json} + </Typography> + </Stack> + ) +} diff --git a/apps/web/src/components/tx/ConfirmTxDetails/NameChip.test.tsx b/apps/web/src/components/tx/ConfirmTxDetails/NameChip.test.tsx new file mode 100644 index 0000000000..57dbf0fc45 --- /dev/null +++ b/apps/web/src/components/tx/ConfirmTxDetails/NameChip.test.tsx @@ -0,0 +1,133 @@ +import NameChip from './NameChip' +import useAddressBook from '@/hooks/useAddressBook' +import { useAddressName } from '@/components/common/NamedAddressInfo' +import { txDataBuilder } from '@/tests/builders/safeTx' +import { render, screen } from '@/tests/test-utils' +import { faker } from '@faker-js/faker' + +// Theme color values +const COLORS = { + ERROR_BACKGROUND: '#FFE6EA', + ERROR_MAIN: '#FF5F72', + BACKGROUND_MAIN: '#F4F4F4', +} as const + +// Mock the hooks +jest.mock('@/hooks/useAddressBook') +jest.mock('@/components/common/NamedAddressInfo') + +describe('NameChip', () => { + const mockUseAddressBook = useAddressBook as jest.MockedFunction<typeof useAddressBook> + const mockUseAddressName = useAddressName as jest.MockedFunction<typeof useAddressName> + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render nothing when no address is provided', () => { + mockUseAddressName.mockReturnValue({ name: undefined, logoUri: undefined, isUnverifiedContract: false }) + render(<NameChip />) + expect(screen.queryByTestId('name-chip')).not.toBeInTheDocument() + }) + + it('should render nothing when address is provided but no name or logo', () => { + const mockAddress = faker.finance.ethereumAddress() + mockUseAddressName.mockReturnValue({ name: undefined, logoUri: undefined, isUnverifiedContract: false }) + mockUseAddressBook.mockReturnValue({}) + + const txData = txDataBuilder() + .with({ + to: { value: mockAddress }, + }) + .build() + + render(<NameChip txData={txData} />) + expect(screen.queryByTestId('name-chip')).not.toBeInTheDocument() + }) + + it('should render with error color for unverified contracts not in address book', () => { + const mockAddress = faker.finance.ethereumAddress() + mockUseAddressName.mockReturnValue({ name: 'Unverified contract', logoUri: undefined, isUnverifiedContract: true }) + mockUseAddressBook.mockReturnValue({}) + + const txData = txDataBuilder() + .with({ + to: { value: mockAddress }, + }) + .build() + + render(<NameChip txData={txData} />) + const chip = screen.getByTestId('name-chip') + expect(chip).toHaveStyle({ backgroundColor: COLORS.ERROR_BACKGROUND }) + expect(chip).toHaveStyle({ color: COLORS.ERROR_MAIN }) + }) + + it('should not render with error color for verified contracts not in address book', () => { + const mockAddress = faker.finance.ethereumAddress() + mockUseAddressName.mockReturnValue({ name: 'Test Contract', logoUri: undefined, isUnverifiedContract: false }) + mockUseAddressBook.mockReturnValue({}) + + const txData = txDataBuilder() + .with({ + to: { value: mockAddress }, + }) + .build() + + render(<NameChip txData={txData} />) + const chip = screen.getByTestId('name-chip') + expect(chip).not.toHaveStyle({ backgroundColor: COLORS.ERROR_BACKGROUND }) + expect(chip).not.toHaveStyle({ color: COLORS.ERROR_MAIN }) + }) + + it('should not render with error color for unverified contracts in address book', () => { + const mockAddress = faker.finance.ethereumAddress() + mockUseAddressName.mockReturnValue({ name: 'Unverified contract', logoUri: undefined, isUnverifiedContract: true }) + mockUseAddressBook.mockReturnValue({ [mockAddress]: 'Address Book Entry' }) + + const txData = txDataBuilder() + .with({ + to: { value: mockAddress }, + }) + .build() + + render(<NameChip txData={txData} />) + const chip = screen.getByTestId('name-chip') + expect(chip).not.toHaveStyle({ backgroundColor: COLORS.ERROR_BACKGROUND }) + expect(chip).not.toHaveStyle({ color: COLORS.ERROR_MAIN }) + }) + + it('should prioritize address book name over txInfo name', () => { + const mockAddress = faker.finance.ethereumAddress() + const addressBookName = 'Address Book Name' + const txInfoName = 'TxInfo Name' + + mockUseAddressName.mockReturnValue({ name: txInfoName, logoUri: undefined, isUnverifiedContract: false }) + mockUseAddressBook.mockReturnValue({ [mockAddress]: addressBookName }) + + const txData = txDataBuilder() + .with({ + to: { value: mockAddress, name: txInfoName }, + }) + .build() + + render(<NameChip txData={txData} />) + expect(screen.getByText(addressBookName)).toBeInTheDocument() + expect(screen.queryByText(txInfoName)).not.toBeInTheDocument() + }) + + it('should display name and logo when provided', () => { + const mockAddress = faker.finance.ethereumAddress() + mockUseAddressName.mockReturnValue({ name: 'Test Contract', logoUri: 'test-logo.png', isUnverifiedContract: false }) + mockUseAddressBook.mockReturnValue({}) + + const txData = txDataBuilder() + .with({ + to: { value: mockAddress }, + }) + .build() + + render(<NameChip txData={txData} />) + expect(screen.getByText('Test Contract')).toBeInTheDocument() + expect(screen.getByRole('presentation')).toHaveAttribute('src', 'test-logo.png') + }) +}) diff --git a/apps/web/src/components/tx/ConfirmTxDetails/NameChip.tsx b/apps/web/src/components/tx/ConfirmTxDetails/NameChip.tsx new file mode 100644 index 0000000000..ea7debd81a --- /dev/null +++ b/apps/web/src/components/tx/ConfirmTxDetails/NameChip.tsx @@ -0,0 +1,39 @@ +import EthHashInfo from '@/components/common/EthHashInfo' +import { useAddressName } from '@/components/common/NamedAddressInfo' +import useAddressBook from '@/hooks/useAddressBook' +import { isCustomTxInfo } from '@/utils/transaction-guards' +import { Chip } from '@mui/material' +import type { TransactionData, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' + +const NameChip = ({ txData, txInfo }: { txData?: TransactionData; txInfo?: TransactionDetails['txInfo'] }) => { + const addressBook = useAddressBook() + const toAddress = txData?.to.value + const customTxInfo = txInfo && isCustomTxInfo(txInfo) ? txInfo : undefined + const toInfo = customTxInfo?.to || txData?.addressInfoIndex?.[txData?.to.value] || txData?.to + const nameFromAb = toAddress !== undefined ? addressBook[toAddress] : undefined + const toName = + nameFromAb || toInfo?.name || (toInfo && 'displayName' in toInfo ? String(toInfo.displayName || '') : undefined) + const toLogo = toInfo?.logoUri + const contractInfo = useAddressName(toAddress, toName) + const name = toName || contractInfo?.name + const logo = toLogo || contractInfo?.logoUri + + const isInAddressBook = !!nameFromAb + const isUntrusted = !isInAddressBook && contractInfo.isUnverifiedContract + + return toAddress && (name || logo) ? ( + <Chip + data-testid="name-chip" + sx={{ + backgroundColor: isUntrusted ? 'error.background' : 'background.paper', + color: isUntrusted ? 'error.main' : undefined, + height: 'unset', + }} + label={ + <EthHashInfo address={toAddress} name={name} customAvatar={logo} showAvatar={!!logo} avatarSize={20} onlyName /> + } + ></Chip> + ) : null +} + +export default NameChip diff --git a/apps/web/src/components/tx/ConfirmTxDetails/Receipt.tsx b/apps/web/src/components/tx/ConfirmTxDetails/Receipt.tsx new file mode 100644 index 0000000000..037630855b --- /dev/null +++ b/apps/web/src/components/tx/ConfirmTxDetails/Receipt.tsx @@ -0,0 +1,201 @@ +import { Fragment, useMemo, type ReactElement } from 'react' +import { Box, Divider, Stack, Typography } from '@mui/material' +import CheckIcon from '@mui/icons-material/Check' +import type { SafeTransaction } from '@safe-global/types-kit' +import { PaperViewToggle } from '../../common/PaperViewToggle' +import EthHashInfo from '@/components/common/EthHashInfo' +import { Operation, type TransactionDetails, type TransactionData } from '@safe-global/safe-gateway-typescript-sdk' +import { HexEncodedData } from '@/components/transactions/HexEncodedData' +import { + useDomainHash, + useMessageHash, + useSafeTxHash, +} from '@/components/transactions/TxDetails/Summary/SafeTxHashDataRow' +import TxDetailsRow from './TxDetailsRow' +import NameChip from './NameChip' +import { isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards' +import { JsonView } from './JsonView' + +type ReceiptProps = { + safeTxData: SafeTransaction['data'] + txData?: TransactionData + txDetails?: TransactionDetails + txInfo?: TransactionDetails['txInfo'] + grid?: boolean + withSignatures?: boolean +} + +const ScrollWrapper = ({ children }: { children: ReactElement | ReactElement[] }) => ( + <Box sx={{ maxHeight: '550px', flex: 1, overflowY: 'auto', px: 2, pt: 1, mt: '0 !important' }}>{children}</Box> +) + +export const Receipt = ({ safeTxData, txData, txDetails, txInfo, grid, withSignatures = false }: ReceiptProps) => { + const safeTxHash = useSafeTxHash({ safeTxData }) + const domainHash = useDomainHash() + const messageHash = useMessageHash({ safeTxData }) + const operation = Number(safeTxData.operation) as Operation + + const ToWrapper = grid ? Box : Fragment + + const confirmations = useMemo(() => { + const detailedExecutionInfo = txDetails?.detailedExecutionInfo + return isMultisigDetailedExecutionInfo(detailedExecutionInfo) ? detailedExecutionInfo.confirmations : [] + }, [txDetails?.detailedExecutionInfo]) + + return ( + <PaperViewToggle activeView={0} leftAlign={grid}> + {[ + { + title: 'Data', + content: ( + <ScrollWrapper> + <Stack spacing={1} divider={<Divider />}> + <TxDetailsRow label="To" grid={grid}> + <ToWrapper> + <NameChip txData={txData} txInfo={txInfo} /> + + <Typography + variant="body2" + mt={grid ? 0.75 : 0} + width={grid ? undefined : '100%'} + sx={{ + '& *': { whiteSpace: 'normal', wordWrap: 'break-word', alignItems: 'flex-start !important' }, + }} + > + <EthHashInfo + address={safeTxData.to} + avatarSize={20} + showPrefix={false} + showName={false} + shortAddress={false} + hasExplorer + showAvatar + highlight4bytes + /> + </Typography> + </ToWrapper> + </TxDetailsRow> + + <TxDetailsRow label="Value" grid={grid}> + {safeTxData.value} + </TxDetailsRow> + + <TxDetailsRow label="Data" grid={grid}> + <Typography variant="body2" width={grid ? '70%' : undefined}> + <HexEncodedData hexData={safeTxData.data} limit={140} /> + </Typography> + </TxDetailsRow> + + <TxDetailsRow label="Operation" grid={grid}> + <Typography variant="body2" display="flex" alignItems="center" gap={0.5}> + {safeTxData.operation} ({operation === Operation.CALL ? 'call' : 'delegate call'}) + {operation === Operation.CALL && <CheckIcon color="success" fontSize="inherit" />} + </Typography> + </TxDetailsRow> + + <TxDetailsRow label="SafeTxGas" grid={grid}> + {safeTxData.safeTxGas} + </TxDetailsRow> + + <TxDetailsRow label="BaseGas" grid={grid}> + {safeTxData.baseGas} + </TxDetailsRow> + + <TxDetailsRow label="GasPrice" grid={grid}> + {safeTxData.gasPrice} + </TxDetailsRow> + + <TxDetailsRow label="GasToken" grid={grid}> + <Typography variant="body2"> + <EthHashInfo + address={safeTxData.gasToken} + avatarSize={20} + showPrefix={false} + showName={false} + shortAddress + hasExplorer + /> + </Typography> + </TxDetailsRow> + + <TxDetailsRow label="RefundReceiver" grid={grid}> + <Typography variant="body2"> + <EthHashInfo + address={safeTxData.refundReceiver} + avatarSize={20} + showPrefix={false} + shortAddress + showName={false} + hasExplorer + /> + </Typography> + </TxDetailsRow> + + <TxDetailsRow label="Nonce" grid={grid}> + {safeTxData.nonce} + </TxDetailsRow> + + {withSignatures && + confirmations?.map( + ({ signature }, index) => + !!signature && ( + <TxDetailsRow + data-testid="tx-signature" + label={`Signature ${index + 1}`} + key={`signature-${index}`} + grid={grid} + > + <Typography variant="body2" width={grid ? '70%' : undefined}> + <HexEncodedData hexData={signature} highlightFirstBytes={false} limit={30} /> + </Typography> + </TxDetailsRow> + ), + )} + </Stack> + </ScrollWrapper> + ), + }, + { + title: 'Hashes', + content: ( + <ScrollWrapper> + <Stack spacing={1} divider={<Divider />}> + {domainHash && ( + <TxDetailsRow label="Domain hash" grid={grid}> + <Typography variant="body2" width="100%" sx={{ wordWrap: 'break-word' }}> + <HexEncodedData hexData={domainHash} limit={66} highlightFirstBytes={false} /> + </Typography> + </TxDetailsRow> + )} + + {messageHash && ( + <TxDetailsRow label="Message hash" grid={grid}> + <Typography variant="body2" width="100%" sx={{ wordWrap: 'break-word' }}> + <HexEncodedData hexData={messageHash} limit={66} highlightFirstBytes={false} /> + </Typography> + </TxDetailsRow> + )} + + {safeTxHash && ( + <TxDetailsRow label="safeTxHash" grid={grid}> + <Typography variant="body2" width="100%" sx={{ wordWrap: 'break-word' }}> + <HexEncodedData hexData={safeTxHash} limit={66} highlightFirstBytes={false} /> + </Typography> + </TxDetailsRow> + )} + </Stack> + </ScrollWrapper> + ), + }, + { + title: 'JSON', + content: ( + <ScrollWrapper> + <JsonView data={safeTxData} /> + </ScrollWrapper> + ), + }, + ]} + </PaperViewToggle> + ) +} diff --git a/apps/web/src/components/tx/ConfirmTxDetails/TxDetailsRow.tsx b/apps/web/src/components/tx/ConfirmTxDetails/TxDetailsRow.tsx new file mode 100644 index 0000000000..429346fbab --- /dev/null +++ b/apps/web/src/components/tx/ConfirmTxDetails/TxDetailsRow.tsx @@ -0,0 +1,32 @@ +import { Stack, type StackProps, Typography } from '@mui/material' +import type { ReactNode } from 'react' +import isString from 'lodash/isString' +import isNumber from 'lodash/isNumber' +import { gridSx } from '../FieldsGrid' + +const TxDetailsRow = ({ + label, + children, + grid = false, +}: { + label: string + children: ReactNode + direction?: StackProps['direction'] + grid?: boolean +}) => ( + <Stack + gap={1} + direction="row" + justifyContent={grid ? 'flex-start' : 'space-between'} + flexWrap="wrap" + alignItems="center" + > + <Typography variant="body2" color={grid ? 'primary.light' : 'text.secondary'} sx={grid ? gridSx : undefined}> + {label} + </Typography> + + {isString(children) || isNumber(children) ? <Typography variant="body2">{children}</Typography> : children} + </Stack> +) + +export default TxDetailsRow diff --git a/apps/web/src/components/tx/ConfirmTxDetails/index.tsx b/apps/web/src/components/tx/ConfirmTxDetails/index.tsx new file mode 100644 index 0000000000..70251e59fa --- /dev/null +++ b/apps/web/src/components/tx/ConfirmTxDetails/index.tsx @@ -0,0 +1,96 @@ +import TxCard from '@/components/tx-flow/common/TxCard' +import { Grid2 as Grid, Stack, StepIcon, Typography } from '@mui/material' +import { Receipt } from './Receipt' +import ExternalLink from '@/components/common/ExternalLink' +import { useContext } from 'react' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import SignOrExecuteFormV2 from '../SignOrExecuteForm/SignOrExecuteFormV2' +import type { SignOrExecuteProps } from '../SignOrExecuteForm/SignOrExecuteFormV2' +import useTxPreview from '../confirmation-views/useTxPreview' +import Track from '@/components/common/Track' +import { MODALS_EVENTS } from '@/services/analytics' +import useWallet from '@/hooks/wallets/useWallet' +import { isHardwareWallet, isLedgerLive } from '@/utils/wallets' + +const InfoSteps = [ + { + label: 'Review what you will sign', + description: ( + <Typography> + Signing is an irreversible action so make sure you know what you are signing.{' '} + <Track {...MODALS_EVENTS.SIGNING_ARTICLE}> + <ExternalLink href="https://help.safe.global/en/articles/276343-how-to-perform-basic-transactions-checks-on-safe-wallet"> + Read more + </ExternalLink> + </Track> + . + </Typography> + ), + }, + { + label: 'Compare with your wallet', + description: ( + <Typography> + Once you click <b>Sign</b>, the transaction will appear in your signing wallet. Make sure that all the details + match. + </Typography> + ), + }, + { + label: 'Verify with external tools', + description: ( + <Typography>You can additionally cross-verify your transaction data in a third-party tools.</Typography> + ), + }, +] + +const HardwareWalletStep = [ + InfoSteps[1], + { + label: 'Compare with your device', + description: ( + <Typography> + If you're using a hardware wallet with “blind signing”, please compare what you see on your + device with the hashes on the right. + </Typography> + ), + }, + InfoSteps[2], +] + +export const ConfirmTxDetails = (props: SignOrExecuteProps) => { + const { safeTx, txOrigin } = useContext(SafeTxContext) + const [txPreview] = useTxPreview(safeTx?.data) + const wallet = useWallet() + const showHashes = wallet ? isHardwareWallet(wallet) || isLedgerLive(wallet) : false + const steps = showHashes ? HardwareWalletStep : InfoSteps + + if (!safeTx) { + return null + } + + return ( + <TxCard> + <Grid container spacing={2}> + <Grid size={{ xs: 12, sm: 6 }}> + <Stack px={1} gap={6}> + {steps.map(({ label, description }, index) => ( + <Stack key={index} spacing={2} direction="row"> + <StepIcon icon={index + 1} active /> + <Stack spacing={1}> + <Typography fontWeight="bold">{label}</Typography> + {description} + </Stack> + </Stack> + ))} + </Stack> + </Grid> + <Grid size={{ xs: 12, sm: 6 }}> + <Receipt safeTxData={safeTx.data} txData={txPreview?.txData} txInfo={txPreview?.txInfo} /> + </Grid> + </Grid> + + <SignOrExecuteFormV2 origin={txOrigin} isCreation={!props.txId} {...props} /> + </TxCard> + ) +} diff --git a/apps/web/src/components/tx/ConfirmTxReceipt/index.tsx b/apps/web/src/components/tx/ConfirmTxReceipt/index.tsx new file mode 100644 index 0000000000..374c9f0130 --- /dev/null +++ b/apps/web/src/components/tx/ConfirmTxReceipt/index.tsx @@ -0,0 +1,108 @@ +import TxCard from '@/components/tx-flow/common/TxCard' +import { Grid2 as Grid, Stack, StepIcon, Typography } from '@mui/material' +import ExternalLink from '@/components/common/ExternalLink' +import { type PropsWithChildren, useContext } from 'react' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import useTxPreview from '../confirmation-views/useTxPreview' +import Track from '@/components/common/Track' +import { MODALS_EVENTS } from '@/services/analytics' +import useWallet from '@/hooks/wallets/useWallet' +import { isHardwareWallet, isLedgerLive } from '@/utils/wallets' +import { TxFlowStep } from '@/components/tx-flow/TxFlowStep' +import { Receipt } from '../ConfirmTxDetails/Receipt' +import { Slot, SlotName } from '@/components/tx-flow/slots' +import { Sign } from '@/components/tx-flow/actions/Sign' + +const InfoSteps = [ + { + label: 'Review what you will sign', + description: ( + <Typography> + Signing is an irreversible action so make sure you know what you are signing.{' '} + <Track {...MODALS_EVENTS.SIGNING_ARTICLE}> + <ExternalLink href="https://help.safe.global/en/articles/276343-how-to-perform-basic-transactions-checks-on-safe-wallet"> + Read more + </ExternalLink> + </Track> + . + </Typography> + ), + }, + { + label: 'Compare with your wallet', + description: ( + <Typography> + Once you click <b>Sign</b>, the transaction will appear in your signing wallet. Make sure that all the details + match. + </Typography> + ), + }, + { + label: 'Verify with external tools', + description: ( + <Typography>You can additionally cross-verify your transaction data in a third-party tools.</Typography> + ), + }, +] + +const HardwareWalletStep = [ + InfoSteps[1], + { + label: 'Compare with your device', + description: ( + <Typography> + If you're using a hardware wallet with “blind signing”, please compare what you see on your + device with the hashes on the right. + </Typography> + ), + }, + InfoSteps[2], +] + +export const ConfirmTxReceipt = ({ children, onSubmit }: PropsWithChildren<{ onSubmit: () => void }>) => { + const { safeTx } = useContext(SafeTxContext) + const [txPreview] = useTxPreview(safeTx?.data) + const wallet = useWallet() + const showHashes = wallet ? isHardwareWallet(wallet) || isLedgerLive(wallet) : false + const steps = showHashes ? HardwareWalletStep : InfoSteps + + if (!safeTx) { + return false + } + + return ( + <TxFlowStep title="Review details" fixedNonce> + <TxCard> + <Grid container spacing={2}> + <Grid size={{ xs: 12, sm: 6 }}> + <Stack px={1} gap={6}> + {steps.map(({ label, description }, index) => ( + <Stack key={index} spacing={2} direction="row"> + <StepIcon icon={index + 1} active /> + <Stack spacing={1}> + <Typography fontWeight="bold">{label}</Typography> + {description} + </Stack> + </Stack> + ))} + </Stack> + </Grid> + <Grid size={{ xs: 12, sm: 6 }}> + <Receipt safeTxData={safeTx?.data} txData={txPreview?.txData} txInfo={txPreview?.txInfo} /> + </Grid> + </Grid> + + {children} + + <Slot name={SlotName.Submit} onSubmitSuccess={onSubmit}> + <Sign + onSubmitSuccess={onSubmit} + options={[{ id: 'sign', label: 'Sign' }]} + onChange={() => {}} + slotId="sign" + /> + </Slot> + </TxCard> + </TxFlowStep> + ) +} diff --git a/apps/web/src/components/tx/ConfirmationOrder/ConfirmationOrderHeader.tsx b/apps/web/src/components/tx/ConfirmationOrder/ConfirmationOrderHeader.tsx index af7db5340e..119ed08f5b 100644 --- a/apps/web/src/components/tx/ConfirmationOrder/ConfirmationOrderHeader.tsx +++ b/apps/web/src/components/tx/ConfirmationOrder/ConfirmationOrderHeader.tsx @@ -7,10 +7,11 @@ export type InfoBlock = { value: string label: string tokenInfo?: { - decimals: number + decimals?: number | null symbol: string logoUri?: string | null } + chainId?: string } const ConfirmationOrderHeader = ({ blocks, showArrow }: { blocks: [InfoBlock, InfoBlock]; showArrow?: boolean }) => { @@ -33,11 +34,16 @@ const ConfirmationOrderHeader = ({ blocks, showArrow }: { blocks: [InfoBlock, In > {block.tokenInfo && ( <Box width={40} mr={2}> - <TokenIcon size={40} logoUri={block.tokenInfo.logoUri || ''} tokenSymbol={block.tokenInfo.symbol} /> + <TokenIcon + size={40} + logoUri={block.tokenInfo.logoUri || ''} + tokenSymbol={block.tokenInfo.symbol} + chainId={block.chainId} + /> </Box> )} - <Box flex={1}> + <Box data-testid="block-label" flex={1}> <Typography variant="body2" color="primary.light"> {block.label} </Typography> diff --git a/apps/web/src/components/tx/DecodedTx/index.test.tsx b/apps/web/src/components/tx/DecodedTx/index.test.tsx deleted file mode 100644 index 40424e9a9b..0000000000 --- a/apps/web/src/components/tx/DecodedTx/index.test.tsx +++ /dev/null @@ -1,405 +0,0 @@ -import { fireEvent, render } from '@/tests/test-utils' -import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' -import DecodedTx from '.' -import { waitFor } from '@testing-library/react' -import { createMockTransactionDetails } from '@/tests/transactions' -import { - DetailedExecutionInfoType, - SettingsInfoType, - TransactionInfoType, - TransactionTokenType, - TransferDirection, -} from '@safe-global/safe-gateway-typescript-sdk' -import type { DecodedDataResponse, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' - -const txDetails = createMockTransactionDetails({ - txInfo: { - type: TransactionInfoType.SETTINGS_CHANGE, - humanDescription: 'Add new owner 0xd8dA...6045 with threshold 1', - dataDecoded: { - method: 'addOwnerWithThreshold', - parameters: [ - { - name: 'owner', - type: 'address', - value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', - }, - { - name: '_threshold', - type: 'uint256', - value: '1', - }, - ], - }, - settingsInfo: { - type: SettingsInfoType.ADD_OWNER, - owner: { - value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', - name: 'Nevinha', - logoUri: 'http://something.com', - }, - threshold: 1, - }, - }, - txData: { - hexData: - '0x0d582f13000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000000000000000001', - dataDecoded: { - method: 'addOwnerWithThreshold', - parameters: [ - { - name: 'owner', - type: 'address', - value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', - }, - { - name: '_threshold', - type: 'uint256', - value: '1', - }, - ], - }, - to: { - value: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', - name: '', - }, - value: '0', - operation: 0, - trustedDelegateCallTarget: false, - addressInfoIndex: { - '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045': { - value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', - name: 'MetaMultiSigWallet', - }, - }, - }, - detailedExecutionInfo: { - type: DetailedExecutionInfoType.MULTISIG, - submittedAt: 1726064794013, - nonce: 4, - safeTxGas: '0', - baseGas: '0', - gasPrice: '0', - gasToken: '0x0000000000000000000000000000000000000000', - refundReceiver: { - value: '0x0000000000000000000000000000000000000000', - name: 'MetaMultiSigWallet', - }, - safeTxHash: '0x96a96c11b8d013ff5d7a6ce960b22e961046cfa42eff422ac71c1daf6adef2e0', - signers: [ - { - value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', - name: '', - }, - ], - confirmationsRequired: 1, - confirmations: [], - rejectors: [], - trusted: false, - proposer: { - value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', - name: '', - }, - }, -}) -describe('DecodedTx', () => { - it('should render a native transfer', async () => { - const result = render( - <DecodedTx - showMultisend - tx={ - { - data: { - to: '0x474e5Ded6b5D078163BFB8F6dBa355C3aA5478C8', - value: '40737664983361196', - data: '0x', - operation: 0, - baseGas: '0', - gasPrice: '0', - gasToken: '0x0000000000000000000000000000000000000000', - refundReceiver: '0x0000000000000000000000000000000000000000', - nonce: 36, - safeTxGas: '0', - }, - } as SafeTransaction - } - txInfo={{ - type: TransactionInfoType.TRANSFER, - sender: { - value: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', - }, - recipient: { - value: '0x474e5Ded6b5D078163BFB8F6dBa355C3aA5478C8', - }, - direction: TransferDirection.OUTGOING, - transferInfo: { - type: TransactionTokenType.NATIVE_COIN, - value: '40737664983361196', - }, - }} - txData={{ - hexData: '0x', - dataDecoded: undefined, - to: { - value: '0x474e5Ded6b5D078163BFB8F6dBa355C3aA5478C8', - }, - value: '40737664983361196', - operation: 0, - trustedDelegateCallTarget: true, - addressInfoIndex: undefined, - }} - />, - ) - - await waitFor(() => { - expect(result.queryByText('native transfer')).toBeInTheDocument() - }) - - fireEvent.click(result.getByText('Advanced details')) - - await waitFor(() => { - expect(result.queryAllByText('safeTxGas:').length).toBeGreaterThan(0) - expect(result.queryAllByText('Raw data:').length).toBeGreaterThan(0) - }) - }) - - it('should render a transfer with custom data details', async () => { - const result = render( - <DecodedTx - tx={ - { - data: { - to: '0x3430d04E42a722c5Ae52C5Bffbf1F230C2677600', - value: '1000000', - data: '0x000001ad6abfb9ea000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000098000000000000000000000000000000000000000000000000000000000000008e4ee8f0b86000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000000000000cd00000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000808415565b0000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000000f1bd50a00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000004c000000000000000000000000000000000000000000000000000000000000005c0000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000000000000210000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000002556e6973776170563200000000000000000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000000f21a484000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000f164fc0ec4e93095b804a4795bbe1e041497b92a00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000005cf7a000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000010000000000000000000000000000000000000110000000000000000000000000000000000000000ff8513c6b54542145a1b4cf70000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000072a600000000000000000000000000000000000000000000000000000000000000cd00000000000000000000000000000000000000000000000000000000000000020000000000000000000000007e8485cf11c370519793d1c2d0a77bd139fdac38000000000000000000000000fea53c695fdf95cfb34514d916ac236e620201bd0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000f2ed992000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000066ed25970000000000000000000000000000000000000000000000000000000066ed799dd00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f4', - operation: 0, - baseGas: '0', - gasPrice: '0', - gasToken: '0x0000000000000000000000000000000000000000', - refundReceiver: '0x0000000000000000000000000000000000000000', - nonce: 58, - safeTxGas: '0', - }, - } as SafeTransaction - } - txInfo={txDetails.txInfo} - txData={ - { - ...txDetails.txData, - dataDecoded: { - method: '', - } as DecodedDataResponse, - } as TransactionDetails['txData'] - } - showMethodCall - />, - ) - - await waitFor(() => { - expect(result.queryByText('Interacted with:')).toBeInTheDocument() - expect(result.queryByText('Data (hex-encoded)')).toBeInTheDocument() - }) - - fireEvent.click(result.getByText('Advanced details')) - - await waitFor(() => { - expect(result.queryByText('safeTxGas:')).toBeInTheDocument() - expect(result.queryByText('Raw data:')).toBeInTheDocument() - }) - }) - - it('should render an ERC20 transfer', async () => { - const result = render( - <DecodedTx - showMethodCall - showMultisend={false} - tx={ - { - data: { - to: '0x3430d04E42a722c5Ae52C5Bffbf1F230C2677600', - value: '0', - data: '0xa9059cbb000000000000000000000000474e5ded6b5d078163bfb8f6dba355c3aa5478c80000000000000000000000000000000000000000000000008ac7230489e80000', - operation: 0, - baseGas: '0', - gasPrice: '0', - gasToken: '0x0000000000000000000000000000000000000000', - refundReceiver: '0x0000000000000000000000000000000000000000', - nonce: 58, - safeTxGas: '0', - }, - } as SafeTransaction - } - txInfo={txDetails.txInfo} - txData={ - { - ...txDetails.txData, - dataDecoded: { - method: 'transfer', - parameters: [ - { - name: 'to', - type: 'address', - value: '0x474e5Ded6b5D078163BFB8F6dBa355C3aA5478C8', - }, - { - name: 'value', - type: 'uint256', - value: '16745726664999765048', - }, - ], - }, - } as TransactionDetails['txData'] - } - />, - ) - - fireEvent.click(result.getByText('Advanced details')) - - await waitFor(() => { - expect(result.queryByText('transfer')).toBeInTheDocument() - expect(result.queryAllByText('Parameters').length).toBeGreaterThan(0) - expect(result.queryByText('to')).toBeInTheDocument() - expect(result.queryAllByText('address').length).toBeGreaterThan(0) - expect(result.queryByText('0x474e...78C8')).toBeInTheDocument() - expect(result.queryByText('value')).toBeInTheDocument() - expect(result.queryAllByText('uint256').length).toBeGreaterThan(0) - expect(result.queryByText('16745726664999765048')).toBeInTheDocument() - }) - }) - - it('should render a multisend transaction', async () => { - const result = render( - <DecodedTx - tx={ - { - data: { - to: '0x40A2aCCbd92BCA938b02010E17A5b8929b49130D', - value: '0', - data: '0x8d80ff00', - operation: 1, - baseGas: '0', - gasPrice: '0', - gasToken: '0x0000000000000000000000000000000000000000', - refundReceiver: '0x0000000000000000000000000000000000000000', - nonce: 58, - safeTxGas: '0', - }, - } as SafeTransaction - } - txInfo={txDetails.txInfo} - txData={ - { - ...txDetails.txData, - dataDecoded: { - method: 'multiSend', - parameters: [ - { - name: 'transactions', - type: 'bytes', - value: '0x0057f1887a8bf19b14fc0df', - valueDecoded: [ - { - operation: 0, - to: '0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85', - value: '0', - data: '0x42842e0e0000000000000000000', - dataDecoded: { - method: 'safeTransferFrom', - parameters: [ - { - name: 'from', - type: 'address', - value: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', - }, - { - name: 'to', - type: 'address', - value: '0x474e5Ded6b5D078163BFB8F6dBa355C3aA5478C8', - }, - { - name: 'tokenId', - type: 'uint256', - value: '52964617156216674852059480948658573966398315289847646343083345905048987083870', - }, - ], - }, - }, - { - operation: 0, - to: '0xD014e20A75437a4bd0FbB40498FF94e6F337c3e9', - value: '0', - data: '0x42842e0e000000000000000000000000a77de', - dataDecoded: { - method: 'safeTransferFrom', - parameters: [ - { - name: 'from', - type: 'address', - value: '0xA77DE01e157f9f57C7c4A326eeE9C4874D0598b6', - }, - { - name: 'to', - type: 'address', - value: '0x474e5Ded6b5D078163BFB8F6dBa355C3aA5478C8', - }, - { - name: 'tokenId', - type: 'uint256', - value: '412', - }, - ], - }, - }, - ], - }, - ], - }, - } as TransactionDetails['txData'] - } - showMethodCall - showMultisend - />, - ) - - expect(result.queryAllByText('safeTransferFrom').length).toBeGreaterThan(1) - }) - - it('should render a function call without parameters', async () => { - const result = render( - <DecodedTx - tx={ - { - data: { - to: '0xe91d153e0b41518a2ce8dd3d7944fa863463a97d', - value: '5000000000000', - data: '0xd0e30db0', - operation: 0, - baseGas: '0', - gasPrice: '0', - gasToken: '0x0000000000000000000000000000000000000000', - refundReceiver: '0x0000000000000000000000000000000000000000', - nonce: 58, - safeTxGas: '0', - }, - } as SafeTransaction - } - showMultisend={false} - txInfo={txDetails.txInfo} - txData={ - { - ...txDetails.txData, - dataDecoded: { - method: 'deposit', - parameters: [], - }, - } as TransactionDetails['txData'] - } - showMethodCall - />, - ) - - fireEvent.click(result.getByText('Advanced details')) - - expect(result.queryByText('deposit')).toBeInTheDocument() - }) -}) diff --git a/apps/web/src/components/tx/DecodedTx/index.tsx b/apps/web/src/components/tx/DecodedTx/index.tsx deleted file mode 100644 index 7d7f628deb..0000000000 --- a/apps/web/src/components/tx/DecodedTx/index.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { type SyntheticEvent, type ReactElement, memo } from 'react' -import { ErrorBoundary } from '@sentry/react' -import { isCustomTxInfo, isNativeTokenTransfer, isTransferTxInfo } from '@/utils/transaction-guards' -import { Accordion, AccordionDetails, AccordionSummary, Box, Stack } from '@mui/material' -import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' -import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' -import Summary, { PartialSummary } from '@/components/transactions/TxDetails/Summary' -import { trackEvent, MODALS_EVENTS } from '@/services/analytics' -import Multisend from '@/components/transactions/TxDetails/TxData/DecodedData/Multisend' -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData' -import accordionCss from '@/styles/accordion.module.css' -import HelpToolTip from './HelpTooltip' - -type DecodedTxProps = { - tx?: SafeTransaction - txId?: string - txDetails?: TransactionDetails - txInfo?: TransactionDetails['txInfo'] - txData?: TransactionDetails['txData'] - showMultisend?: boolean - showMethodCall?: boolean - showAdvancedDetails?: boolean -} - -export const Divider = () => ( - <Box - borderBottom="1px solid var(--color-border-light)" - width="calc(100% + 32px)" - my={2} - sx={{ ml: '-16px !important' }} - /> -) - -const onChangeExpand = (_: SyntheticEvent, expanded: boolean) => { - trackEvent({ ...MODALS_EVENTS.TX_DETAILS, label: expanded ? 'Open' : 'Close' }) -} - -const DecodedTx = ({ - tx, - txDetails, - txInfo, - txData, - showMultisend = true, - showMethodCall = false, - showAdvancedDetails = true, -}: DecodedTxProps): ReactElement => { - const decodedData = txData?.dataDecoded - const isMultisend = decodedData?.parameters && !!decodedData?.parameters[0]?.valueDecoded - const isMethodCallInAdvanced = showAdvancedDetails && (!showMethodCall || (isMultisend && showMultisend)) - - let toInfo = tx && { - value: tx.data.to, - } - if (txInfo && isCustomTxInfo(txInfo)) { - toInfo = txInfo.to - } - - const decodedDataBlock = <DecodedData txData={txData} toInfo={toInfo} /> - const showDecodedData = isMethodCallInAdvanced && decodedData?.method - - return ( - <Stack spacing={2}> - {!isMethodCallInAdvanced && ( - <Box border="1px solid var(--color-border-light)" borderRadius={1} p={2}> - {decodedDataBlock} - </Box> - )} - - {isMultisend && showMultisend && <Multisend txData={txData} compact />} - - {showAdvancedDetails && ( - <Box> - <Accordion elevation={0} onChange={onChangeExpand} sx={!tx ? { pointerEvents: 'none' } : undefined}> - <AccordionSummary - data-testid="decoded-tx-summary" - expandIcon={<ExpandMoreIcon />} - className={accordionCss.accordion} - > - Advanced details - <HelpToolTip /> - <Box flex={1} /> - {isMethodCallInAdvanced && decodedData?.method} - {txInfo && isTransferTxInfo(txInfo) && isNativeTokenTransfer(txInfo.transferInfo) && ( - <span>native transfer</span> - )} - </AccordionSummary> - - <AccordionDetails data-testid="decoded-tx-details"> - {showDecodedData && ( - <> - {decodedDataBlock} - <Divider /> - </> - )} - - {txDetails && !showDecodedData ? ( - <Summary - txDetails={txDetails} - defaultExpanded - hideDecodedData={isMethodCallInAdvanced && !!decodedData?.method} - /> - ) : ( - tx && ( - <ErrorBoundary> - <PartialSummary safeTx={tx} /> - </ErrorBoundary> - ) - )} - </AccordionDetails> - </Accordion> - </Box> - )} - </Stack> - ) -} - -export default memo(DecodedTx) diff --git a/apps/web/src/components/tx/FieldsGrid/index.tsx b/apps/web/src/components/tx/FieldsGrid/index.tsx index 1627736779..0462cd1f3e 100644 --- a/apps/web/src/components/tx/FieldsGrid/index.tsx +++ b/apps/web/src/components/tx/FieldsGrid/index.tsx @@ -1,36 +1,34 @@ import { type ReactNode } from 'react' import { Grid, Typography } from '@mui/material' -const width = { xl: '25%', lg: '200px', xs: 'auto' } -const minWidth = { xl: '25%', lg: '200px' } -const wrap = { flexWrap: { xl: 'nowrap' } } +export const gridSx = { + width: { xl: '25%', lg: '170px', xs: 'auto' }, + minWidth: '100px', + flexWrap: { xl: 'nowrap' }, +} -const FieldsGrid = ({ title, children }: { title: string | ReactNode; children: ReactNode }) => { +const FieldsGrid = ({ + title, + children, + testId, +}: { + title: string | ReactNode + children: ReactNode + testId?: string +}) => { return ( <Grid container sx={[ { - alignItems: 'center', gap: 1, + flexWrap: gridSx.flexWrap, }, - ...(Array.isArray(wrap) ? wrap : [wrap]), ]} + data-testid={testId} > - <Grid - item - data-testid="tx-row-title" - style={{ wordBreak: 'break-word' }} - sx={{ - width, - minWidth, - }} - > - <Typography - sx={{ - color: 'primary.light', - }} - > + <Grid item data-testid="tx-row-title" style={{ wordBreak: 'break-word' }} sx={gridSx}> + <Typography color="primary.light" variant="body2" component="span"> {title} </Typography> </Grid> diff --git a/apps/web/src/components/tx/GasParams/index.tsx b/apps/web/src/components/tx/GasParams/index.tsx index 28cdbd1445..603f81f32d 100644 --- a/apps/web/src/components/tx/GasParams/index.tsx +++ b/apps/web/src/components/tx/GasParams/index.tsx @@ -5,7 +5,7 @@ import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import WarningIcon from '@/public/images/notifications/warning.svg' import { useCurrentChain } from '@/hooks/useChains' -import { formatVisualAmount } from '@/utils/formatters' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' import { type AdvancedParameters } from '../AdvancedParams/types' import { trackEvent, MODALS_EVENTS } from '@/services/analytics' import classnames from 'classnames' diff --git a/apps/web/src/components/tx/RemainingRelays/index.tsx b/apps/web/src/components/tx/RemainingRelays/index.tsx index 8bd3051463..2187792ad6 100644 --- a/apps/web/src/components/tx/RemainingRelays/index.tsx +++ b/apps/web/src/components/tx/RemainingRelays/index.tsx @@ -3,7 +3,7 @@ import InfoIcon from '@/public/images/notifications/info.svg' import { MAX_DAY_RELAYS } from '@/hooks/useRemainingRelays' import css from '../BalanceInfo/styles.module.css' import type { RelayCountResponse } from '@safe-global/safe-gateway-typescript-sdk' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' const RemainingRelays = ({ relays, tooltip }: { relays?: RelayCountResponse; tooltip?: string }) => { if (!tooltip) { diff --git a/apps/web/src/components/tx/ReviewTransactionV2/ReviewTransactionContent.tsx b/apps/web/src/components/tx/ReviewTransactionV2/ReviewTransactionContent.tsx new file mode 100644 index 0000000000..54c7880f89 --- /dev/null +++ b/apps/web/src/components/tx/ReviewTransactionV2/ReviewTransactionContent.tsx @@ -0,0 +1,128 @@ +import type { PropsWithChildren, ReactElement } from 'react' +import { useCallback, useContext } from 'react' +import madProps from '@/utils/mad-props' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import ErrorMessage from '../ErrorMessage' +import TxCard, { TxCardActions } from '@/components/tx-flow/common/TxCard' +import ConfirmationTitle, { ConfirmationTitleTypes } from '@/components/tx/SignOrExecuteForm/ConfirmationTitle' +import { ErrorBoundary } from '@sentry/react' +import ApprovalEditor from '../ApprovalEditor' +import { BlockaidBalanceChanges } from '../security/blockaid/BlockaidBalanceChange' +import { useApprovalInfos } from '../ApprovalEditor/hooks/useApprovalInfos' +import type { TransactionDetails, TransactionPreview } from '@safe-global/safe-gateway-typescript-sdk' +import NetworkWarning from '@/components/new-safe/create/NetworkWarning' +import ConfirmationView from '../confirmation-views' +import UnknownContractError from '../SignOrExecuteForm/UnknownContractError' +import { TxFlowContext } from '@/components/tx-flow/TxFlowProvider' +import useIsCounterfactualSafe from '@/features/counterfactual/hooks/useIsCounterfactualSafe' +import { Slot, SlotName } from '@/components/tx-flow/slots' +import type { SubmitCallback } from '@/components/tx-flow/TxFlow' +import { Button, CircularProgress } from '@mui/material' +import CheckWallet from '@/components/common/CheckWallet' +import { MODALS_EVENTS, trackEvent } from '@/services/analytics' + +export type ReviewTransactionContentProps = PropsWithChildren<{ onSubmit: SubmitCallback; withDecodedData?: boolean }> + +export const ReviewTransactionContent = ({ + safeTx, + safeTxError, + onSubmit, + children, + txDetails, + txPreview, + withDecodedData = true, +}: ReviewTransactionContentProps & { + safeTx: ReturnType<typeof useSafeTx> + safeTxError: ReturnType<typeof useSafeTxError> + isCreation?: boolean + txDetails?: TransactionDetails + txPreview?: TransactionPreview +}): ReactElement => { + const { willExecute, isBatch, isCreation, isProposing, isRejection, isSubmitLoading, isSubmitDisabled, onlyExecute } = + useContext(TxFlowContext) + + const [readableApprovals] = useApprovalInfos({ safeTransaction: safeTx }) + const isApproval = readableApprovals && readableApprovals.length > 0 + const isCounterfactualSafe = useIsCounterfactualSafe() + + const onContinueClick = useCallback(() => { + trackEvent(MODALS_EVENTS.CONTINUE_CLICKED) + onSubmit() + }, [onSubmit]) + + return ( + <> + <TxCard> + {children} + + <ConfirmationView + isCreation={isCreation} + txDetails={txDetails} + txPreview={txPreview} + safeTx={safeTx} + isBatch={isBatch} + isApproval={isApproval} + withDecodedData={withDecodedData} + > + {!isRejection && ( + <ErrorBoundary fallback={<div>Error parsing data</div>}> + {isApproval && <ApprovalEditor safeTransaction={safeTx} />} + </ErrorBoundary> + )} + </ConfirmationView> + + {!isCounterfactualSafe && !isRejection && <BlockaidBalanceChanges />} + </TxCard> + + <Slot name={SlotName.Feature} /> + + <TxCard> + <ConfirmationTitle + variant={ + isProposing + ? ConfirmationTitleTypes.propose + : willExecute + ? ConfirmationTitleTypes.execute + : ConfirmationTitleTypes.sign + } + isCreation={isCreation} + /> + {safeTxError && ( + <ErrorMessage error={safeTxError}> + This transaction will most likely fail. To save gas costs, avoid confirming the transaction. + </ErrorMessage> + )} + + <Slot name={SlotName.Footer} /> + <NetworkWarning /> + <UnknownContractError txData={txDetails?.txData ?? txPreview?.txData} /> + + <TxCardActions> + {/* Continue button */} + <CheckWallet allowNonOwner={onlyExecute} checkNetwork={!isSubmitDisabled}> + {(isOk) => ( + <Button + data-testid="continue-sign-btn" + variant="contained" + type="submit" + onClick={onContinueClick} + disabled={!isOk || isSubmitDisabled} + sx={{ minWidth: '82px', order: '1', width: ['100%', '100%', '100%', 'auto'] }} + > + {isSubmitLoading ? <CircularProgress size={20} /> : 'Continue'} + </Button> + )} + </CheckWallet> + </TxCardActions> + </TxCard> + </> + ) +} + +const useSafeTx = () => useContext(SafeTxContext).safeTx +const useSafeTxError = () => useContext(SafeTxContext).safeTxError + +export default madProps(ReviewTransactionContent, { + safeTx: useSafeTx, + safeTxError: useSafeTxError, +}) diff --git a/apps/web/src/components/tx/ReviewTransactionV2/ReviewTransactionSkeleton.tsx b/apps/web/src/components/tx/ReviewTransactionV2/ReviewTransactionSkeleton.tsx new file mode 100644 index 0000000000..03a78245a7 --- /dev/null +++ b/apps/web/src/components/tx/ReviewTransactionV2/ReviewTransactionSkeleton.tsx @@ -0,0 +1,13 @@ +import LoadingSpinner, { SpinnerStatus } from '@/components/new-safe/create/steps/StatusStep/LoadingSpinner' +import TxCard from '@/components/tx-flow/common/TxCard' +import { Box } from '@mui/material' + +const ReviewTransactionSkeleton = () => ( + <TxCard> + <Box minHeight="38svh" display="flex" alignItems="center" justifyContent="center" mb={5}> + <LoadingSpinner status={SpinnerStatus.PROCESSING} /> + </Box> + </TxCard> +) + +export default ReviewTransactionSkeleton diff --git a/apps/web/src/components/tx/ReviewTransactionV2/__tests__/ReviewTransactionContent.test.tsx b/apps/web/src/components/tx/ReviewTransactionV2/__tests__/ReviewTransactionContent.test.tsx new file mode 100644 index 0000000000..8830611717 --- /dev/null +++ b/apps/web/src/components/tx/ReviewTransactionV2/__tests__/ReviewTransactionContent.test.tsx @@ -0,0 +1,67 @@ +import ReviewTransaction from '../index' +import { render } from '@/tests/test-utils' +import type { TransactionPreview } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeTxContextParams } from '@/components/tx-flow/SafeTxProvider' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { createSafeTx } from '@/tests/builders/safeTx' +import * as useTxPreviewHooks from '@/components/tx/confirmation-views/useTxPreview' +import { SlotProvider } from '@/components/tx-flow/slots' + +describe('ReviewTransaction', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should display a loading component', () => { + const { container } = render(<ReviewTransaction onSubmit={jest.fn()} />) + + expect(container).toMatchSnapshot() + }) + + it('should display a confirmation screen', async () => { + jest.spyOn(useTxPreviewHooks, 'default').mockReturnValue([ + { + txInfo: {}, + txData: { to: { value: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67' } }, + } as TransactionPreview, + undefined, + false, + ]) + + const { container, getByText } = render( + <SlotProvider> + <SafeTxContext.Provider value={{ safeTx: createSafeTx() } as SafeTxContextParams}> + <ReviewTransaction onSubmit={jest.fn()} /> + </SafeTxContext.Provider> + , + </SlotProvider>, + ) + + expect(getByText("You're about to confirm this transaction.")).toBeInTheDocument() + expect(container).toMatchSnapshot() + }) + + it('should display an error screen', async () => { + jest + .spyOn(useTxPreviewHooks, 'default') + .mockReturnValue([undefined, new Error('This is a mock error message'), false]) + + const { container } = render( + <SlotProvider> + <SafeTxContext.Provider + value={ + { + safeTx: createSafeTx(), + } as SafeTxContextParams + } + > + <ReviewTransaction onSubmit={jest.fn()} /> + </SafeTxContext.Provider> + , + </SlotProvider>, + ) + + expect(container.querySelector('continue-sign-btn')).not.toBeInTheDocument() + expect(container).toMatchSnapshot() + }) +}) diff --git a/apps/web/src/components/tx/ReviewTransactionV2/__tests__/__snapshots__/ReviewTransactionContent.test.tsx.snap b/apps/web/src/components/tx/ReviewTransactionV2/__tests__/__snapshots__/ReviewTransactionContent.test.tsx.snap new file mode 100644 index 0000000000..36a0f8fb6e --- /dev/null +++ b/apps/web/src/components/tx/ReviewTransactionV2/__tests__/__snapshots__/ReviewTransactionContent.test.tsx.snap @@ -0,0 +1,832 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReviewTransaction should display a confirmation screen 1`] = ` +<div> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" + style="--Paper-shadow: none;" + > + <div + class="MuiCardContent-root cardContent css-1lt5qva-MuiCardContent-root" + data-testid="card-content" + > + <div + class="MuiBox-root css-0" + > + <div + class="MuiStack-root css-1sazv7p-MuiStack-root" + > + <div + class="MuiGrid-root MuiGrid-container css-1yff1ei-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-ezmk0c-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <span + class="MuiTypography-root MuiTypography-body2 css-1ew0eu5-MuiTypography-root" + > + Interacted with + </span> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + <div + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + <div + class="container" + > + <div + class="avatarContainer" + style="width: 20px; height: 20px;" + > + <div + class="icon" + style="background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4IDgiIHNoYXBlLXJlbmRlcmluZz0ib3B0aW1pemVTcGVlZCIgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0Ij48cGF0aCBmaWxsPSJoc2woOTIgOTYlIDQzJSkiIGQ9Ik0wLDBIOFY4SDB6Ii8+PHBhdGggZmlsbD0iaHNsKDMxMCA4NCUgMzglKSIgZD0iTTEsMGgxdjFoLTF6TTYsMGgxdjFoLTF6TTIsMGgxdjFoLTF6TTUsMGgxdjFoLTF6TTMsMGgxdjFoLTF6TTQsMGgxdjFoLTF6TTAsMWgxdjFoLTF6TTcsMWgxdjFoLTF6TTEsMWgxdjFoLTF6TTYsMWgxdjFoLTF6TTIsMWgxdjFoLTF6TTUsMWgxdjFoLTF6TTMsMWgxdjFoLTF6TTQsMWgxdjFoLTF6TTAsMmgxdjFoLTF6TTcsMmgxdjFoLTF6TTIsMmgxdjFoLTF6TTUsMmgxdjFoLTF6TTMsMmgxdjFoLTF6TTQsMmgxdjFoLTF6TTMsM2gxdjFoLTF6TTQsM2gxdjFoLTF6TTAsNGgxdjFoLTF6TTcsNGgxdjFoLTF6TTEsNGgxdjFoLTF6TTYsNGgxdjFoLTF6TTIsNGgxdjFoLTF6TTUsNGgxdjFoLTF6TTEsNWgxdjFoLTF6TTYsNWgxdjFoLTF6TTIsNWgxdjFoLTF6TTUsNWgxdjFoLTF6TTMsNWgxdjFoLTF6TTQsNWgxdjFoLTF6TTIsNmgxdjFoLTF6TTUsNmgxdjFoLTF6TTMsNmgxdjFoLTF6TTQsNmgxdjFoLTF6TTMsN2gxdjFoLTF6TTQsN2gxdjFoLTF6Ii8+PHBhdGggZmlsbD0iaHNsKDM1MSA2MSUgNjUlKSIgZD0iTTMsNGgxdjFoLTF6TTQsNGgxdjFoLTF6TTAsNWgxdjFoLTF6TTcsNWgxdjFoLTF6TTAsN2gxdjFoLTF6TTcsN2gxdjFoLTF6Ii8+PC9zdmc+); width: 20px; height: 20px;" + /> + </div> + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span> + 0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67 + </span> + </span> + </div> + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <button + aria-label="Copy to clipboard" + class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + tabindex="0" + type="button" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" + data-testid="copy-btn-icon" + focusable="false" + /> + </button> + </span> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + <div + class="MuiBox-root css-1yuhvjn" + > + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiAccordion-root MuiAccordion-rounded MuiAccordion-gutters css-wrhbuy-MuiPaper-root-MuiAccordion-root" + color="info" + style="--Paper-shadow: none;" + > + <h3 + class="MuiAccordion-heading css-cy7rkm-MuiAccordion-heading" + > + <button + aria-expanded="false" + class="MuiButtonBase-root MuiAccordionSummary-root MuiAccordionSummary-gutters accordion css-10sjung-MuiButtonBase-root-MuiAccordionSummary-root" + data-testid="decoded-tx-summary" + tabindex="0" + type="button" + > + <span + class="MuiAccordionSummary-content MuiAccordionSummary-contentGutters css-1r0e0ir-MuiAccordionSummary-content" + > + <div + class="MuiStack-root css-1bujbuo-MuiStack-root" + > + <h6 + class="MuiTypography-root MuiTypography-subtitle2 css-8ydk8b-MuiTypography-root" + data-testid="tx-advanced-details" + > + Transaction details + <span + class="" + data-mui-internal-clone-element="true" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-17ibv3e-MuiSvgIcon-root" + focusable="false" + /> + </span> + </h6> + </div> + </span> + <span + class="MuiAccordionSummary-expandIconWrapper css-1wqf3nl-MuiAccordionSummary-expandIconWrapper" + > + <svg + aria-hidden="true" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-1dhtbeh-MuiSvgIcon-root" + data-testid="ExpandMoreIcon" + focusable="false" + viewBox="0 0 24 24" + > + <path + d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" + /> + </svg> + </span> + </button> + </h3> + <div + class="MuiCollapse-root MuiCollapse-vertical MuiCollapse-hidden css-cwrbtg-MuiCollapse-root" + style="min-height: 0px;" + > + <div + class="MuiCollapse-wrapper MuiCollapse-vertical css-1x6hinx-MuiCollapse-wrapper" + > + <div + class="MuiCollapse-wrapperInner MuiCollapse-vertical css-1i4ywhz-MuiCollapse-wrapperInner" + > + <div + class="MuiAccordion-region" + role="region" + > + <div + class="MuiAccordionDetails-root css-w74p4c-MuiAccordionDetails-root" + data-testid="decoded-tx-details" + > + <div + class="MuiStack-root css-hwnj0i-MuiStack-root" + > + <div + class="MuiBox-root css-0" + > + <h6 + class="MuiTypography-root MuiTypography-subtitle2 css-4lr168-MuiTypography-root" + > + Advanced details + </h6> + <p + class="MuiTypography-root MuiTypography-body2 css-gkag80-MuiTypography-root" + > + Cross-verify your transaction data with external tools like + + <a + class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineAlways css-r9pq5a-MuiTypography-root-MuiLink-root" + href="https://safeutils.openzeppelin.com" + rel="noreferrer noopener" + target="_blank" + > + <span + class="MuiBox-root css-u9xrjn" + > + Safe Utils + <svg + aria-hidden="true" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-tqxw8e-MuiSvgIcon-root" + data-testid="OpenInNewRoundedIcon" + focusable="false" + viewBox="0 0 24 24" + > + <path + d="M18 19H6c-.55 0-1-.45-1-1V6c0-.55.45-1 1-1h5c.55 0 1-.45 1-1s-.45-1-1-1H5c-1.11 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-6c0-.55-.45-1-1-1s-1 .45-1 1v5c0 .55-.45 1-1 1M14 4c0 .55.45 1 1 1h2.59l-9.13 9.13c-.39.39-.39 1.02 0 1.41s1.02.39 1.41 0L19 6.41V9c0 .55.45 1 1 1s1-.45 1-1V4c0-.55-.45-1-1-1h-5c-.55 0-1 .45-1 1" + /> + </svg> + </span> + </a> + and + + <a + class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineAlways css-r9pq5a-MuiTypography-root-MuiLink-root" + href="https://transaction-decoder.pages.dev" + rel="noreferrer noopener" + target="_blank" + > + <span + class="MuiBox-root css-u9xrjn" + > + Transaction Decoder + <svg + aria-hidden="true" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-tqxw8e-MuiSvgIcon-root" + data-testid="OpenInNewRoundedIcon" + focusable="false" + viewBox="0 0 24 24" + > + <path + d="M18 19H6c-.55 0-1-.45-1-1V6c0-.55.45-1 1-1h5c.55 0 1-.45 1-1s-.45-1-1-1H5c-1.11 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-6c0-.55-.45-1-1-1s-1 .45-1 1v5c0 .55-.45 1-1 1M14 4c0 .55.45 1 1 1h2.59l-9.13 9.13c-.39.39-.39 1.02 0 1.41s1.02.39 1.41 0L19 6.41V9c0 .55.45 1 1 1s1-.45 1-1V4c0-.55-.45-1-1-1h-5c-.55 0-1 .45-1 1" + /> + </svg> + </span> + </a> + . + </p> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 css-shqplw-MuiPaper-root" + style="--Paper-shadow: none;" + > + <div + class="MuiStack-root css-1sazv7p-MuiStack-root" + > + <div + class="MuiStack-root css-1n46f6x-MuiStack-root" + > + <div + aria-label="text alignment" + class="MuiToggleButtonGroup-root MuiToggleButtonGroup-horizontal css-cinseo-MuiToggleButtonGroup-root" + role="group" + > + <button + aria-pressed="true" + class="MuiButtonBase-root MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButton-root Mui-selected MuiToggleButton-sizeSmall MuiToggleButton-standard MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButtonGroup-firstButton css-u2ogzi-MuiButtonBase-root-MuiToggleButton-root" + tabindex="0" + type="button" + value="0" + > + <div + class="MuiBox-root css-mro3c9" + > + Data + </div> + </button> + <button + aria-pressed="false" + class="MuiButtonBase-root MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButton-root MuiToggleButton-sizeSmall MuiToggleButton-standard MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButtonGroup-middleButton css-u2ogzi-MuiButtonBase-root-MuiToggleButton-root" + tabindex="0" + type="button" + value="1" + > + <div + class="MuiBox-root css-mro3c9" + > + Hashes + </div> + </button> + <button + aria-pressed="false" + class="MuiButtonBase-root MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButton-root MuiToggleButton-sizeSmall MuiToggleButton-standard MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButtonGroup-lastButton css-u2ogzi-MuiButtonBase-root-MuiToggleButton-root" + tabindex="0" + type="button" + value="2" + > + <div + class="MuiBox-root css-mro3c9" + > + JSON + </div> + </button> + </div> + </div> + <div + class="MuiBox-root css-e0rnc0" + > + <div + class="MuiStack-root css-jfdv4h-MuiStack-root" + > + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + To + </p> + <div + class="MuiBox-root css-0" + > + <p + class="MuiTypography-root MuiTypography-body2 css-m9op2-MuiTypography-root" + > + <div + class="container" + > + <div + class="avatarContainer" + style="width: 20px; height: 20px;" + > + <div + class="icon" + style="background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4IDgiIHNoYXBlLXJlbmRlcmluZz0ib3B0aW1pemVTcGVlZCIgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0Ij48cGF0aCBmaWxsPSJoc2woMjEzIDY1JSA0OSUpIiBkPSJNMCwwSDhWOEgweiIvPjxwYXRoIGZpbGw9ImhzbCgzNTQgNjYlIDU2JSkiIGQ9Ik0yLDBoMXYxaC0xek01LDBoMXYxaC0xek0wLDFoMXYxaC0xek03LDFoMXYxaC0xek0xLDJoMXYxaC0xek02LDJoMXYxaC0xek0yLDJoMXYxaC0xek01LDJoMXYxaC0xek0xLDNoMXYxaC0xek02LDNoMXYxaC0xek0yLDNoMXYxaC0xek01LDNoMXYxaC0xek0zLDNoMXYxaC0xek00LDNoMXYxaC0xek0wLDRoMXYxaC0xek03LDRoMXYxaC0xek0yLDRoMXYxaC0xek01LDRoMXYxaC0xek0wLDVoMXYxaC0xek03LDVoMXYxaC0xek0zLDVoMXYxaC0xek00LDVoMXYxaC0xek0zLDdoMXYxaC0xek00LDdoMXYxaC0xeiIvPjxwYXRoIGZpbGw9ImhzbCgzMjcgNDElIDYzJSkiIGQ9Ik0wLDNoMXYxaC0xek03LDNoMXYxaC0xek0zLDRoMXYxaC0xek00LDRoMXYxaC0xek0wLDZoMXYxaC0xek03LDZoMXYxaC0xek0yLDdoMXYxaC0xek01LDdoMXYxaC0xeiIvPjwvc3ZnPg==); width: 20px; height: 20px;" + /> + </div> + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span> + 0x + <b> + 0000 + </b> + 00000000000000000000000000000000 + <b> + 0000 + </b> + </span> + </span> + </div> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </p> + </div> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + Value + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + 0x0 + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + Data + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-n5xdb5-MuiTypography-root" + > + <div + class="encodedData MuiBox-root css-0" + data-testid="tx-hexData" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span + class="monospace" + > + <b + aria-label="The first 4 bytes determine the contract method that is being called" + class="" + data-mui-internal-clone-element="true" + > + 0x + </b> + + </span> + </span> + </div> + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + Operation + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-1hy3mdy-MuiTypography-root" + > + 0 + ( + call + ) + <svg + aria-hidden="true" + class="MuiSvgIcon-root MuiSvgIcon-colorSuccess MuiSvgIcon-fontSizeInherit css-113mief-MuiSvgIcon-root" + data-testid="CheckIcon" + focusable="false" + viewBox="0 0 24 24" + > + <path + d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" + /> + </svg> + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + SafeTxGas + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + BaseGas + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + GasPrice + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + GasToken + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + <div + class="container" + > + <div + class="avatarContainer" + style="width: 20px; height: 20px;" + > + <span + class="MuiSkeleton-root MuiSkeleton-circular MuiSkeleton-pulse css-143xw5x-MuiSkeleton-root" + style="width: 20px; height: 20px;" + /> + </div> + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span /> + </span> + </div> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + RefundReceiver + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + <div + class="container" + > + <div + class="avatarContainer" + style="width: 20px; height: 20px;" + > + <span + class="MuiSkeleton-root MuiSkeleton-circular MuiSkeleton-pulse css-143xw5x-MuiSkeleton-root" + style="width: 20px; height: 20px;" + /> + </div> + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span /> + </span> + </div> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + Nonce + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + 100 + </p> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" + style="--Paper-shadow: none;" + > + <div + class="MuiCardContent-root cardContent css-1lt5qva-MuiCardContent-root" + data-testid="card-content" + > + <div + class="wrapper" + > + <div + class="icon sign" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-tqxw8e-MuiSvgIcon-root" + focusable="false" + /> + </div> + <div> + <h5 + class="MuiTypography-root MuiTypography-h5 css-1mp5g96-MuiTypography-root" + > + confirm + </h5> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + You're about to + confirm + this transaction. + </p> + </div> + </div> + <div + class="MuiCardActions-root MuiCardActions-spacing css-1q4nm6f-MuiCardActions-root" + > + <div + class="MuiStack-root css-irtfmw-MuiStack-root" + > + <span + aria-label="Please connect your wallet" + class="" + data-mui-internal-clone-element="true" + > + <button + class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary Mui-disabled MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary css-1hp2ejb-MuiButtonBase-root-MuiButton-root" + data-testid="continue-sign-btn" + disabled="" + tabindex="-1" + type="submit" + > + Continue + </button> + </span> + </div> + </div> + </div> + </div> + , +</div> +`; + +exports[`ReviewTransaction should display a loading component 1`] = ` +<div> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" + style="--Paper-shadow: none;" + > + <div + class="MuiCardContent-root cardContent css-1lt5qva-MuiCardContent-root" + data-testid="card-content" + > + <div + class="MuiBox-root css-17luq05" + > + <div + class="box MuiBox-root css-0" + > + <div + class="rect rectTl" + /> + <div + class="rect rectTr" + /> + <div + class="rect rectBl" + /> + <div + class="rect rectBr" + /> + <div + class="rect rectCenter" + /> + <svg + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <defs> + <filter + id="gooey" + > + <fegaussianblur + in="SourceGraphic" + result="blur" + stdDeviation="3" + /> + <fecolormatrix + in="blur" + mode="matrix" + result="goo" + values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9" + /> + <fecomposite + in="SourceGraphic" + in2="goo" + operator="atop" + /> + </filter> + </defs> + </svg> + </div> + </div> + </div> + </div> +</div> +`; + +exports[`ReviewTransaction should display an error screen 1`] = ` +<div> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" + style="--Paper-shadow: none;" + > + <div + class="MuiCardContent-root cardContent css-1lt5qva-MuiCardContent-root" + data-testid="card-content" + /> + </div> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" + style="--Paper-shadow: none;" + > + <div + class="MuiCardContent-root cardContent css-1lt5qva-MuiCardContent-root" + data-testid="card-content" + > + <div + class="wrapper" + > + <div + class="icon sign" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-tqxw8e-MuiSvgIcon-root" + focusable="false" + /> + </div> + <div> + <h5 + class="MuiTypography-root MuiTypography-h5 css-1mp5g96-MuiTypography-root" + > + confirm + </h5> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + You're about to + confirm + this transaction. + </p> + </div> + </div> + <div + class="MuiCardActions-root MuiCardActions-spacing css-1q4nm6f-MuiCardActions-root" + > + <div + class="MuiStack-root css-irtfmw-MuiStack-root" + > + <span + aria-label="Please connect your wallet" + class="" + data-mui-internal-clone-element="true" + > + <button + class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary Mui-disabled MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary css-1hp2ejb-MuiButtonBase-root-MuiButton-root" + data-testid="continue-sign-btn" + disabled="" + tabindex="-1" + type="submit" + > + Continue + </button> + </span> + </div> + </div> + </div> + </div> + , +</div> +`; diff --git a/apps/web/src/components/tx/ReviewTransactionV2/__tests__/__snapshots__/index.test.tsx.snap b/apps/web/src/components/tx/ReviewTransactionV2/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..36a0f8fb6e --- /dev/null +++ b/apps/web/src/components/tx/ReviewTransactionV2/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,832 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReviewTransaction should display a confirmation screen 1`] = ` +<div> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" + style="--Paper-shadow: none;" + > + <div + class="MuiCardContent-root cardContent css-1lt5qva-MuiCardContent-root" + data-testid="card-content" + > + <div + class="MuiBox-root css-0" + > + <div + class="MuiStack-root css-1sazv7p-MuiStack-root" + > + <div + class="MuiGrid-root MuiGrid-container css-1yff1ei-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-ezmk0c-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <span + class="MuiTypography-root MuiTypography-body2 css-1ew0eu5-MuiTypography-root" + > + Interacted with + </span> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + <div + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + <div + class="container" + > + <div + class="avatarContainer" + style="width: 20px; height: 20px;" + > + <div + class="icon" + style="background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4IDgiIHNoYXBlLXJlbmRlcmluZz0ib3B0aW1pemVTcGVlZCIgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0Ij48cGF0aCBmaWxsPSJoc2woOTIgOTYlIDQzJSkiIGQ9Ik0wLDBIOFY4SDB6Ii8+PHBhdGggZmlsbD0iaHNsKDMxMCA4NCUgMzglKSIgZD0iTTEsMGgxdjFoLTF6TTYsMGgxdjFoLTF6TTIsMGgxdjFoLTF6TTUsMGgxdjFoLTF6TTMsMGgxdjFoLTF6TTQsMGgxdjFoLTF6TTAsMWgxdjFoLTF6TTcsMWgxdjFoLTF6TTEsMWgxdjFoLTF6TTYsMWgxdjFoLTF6TTIsMWgxdjFoLTF6TTUsMWgxdjFoLTF6TTMsMWgxdjFoLTF6TTQsMWgxdjFoLTF6TTAsMmgxdjFoLTF6TTcsMmgxdjFoLTF6TTIsMmgxdjFoLTF6TTUsMmgxdjFoLTF6TTMsMmgxdjFoLTF6TTQsMmgxdjFoLTF6TTMsM2gxdjFoLTF6TTQsM2gxdjFoLTF6TTAsNGgxdjFoLTF6TTcsNGgxdjFoLTF6TTEsNGgxdjFoLTF6TTYsNGgxdjFoLTF6TTIsNGgxdjFoLTF6TTUsNGgxdjFoLTF6TTEsNWgxdjFoLTF6TTYsNWgxdjFoLTF6TTIsNWgxdjFoLTF6TTUsNWgxdjFoLTF6TTMsNWgxdjFoLTF6TTQsNWgxdjFoLTF6TTIsNmgxdjFoLTF6TTUsNmgxdjFoLTF6TTMsNmgxdjFoLTF6TTQsNmgxdjFoLTF6TTMsN2gxdjFoLTF6TTQsN2gxdjFoLTF6Ii8+PHBhdGggZmlsbD0iaHNsKDM1MSA2MSUgNjUlKSIgZD0iTTMsNGgxdjFoLTF6TTQsNGgxdjFoLTF6TTAsNWgxdjFoLTF6TTcsNWgxdjFoLTF6TTAsN2gxdjFoLTF6TTcsN2gxdjFoLTF6Ii8+PC9zdmc+); width: 20px; height: 20px;" + /> + </div> + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span> + 0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67 + </span> + </span> + </div> + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <button + aria-label="Copy to clipboard" + class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + tabindex="0" + type="button" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" + data-testid="copy-btn-icon" + focusable="false" + /> + </button> + </span> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + <div + class="MuiBox-root css-1yuhvjn" + > + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiAccordion-root MuiAccordion-rounded MuiAccordion-gutters css-wrhbuy-MuiPaper-root-MuiAccordion-root" + color="info" + style="--Paper-shadow: none;" + > + <h3 + class="MuiAccordion-heading css-cy7rkm-MuiAccordion-heading" + > + <button + aria-expanded="false" + class="MuiButtonBase-root MuiAccordionSummary-root MuiAccordionSummary-gutters accordion css-10sjung-MuiButtonBase-root-MuiAccordionSummary-root" + data-testid="decoded-tx-summary" + tabindex="0" + type="button" + > + <span + class="MuiAccordionSummary-content MuiAccordionSummary-contentGutters css-1r0e0ir-MuiAccordionSummary-content" + > + <div + class="MuiStack-root css-1bujbuo-MuiStack-root" + > + <h6 + class="MuiTypography-root MuiTypography-subtitle2 css-8ydk8b-MuiTypography-root" + data-testid="tx-advanced-details" + > + Transaction details + <span + class="" + data-mui-internal-clone-element="true" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-17ibv3e-MuiSvgIcon-root" + focusable="false" + /> + </span> + </h6> + </div> + </span> + <span + class="MuiAccordionSummary-expandIconWrapper css-1wqf3nl-MuiAccordionSummary-expandIconWrapper" + > + <svg + aria-hidden="true" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-1dhtbeh-MuiSvgIcon-root" + data-testid="ExpandMoreIcon" + focusable="false" + viewBox="0 0 24 24" + > + <path + d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" + /> + </svg> + </span> + </button> + </h3> + <div + class="MuiCollapse-root MuiCollapse-vertical MuiCollapse-hidden css-cwrbtg-MuiCollapse-root" + style="min-height: 0px;" + > + <div + class="MuiCollapse-wrapper MuiCollapse-vertical css-1x6hinx-MuiCollapse-wrapper" + > + <div + class="MuiCollapse-wrapperInner MuiCollapse-vertical css-1i4ywhz-MuiCollapse-wrapperInner" + > + <div + class="MuiAccordion-region" + role="region" + > + <div + class="MuiAccordionDetails-root css-w74p4c-MuiAccordionDetails-root" + data-testid="decoded-tx-details" + > + <div + class="MuiStack-root css-hwnj0i-MuiStack-root" + > + <div + class="MuiBox-root css-0" + > + <h6 + class="MuiTypography-root MuiTypography-subtitle2 css-4lr168-MuiTypography-root" + > + Advanced details + </h6> + <p + class="MuiTypography-root MuiTypography-body2 css-gkag80-MuiTypography-root" + > + Cross-verify your transaction data with external tools like + + <a + class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineAlways css-r9pq5a-MuiTypography-root-MuiLink-root" + href="https://safeutils.openzeppelin.com" + rel="noreferrer noopener" + target="_blank" + > + <span + class="MuiBox-root css-u9xrjn" + > + Safe Utils + <svg + aria-hidden="true" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-tqxw8e-MuiSvgIcon-root" + data-testid="OpenInNewRoundedIcon" + focusable="false" + viewBox="0 0 24 24" + > + <path + d="M18 19H6c-.55 0-1-.45-1-1V6c0-.55.45-1 1-1h5c.55 0 1-.45 1-1s-.45-1-1-1H5c-1.11 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-6c0-.55-.45-1-1-1s-1 .45-1 1v5c0 .55-.45 1-1 1M14 4c0 .55.45 1 1 1h2.59l-9.13 9.13c-.39.39-.39 1.02 0 1.41s1.02.39 1.41 0L19 6.41V9c0 .55.45 1 1 1s1-.45 1-1V4c0-.55-.45-1-1-1h-5c-.55 0-1 .45-1 1" + /> + </svg> + </span> + </a> + and + + <a + class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineAlways css-r9pq5a-MuiTypography-root-MuiLink-root" + href="https://transaction-decoder.pages.dev" + rel="noreferrer noopener" + target="_blank" + > + <span + class="MuiBox-root css-u9xrjn" + > + Transaction Decoder + <svg + aria-hidden="true" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-tqxw8e-MuiSvgIcon-root" + data-testid="OpenInNewRoundedIcon" + focusable="false" + viewBox="0 0 24 24" + > + <path + d="M18 19H6c-.55 0-1-.45-1-1V6c0-.55.45-1 1-1h5c.55 0 1-.45 1-1s-.45-1-1-1H5c-1.11 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-6c0-.55-.45-1-1-1s-1 .45-1 1v5c0 .55-.45 1-1 1M14 4c0 .55.45 1 1 1h2.59l-9.13 9.13c-.39.39-.39 1.02 0 1.41s1.02.39 1.41 0L19 6.41V9c0 .55.45 1 1 1s1-.45 1-1V4c0-.55-.45-1-1-1h-5c-.55 0-1 .45-1 1" + /> + </svg> + </span> + </a> + . + </p> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 css-shqplw-MuiPaper-root" + style="--Paper-shadow: none;" + > + <div + class="MuiStack-root css-1sazv7p-MuiStack-root" + > + <div + class="MuiStack-root css-1n46f6x-MuiStack-root" + > + <div + aria-label="text alignment" + class="MuiToggleButtonGroup-root MuiToggleButtonGroup-horizontal css-cinseo-MuiToggleButtonGroup-root" + role="group" + > + <button + aria-pressed="true" + class="MuiButtonBase-root MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButton-root Mui-selected MuiToggleButton-sizeSmall MuiToggleButton-standard MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButtonGroup-firstButton css-u2ogzi-MuiButtonBase-root-MuiToggleButton-root" + tabindex="0" + type="button" + value="0" + > + <div + class="MuiBox-root css-mro3c9" + > + Data + </div> + </button> + <button + aria-pressed="false" + class="MuiButtonBase-root MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButton-root MuiToggleButton-sizeSmall MuiToggleButton-standard MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButtonGroup-middleButton css-u2ogzi-MuiButtonBase-root-MuiToggleButton-root" + tabindex="0" + type="button" + value="1" + > + <div + class="MuiBox-root css-mro3c9" + > + Hashes + </div> + </button> + <button + aria-pressed="false" + class="MuiButtonBase-root MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButton-root MuiToggleButton-sizeSmall MuiToggleButton-standard MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButtonGroup-lastButton css-u2ogzi-MuiButtonBase-root-MuiToggleButton-root" + tabindex="0" + type="button" + value="2" + > + <div + class="MuiBox-root css-mro3c9" + > + JSON + </div> + </button> + </div> + </div> + <div + class="MuiBox-root css-e0rnc0" + > + <div + class="MuiStack-root css-jfdv4h-MuiStack-root" + > + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + To + </p> + <div + class="MuiBox-root css-0" + > + <p + class="MuiTypography-root MuiTypography-body2 css-m9op2-MuiTypography-root" + > + <div + class="container" + > + <div + class="avatarContainer" + style="width: 20px; height: 20px;" + > + <div + class="icon" + style="background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4IDgiIHNoYXBlLXJlbmRlcmluZz0ib3B0aW1pemVTcGVlZCIgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0Ij48cGF0aCBmaWxsPSJoc2woMjEzIDY1JSA0OSUpIiBkPSJNMCwwSDhWOEgweiIvPjxwYXRoIGZpbGw9ImhzbCgzNTQgNjYlIDU2JSkiIGQ9Ik0yLDBoMXYxaC0xek01LDBoMXYxaC0xek0wLDFoMXYxaC0xek03LDFoMXYxaC0xek0xLDJoMXYxaC0xek02LDJoMXYxaC0xek0yLDJoMXYxaC0xek01LDJoMXYxaC0xek0xLDNoMXYxaC0xek02LDNoMXYxaC0xek0yLDNoMXYxaC0xek01LDNoMXYxaC0xek0zLDNoMXYxaC0xek00LDNoMXYxaC0xek0wLDRoMXYxaC0xek03LDRoMXYxaC0xek0yLDRoMXYxaC0xek01LDRoMXYxaC0xek0wLDVoMXYxaC0xek03LDVoMXYxaC0xek0zLDVoMXYxaC0xek00LDVoMXYxaC0xek0zLDdoMXYxaC0xek00LDdoMXYxaC0xeiIvPjxwYXRoIGZpbGw9ImhzbCgzMjcgNDElIDYzJSkiIGQ9Ik0wLDNoMXYxaC0xek03LDNoMXYxaC0xek0zLDRoMXYxaC0xek00LDRoMXYxaC0xek0wLDZoMXYxaC0xek03LDZoMXYxaC0xek0yLDdoMXYxaC0xek01LDdoMXYxaC0xeiIvPjwvc3ZnPg==); width: 20px; height: 20px;" + /> + </div> + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span> + 0x + <b> + 0000 + </b> + 00000000000000000000000000000000 + <b> + 0000 + </b> + </span> + </span> + </div> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </p> + </div> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + Value + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + 0x0 + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + Data + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-n5xdb5-MuiTypography-root" + > + <div + class="encodedData MuiBox-root css-0" + data-testid="tx-hexData" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span + class="monospace" + > + <b + aria-label="The first 4 bytes determine the contract method that is being called" + class="" + data-mui-internal-clone-element="true" + > + 0x + </b> + + </span> + </span> + </div> + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + Operation + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-1hy3mdy-MuiTypography-root" + > + 0 + ( + call + ) + <svg + aria-hidden="true" + class="MuiSvgIcon-root MuiSvgIcon-colorSuccess MuiSvgIcon-fontSizeInherit css-113mief-MuiSvgIcon-root" + data-testid="CheckIcon" + focusable="false" + viewBox="0 0 24 24" + > + <path + d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" + /> + </svg> + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + SafeTxGas + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + BaseGas + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + GasPrice + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + GasToken + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + <div + class="container" + > + <div + class="avatarContainer" + style="width: 20px; height: 20px;" + > + <span + class="MuiSkeleton-root MuiSkeleton-circular MuiSkeleton-pulse css-143xw5x-MuiSkeleton-root" + style="width: 20px; height: 20px;" + /> + </div> + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span /> + </span> + </div> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + RefundReceiver + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + <div + class="container" + > + <div + class="avatarContainer" + style="width: 20px; height: 20px;" + > + <span + class="MuiSkeleton-root MuiSkeleton-circular MuiSkeleton-pulse css-143xw5x-MuiSkeleton-root" + style="width: 20px; height: 20px;" + /> + </div> + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span /> + </span> + </div> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + Nonce + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + 100 + </p> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" + style="--Paper-shadow: none;" + > + <div + class="MuiCardContent-root cardContent css-1lt5qva-MuiCardContent-root" + data-testid="card-content" + > + <div + class="wrapper" + > + <div + class="icon sign" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-tqxw8e-MuiSvgIcon-root" + focusable="false" + /> + </div> + <div> + <h5 + class="MuiTypography-root MuiTypography-h5 css-1mp5g96-MuiTypography-root" + > + confirm + </h5> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + You're about to + confirm + this transaction. + </p> + </div> + </div> + <div + class="MuiCardActions-root MuiCardActions-spacing css-1q4nm6f-MuiCardActions-root" + > + <div + class="MuiStack-root css-irtfmw-MuiStack-root" + > + <span + aria-label="Please connect your wallet" + class="" + data-mui-internal-clone-element="true" + > + <button + class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary Mui-disabled MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary css-1hp2ejb-MuiButtonBase-root-MuiButton-root" + data-testid="continue-sign-btn" + disabled="" + tabindex="-1" + type="submit" + > + Continue + </button> + </span> + </div> + </div> + </div> + </div> + , +</div> +`; + +exports[`ReviewTransaction should display a loading component 1`] = ` +<div> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" + style="--Paper-shadow: none;" + > + <div + class="MuiCardContent-root cardContent css-1lt5qva-MuiCardContent-root" + data-testid="card-content" + > + <div + class="MuiBox-root css-17luq05" + > + <div + class="box MuiBox-root css-0" + > + <div + class="rect rectTl" + /> + <div + class="rect rectTr" + /> + <div + class="rect rectBl" + /> + <div + class="rect rectBr" + /> + <div + class="rect rectCenter" + /> + <svg + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <defs> + <filter + id="gooey" + > + <fegaussianblur + in="SourceGraphic" + result="blur" + stdDeviation="3" + /> + <fecolormatrix + in="blur" + mode="matrix" + result="goo" + values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9" + /> + <fecomposite + in="SourceGraphic" + in2="goo" + operator="atop" + /> + </filter> + </defs> + </svg> + </div> + </div> + </div> + </div> +</div> +`; + +exports[`ReviewTransaction should display an error screen 1`] = ` +<div> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" + style="--Paper-shadow: none;" + > + <div + class="MuiCardContent-root cardContent css-1lt5qva-MuiCardContent-root" + data-testid="card-content" + /> + </div> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" + style="--Paper-shadow: none;" + > + <div + class="MuiCardContent-root cardContent css-1lt5qva-MuiCardContent-root" + data-testid="card-content" + > + <div + class="wrapper" + > + <div + class="icon sign" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-tqxw8e-MuiSvgIcon-root" + focusable="false" + /> + </div> + <div> + <h5 + class="MuiTypography-root MuiTypography-h5 css-1mp5g96-MuiTypography-root" + > + confirm + </h5> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + You're about to + confirm + this transaction. + </p> + </div> + </div> + <div + class="MuiCardActions-root MuiCardActions-spacing css-1q4nm6f-MuiCardActions-root" + > + <div + class="MuiStack-root css-irtfmw-MuiStack-root" + > + <span + aria-label="Please connect your wallet" + class="" + data-mui-internal-clone-element="true" + > + <button + class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary Mui-disabled MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary css-1hp2ejb-MuiButtonBase-root-MuiButton-root" + data-testid="continue-sign-btn" + disabled="" + tabindex="-1" + type="submit" + > + Continue + </button> + </span> + </div> + </div> + </div> + </div> + , +</div> +`; diff --git a/apps/web/src/components/tx/ReviewTransactionV2/__tests__/index.test.tsx b/apps/web/src/components/tx/ReviewTransactionV2/__tests__/index.test.tsx new file mode 100644 index 0000000000..8830611717 --- /dev/null +++ b/apps/web/src/components/tx/ReviewTransactionV2/__tests__/index.test.tsx @@ -0,0 +1,67 @@ +import ReviewTransaction from '../index' +import { render } from '@/tests/test-utils' +import type { TransactionPreview } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeTxContextParams } from '@/components/tx-flow/SafeTxProvider' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { createSafeTx } from '@/tests/builders/safeTx' +import * as useTxPreviewHooks from '@/components/tx/confirmation-views/useTxPreview' +import { SlotProvider } from '@/components/tx-flow/slots' + +describe('ReviewTransaction', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should display a loading component', () => { + const { container } = render(<ReviewTransaction onSubmit={jest.fn()} />) + + expect(container).toMatchSnapshot() + }) + + it('should display a confirmation screen', async () => { + jest.spyOn(useTxPreviewHooks, 'default').mockReturnValue([ + { + txInfo: {}, + txData: { to: { value: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67' } }, + } as TransactionPreview, + undefined, + false, + ]) + + const { container, getByText } = render( + <SlotProvider> + <SafeTxContext.Provider value={{ safeTx: createSafeTx() } as SafeTxContextParams}> + <ReviewTransaction onSubmit={jest.fn()} /> + </SafeTxContext.Provider> + , + </SlotProvider>, + ) + + expect(getByText("You're about to confirm this transaction.")).toBeInTheDocument() + expect(container).toMatchSnapshot() + }) + + it('should display an error screen', async () => { + jest + .spyOn(useTxPreviewHooks, 'default') + .mockReturnValue([undefined, new Error('This is a mock error message'), false]) + + const { container } = render( + <SlotProvider> + <SafeTxContext.Provider + value={ + { + safeTx: createSafeTx(), + } as SafeTxContextParams + } + > + <ReviewTransaction onSubmit={jest.fn()} /> + </SafeTxContext.Provider> + , + </SlotProvider>, + ) + + expect(container.querySelector('continue-sign-btn')).not.toBeInTheDocument() + expect(container).toMatchSnapshot() + }) +}) diff --git a/apps/web/src/components/tx/ReviewTransactionV2/index.tsx b/apps/web/src/components/tx/ReviewTransactionV2/index.tsx new file mode 100644 index 0000000000..59885aee81 --- /dev/null +++ b/apps/web/src/components/tx/ReviewTransactionV2/index.tsx @@ -0,0 +1,32 @@ +import { useContext } from 'react' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import ReviewTransactionSkeleton from './ReviewTransactionSkeleton' +import useTxPreview from '../confirmation-views/useTxPreview' +import type { ReviewTransactionContentProps } from './ReviewTransactionContent' +import ReviewTransactionContent from './ReviewTransactionContent' +import { TxFlowStep } from '@/components/tx-flow/TxFlowStep' +import { TxFlowContext } from '@/components/tx-flow/TxFlowProvider' + +export type ReviewTransactionProps = { + title?: string +} & ReviewTransactionContentProps + +const ReviewTransaction = ({ title, ...props }: ReviewTransactionProps) => { + const { safeTx, safeTxError } = useContext(SafeTxContext) + const { txId, txDetails, txDetailsLoading } = useContext(TxFlowContext) + const [txPreview, , txPreviewLoading] = useTxPreview(safeTx?.data, undefined, txId) + + if ((!safeTx && !safeTxError) || txDetailsLoading || txPreviewLoading) { + return <ReviewTransactionSkeleton /> + } + + return ( + <TxFlowStep title={title ?? 'Confirm transaction'}> + <ReviewTransactionContent {...props} txDetails={txDetails} txPreview={txPreview}> + {props.children} + </ReviewTransactionContent> + </TxFlowStep> + ) +} + +export default ReviewTransaction diff --git a/apps/web/src/components/tx/SendToBlock/index.tsx b/apps/web/src/components/tx/SendToBlock/index.tsx index 51b128a89b..ba3cbb5ce5 100644 --- a/apps/web/src/components/tx/SendToBlock/index.tsx +++ b/apps/web/src/components/tx/SendToBlock/index.tsx @@ -4,7 +4,7 @@ import FieldsGrid from '../FieldsGrid' const SendToBlock = ({ address, - title = 'To:', + title = 'Recipient', customAvatar, avatarSize, name, diff --git a/apps/web/src/components/tx/SignOrExecuteForm/SignForm.tsx b/apps/web/src/components/tx/SignOrExecuteForm/SignForm.tsx index 063a99aa56..44ea62750c 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/SignForm.tsx +++ b/apps/web/src/components/tx/SignOrExecuteForm/SignForm.tsx @@ -1,25 +1,25 @@ import madProps from '@/utils/mad-props' import { type ReactElement, type SyntheticEvent, useContext, useMemo, useState } from 'react' -import { CircularProgress, Box, Button, CardActions, Divider } from '@mui/material' -import Stack from '@mui/system/Stack' +import { CircularProgress, Box, Button, Divider, Tooltip } from '@mui/material' import ErrorMessage from '@/components/tx/ErrorMessage' import { trackError, Errors } from '@/services/exceptions' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import CheckWallet from '@/components/common/CheckWallet' import { useAlreadySigned, useTxActions } from './hooks' import type { SignOrExecuteProps } from './SignOrExecuteForm' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction } from '@safe-global/types-kit' import { TxModalContext } from '@/components/tx-flow' import commonCss from '@/components/tx-flow/common/styles.module.css' import { TxSecurityContext } from '../security/shared/TxSecurityContext' import NonOwnerError from '@/components/tx/SignOrExecuteForm/NonOwnerError' import WalletRejectionError from '@/components/tx/SignOrExecuteForm/WalletRejectionError' -import BatchButton from './BatchButton' -import { asError } from '@/services/exceptions/utils' +import BatchButton from '@/components/tx-flow/actions/Batching/BatchButton' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { isWalletRejection } from '@/utils/wallets' import { useSigner } from '@/hooks/wallets/useWallet' import { NestedTxSuccessScreenFlow } from '@/components/tx-flow/flows' import { useValidateTxData } from '@/hooks/useValidateTxData' +import { TxCardActions } from '@/components/tx-flow/common/TxCard' export const SignForm = ({ safeTx, @@ -33,12 +33,14 @@ export const SignForm = ({ isOwner, txActions, txSecurity, + tooltip, }: SignOrExecuteProps & { isOwner: ReturnType<typeof useIsSafeOwner> txActions: ReturnType<typeof useTxActions> txSecurity: ReturnType<typeof useTxSecurityContext> isCreation?: boolean safeTx?: SafeTransaction + tooltip?: string }): ReactElement => { // Form state const [isSubmittable, setIsSubmittable] = useState<boolean>(true) @@ -138,39 +140,35 @@ export const SignForm = ({ <Divider className={commonCss.nestedDivider} sx={{ pt: 3 }} /> - <CardActions> - <Stack - sx={{ - width: ['100%', '100%', '100%', 'auto'], - }} - direction={{ xs: 'column-reverse', lg: 'row' }} - spacing={{ xs: 2, md: 2 }} - > - {/* Batch button */} - {isCreation && !isBatch && ( - <BatchButton - onClick={onBatchClick} - disabled={submitDisabled || !isBatchable} - tooltip={!isBatchable ? `Cannot batch this type of transaction` : undefined} - /> + <TxCardActions> + {/* Batch button */} + {isCreation && !isBatch && ( + <BatchButton + onClick={onBatchClick} + disabled={submitDisabled || !isBatchable} + tooltip={!isBatchable ? `Cannot batch this type of transaction` : undefined} + /> + )} + + {/* Submit button */} + <CheckWallet checkNetwork={!submitDisabled}> + {(isOk) => ( + <Tooltip title={tooltip} placement="top"> + <span> + <Button + data-testid="sign-btn" + variant="contained" + type="submit" + disabled={!isOk || submitDisabled} + sx={{ minWidth: '82px', order: '1', width: ['100%', '100%', '100%', 'auto'] }} + > + {!isSubmittable ? <CircularProgress size={20} /> : 'Sign'} + </Button> + </span> + </Tooltip> )} - - {/* Submit button */} - <CheckWallet checkNetwork={!submitDisabled}> - {(isOk) => ( - <Button - data-testid="sign-btn" - variant="contained" - type="submit" - disabled={!isOk || submitDisabled} - sx={{ minWidth: '82px', order: '1', width: ['100%', '100%', '100%', 'auto'] }} - > - {!isSubmittable ? <CircularProgress size={20} /> : 'Sign'} - </Button> - )} - </CheckWallet> - </Stack> - </CardActions> + </CheckWallet> + </TxCardActions> </form> ) } diff --git a/apps/web/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx b/apps/web/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx index 2cfd2ea2f1..b2988ce6da 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx +++ b/apps/web/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx @@ -1,4 +1,4 @@ -import ProposerForm from '@/components/tx/SignOrExecuteForm/ProposerForm' +import ProposerForm from '@/components/tx-flow/actions/Propose/ProposerForm' import CounterfactualForm from '@/features/counterfactual/CounterfactualForm' import { useIsWalletProposer } from '@/hooks/useProposers' import useSafeInfo from '@/hooks/useSafeInfo' @@ -6,11 +6,11 @@ import { type ReactElement, type ReactNode, useState, useContext, useCallback } import madProps from '@/utils/mad-props' import ExecuteCheckbox from '../ExecuteCheckbox' import { useImmediatelyExecutable, useValidateNonce } from './hooks' -import ExecuteForm from './ExecuteForm' +import ExecuteForm from '@/components/tx-flow/actions/Execute/ExecuteForm' import SignForm from './SignForm' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import ErrorMessage from '../ErrorMessage' -import TxChecks from './TxChecks' +import TxChecks from '@/components/tx-flow/features/TxChecks/TxChecks' import TxCard from '@/components/tx-flow/common/TxCard' import ConfirmationTitle, { ConfirmationTitleTypes } from '@/components/tx/SignOrExecuteForm/ConfirmationTitle' import { useAppSelector } from '@/store' @@ -20,8 +20,12 @@ import { ErrorBoundary } from '@sentry/react' import ApprovalEditor from '../ApprovalEditor' import { isDelegateCall } from '@/services/tx/tx-sender/sdk' import useChainId from '@/hooks/useChainId' -import ExecuteThroughRoleForm from './ExecuteThroughRoleForm' -import { findAllowingRole, findMostLikelyRole, useRoles } from './ExecuteThroughRoleForm/hooks' +import ExecuteThroughRoleForm from '@/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm' +import { + findAllowingRole, + findMostLikelyRole, + useRoles, +} from '@/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/hooks' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import { BlockaidBalanceChanges } from '../security/blockaid/BlockaidBalanceChange' import { Blockaid } from '../security/blockaid' @@ -30,7 +34,7 @@ import { useApprovalInfos } from '../ApprovalEditor/hooks/useApprovalInfos' import type { TransactionDetails, TransactionPreview } from '@safe-global/safe-gateway-typescript-sdk' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' import ConfirmationView from '../confirmation-views' -import { SignerForm } from './SignerForm' +import { SignerForm } from '@/components/tx-flow/features/SignerSelect/SignerForm' import { useSigner } from '@/hooks/wallets/useWallet' import { trackTxEvents } from './tracking' import { TxNoteForm, encodeTxNote, trackAddNote } from '@/features/tx-notes' @@ -48,7 +52,6 @@ export type SignOrExecuteProps = { onlyExecute?: boolean disableSubmit?: boolean origin?: string - showMethodCall?: boolean } export const SignOrExecuteForm = ({ @@ -151,7 +154,16 @@ export const SignOrExecuteForm = ({ } if (!isCounterfactualSafe && willExecute && !isProposing) { - return <ExecuteForm {...commonProps} /> + return ( + <ExecuteForm + {...commonProps} + options={[{ label: 'Execute', id: 'execute' }]} + slotId="execute" + onChange={() => {}} + onSubmit={() => {}} + onSubmitSuccess={({ txId, isExecuted } = {}) => onFormSubmit(txId!, isExecuted)} + /> + ) } if (!isCounterfactualSafe && willExecuteThroughRole) { @@ -180,13 +192,11 @@ export const SignOrExecuteForm = ({ {props.children} <ConfirmationView - txId={props.txId} isCreation={isCreation} txDetails={props.txDetails} txPreview={props.txPreview} safeTx={safeTx} isBatch={props.isBatch} - showMethodCall={props.showMethodCall} isApproval={isApproval} > {!props.isRejection && ( @@ -199,7 +209,7 @@ export const SignOrExecuteForm = ({ {!isCounterfactualSafe && !props.isRejection && <BlockaidBalanceChanges />} </TxCard> - {!isCounterfactualSafe && !props.isRejection && <TxChecks />} + {!isCounterfactualSafe && !props.isRejection && safeTx && <TxChecks transaction={safeTx} />} <TxNoteForm isCreation={isCreation ?? false} onChange={onNoteChange} txDetails={props.txDetails} /> diff --git a/apps/web/src/components/tx/SignOrExecuteForm/SignOrExecuteFormV2.tsx b/apps/web/src/components/tx/SignOrExecuteForm/SignOrExecuteFormV2.tsx new file mode 100644 index 0000000000..50eb29a8e9 --- /dev/null +++ b/apps/web/src/components/tx/SignOrExecuteForm/SignOrExecuteFormV2.tsx @@ -0,0 +1,166 @@ +import ProposerForm from '@/components/tx-flow/actions/Propose/ProposerForm' +import CounterfactualForm from '@/features/counterfactual/CounterfactualForm' +import { useIsWalletProposer } from '@/hooks/useProposers' +import useSafeInfo from '@/hooks/useSafeInfo' +import { type ReactElement, type ReactNode, useContext, useCallback } from 'react' +import madProps from '@/utils/mad-props' +import { useImmediatelyExecutable, useValidateNonce } from './hooks' +import ExecuteForm from '@/components/tx-flow/actions/Execute/ExecuteForm' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { useAppSelector } from '@/store' +import { selectSettings } from '@/store/settingsSlice' +import useChainId from '@/hooks/useChainId' +import ExecuteThroughRoleForm from '@/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm' +import { + findAllowingRole, + findMostLikelyRole, + useRoles, +} from '@/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/hooks' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { useLazyGetTransactionDetailsQuery } from '@/store/api/gateway' +import type { TransactionDetails, TransactionPreview } from '@safe-global/safe-gateway-typescript-sdk' +import { useSigner } from '@/hooks/wallets/useWallet' +import { trackTxEvents } from './tracking' +import SignForm from './SignForm' + +export type SubmitCallback = (txId: string, isExecuted?: boolean) => void + +export type SignOrExecuteProps = { + txId?: string + onSubmit?: SubmitCallback + children?: ReactNode + isExecutable?: boolean + isRejection?: boolean + onlyExecute?: boolean + disableSubmit?: boolean + origin?: string + tooltip?: string + isMassPayout?: boolean +} + +export const SignOrExecuteFormV2 = ({ + chainId, + safeTx, + safeTxError, + onSubmit, + isCreation, + origin, + isMassPayout = false, + ...props +}: SignOrExecuteProps & { + chainId: ReturnType<typeof useChainId> + safeTx: ReturnType<typeof useSafeTx> + safeTxError: ReturnType<typeof useSafeTxError> + isCreation?: boolean + txDetails?: TransactionDetails + txPreview?: TransactionPreview +}): ReactElement | undefined => { + const { transactionExecution: shouldExecute } = useAppSelector(selectSettings) + const isNewExecutableTx = useImmediatelyExecutable() && isCreation + const isCorrectNonce = useValidateNonce(safeTx) + + const [trigger] = useLazyGetTransactionDetailsQuery() + const { safe } = useSafeInfo() + const isSafeOwner = useIsSafeOwner() + const signer = useSigner() + const isProposer = useIsWalletProposer() + const isProposing = isProposer && !isSafeOwner && !!isCreation + const isCounterfactualSafe = !safe.deployed + + // Check if a Zodiac Roles mod is enabled and if the user is a member of any role that allows the transaction + const roles = useRoles( + !isCounterfactualSafe && isCreation && !(isNewExecutableTx && isSafeOwner) ? safeTx : undefined, + ) + const allowingRole = findAllowingRole(roles) + const mostLikelyRole = findMostLikelyRole(roles) + const canExecuteThroughRole = !!allowingRole || (!!mostLikelyRole && !isSafeOwner) + const preferThroughRole = canExecuteThroughRole && !isSafeOwner // execute through role if a non-owner role member wallet is connected + + // If checkbox is checked and the transaction is executable, execute it, otherwise sign it + const canExecute = isCorrectNonce && (props.isExecutable || isNewExecutableTx) + const willExecute = (props.onlyExecute || shouldExecute) && canExecute && !preferThroughRole + const willExecuteThroughRole = + (props.onlyExecute || shouldExecute) && canExecuteThroughRole && (!canExecute || preferThroughRole) + + const onFormSubmit = useCallback( + async (txId: string, isExecuted = false, isRoleExecution = false, isProposerCreation = false) => { + onSubmit?.(txId, isExecuted) + + const { data: details } = await trigger({ chainId, txId }) + // Track tx event + trackTxEvents( + details, + !!isCreation, + isExecuted, + isRoleExecution, + isProposerCreation, + !!signer?.isSafe, + origin, + isMassPayout, + ) + }, + [chainId, isCreation, onSubmit, trigger, signer?.isSafe, origin, isMassPayout], + ) + + const onRoleExecutionSubmit = useCallback<typeof onFormSubmit>( + (txId, isExecuted) => onFormSubmit(txId, isExecuted, true), + [onFormSubmit], + ) + + const onProposerFormSubmit = useCallback<typeof onFormSubmit>( + (txId, isExecuted) => onFormSubmit(txId, isExecuted, false, true), + [onFormSubmit], + ) + + const commonProps = { + ...props, + safeTx, + isCreation, + origin, + onSubmit: onFormSubmit, + } + if (isCounterfactualSafe && !isProposing) { + return <CounterfactualForm {...commonProps} onlyExecute /> + } + + if (!isCounterfactualSafe && willExecute && !isProposing) { + return ( + <ExecuteForm + {...commonProps} + options={[{ label: 'Execute', id: 'execute' }]} + slotId="execute" + onChange={() => {}} + onSubmit={() => {}} + onSubmitSuccess={({ txId, isExecuted } = {}) => onFormSubmit(txId!, isExecuted)} + /> + ) + } + + if (!isCounterfactualSafe && willExecuteThroughRole) { + return ( + <ExecuteThroughRoleForm + {...commonProps} + role={(allowingRole || mostLikelyRole)!} + safeTxError={safeTxError} + onSubmit={onRoleExecutionSubmit} + /> + ) + } + + if (!isCounterfactualSafe && !willExecute && !willExecuteThroughRole && !isProposing) { + return <SignForm {...commonProps} /> + } + + if (isProposing) { + return <ProposerForm {...commonProps} onSubmit={onProposerFormSubmit} /> + } +} + +const useSafeTx = () => useContext(SafeTxContext).safeTx +const useSafeTxError = () => useContext(SafeTxContext).safeTxError + +export default madProps(SignOrExecuteFormV2, { + chainId: useChainId, + safeTx: useSafeTx, + safeTxError: useSafeTxError, +}) diff --git a/apps/web/src/components/tx/SignOrExecuteForm/TxChecks.tsx b/apps/web/src/components/tx/SignOrExecuteForm/TxChecks.tsx deleted file mode 100644 index d997c865b0..0000000000 --- a/apps/web/src/components/tx/SignOrExecuteForm/TxChecks.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { isTxSimulationEnabled } from '@/components/tx/security/tenderly/utils' -import { useCurrentChain, useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' -import { type ReactElement, useContext } from 'react' -import { TxSimulation, TxSimulationMessage } from '@/components/tx/security/tenderly' -import TxCard from '@/components/tx-flow/common/TxCard' -import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' -import { Box, Typography } from '@mui/material' - -import css from './styles.module.css' - -const TxChecks = ({ executionOwner }: { executionOwner?: string }): ReactElement | null => { - const { safeTx } = useContext(SafeTxContext) - const chain = useCurrentChain() - const isRiskMitigationFeatureEnabled = useHasFeature(FEATURES.RISK_MITIGATION) - const isTxSimulationFeatureEnabled = isTxSimulationEnabled(chain) - - if (!isTxSimulationFeatureEnabled && !isRiskMitigationFeatureEnabled) { - return null - } - - return ( - <TxCard> - <Typography variant="h5">Transaction checks</Typography> - - <TxSimulation disabled={false} transactions={safeTx} executionOwner={executionOwner} /> - - <Box className={css.mobileTxCheckMessages}> - <TxSimulationMessage /> - </Box> - </TxCard> - ) -} - -export default TxChecks diff --git a/apps/web/src/components/tx/SignOrExecuteForm/UnknownContractError.tsx b/apps/web/src/components/tx/SignOrExecuteForm/UnknownContractError.tsx index aa8f561094..90a622bc66 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/UnknownContractError.tsx +++ b/apps/web/src/components/tx/SignOrExecuteForm/UnknownContractError.tsx @@ -3,32 +3,55 @@ import type { TransactionData } from '@safe-global/safe-gateway-typescript-sdk' import ExternalLink from '@/components/common/ExternalLink' import { useCurrentChain } from '@/hooks/useChains' import useSafeInfo from '@/hooks/useSafeInfo' -import { getExplorerLink } from '@/utils/gateway' import ErrorMessage from '../ErrorMessage' -import { isValidMasterCopy } from '@/services/contracts/safeContracts' -import { extractMigrationL2MasterCopyAddress } from '@/features/multichain/utils/extract-migration-data' +import { isMigrationToL2Possible, isValidMasterCopy } from '@safe-global/utils/services/contracts/safeContracts' +import { AlertTitle, Typography } from '@mui/material' +import { isMigrateL2SingletonCall } from '@/utils/safe-migrations' +import { getExplorerLink } from '@safe-global/utils/utils/gateway' const UnknownContractError = ({ txData }: { txData: TransactionData | undefined }): ReactElement | null => { const { safe, safeAddress } = useSafeInfo() const currentChain = useCurrentChain() - const newMasterCopy = useMemo(() => { - return txData && extractMigrationL2MasterCopyAddress(txData) + + const isMigrationTx = useMemo((): boolean => { + return txData !== undefined && isMigrateL2SingletonCall(txData) }, [txData]) // Unsupported base contract const isUnknown = !isValidMasterCopy(safe.implementationVersionState) + const isMigrationPossible = isMigrationToL2Possible(safe) - if (!isUnknown || !!newMasterCopy) return null + if (!isUnknown || isMigrationTx) return null return ( - <ErrorMessage> - This Safe Account was created with an unsupported base contract. It should <b>ONLY</b> be used for fund recovery. - Transactions will execute but the transaction list may not immediately update. Transaction success can be verified - on the{' '} - <ExternalLink href={currentChain ? getExplorerLink(safeAddress, currentChain.blockExplorerUriTemplate).href : ''}> - {currentChain?.chainName} explorer - </ExternalLink> - . + <ErrorMessage level="error"> + <AlertTitle> + <Typography + variant="subtitle1" + sx={{ + fontWeight: 700, + }} + > + This Safe Account was created with an unsupported base contract. + </Typography> + </AlertTitle> + {isMigrationPossible ? ( + <> + The Safe Account can be migrated to use the supported base contract. We advise to do that in the Safe's + settings before executing other transactions. + </> + ) : ( + <> + It should <b>ONLY</b> be used for fund recovery. Transactions will execute but the transaction list may not + update. Transaction success can be verified on the{' '} + <ExternalLink + href={currentChain ? getExplorerLink(safeAddress, currentChain.blockExplorerUriTemplate).href : ''} + > + {currentChain?.chainName} explorer + </ExternalLink> + . + </> + )} </ErrorMessage> ) } diff --git a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignForm.test.tsx b/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignForm.test.tsx index a803f5cdee..4d14b697b0 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignForm.test.tsx +++ b/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignForm.test.tsx @@ -1,11 +1,11 @@ -import { defaultSecurityContextValues } from '@/components/tx/security/shared/TxSecurityContext' +import { defaultSecurityContextValues } from '@safe-global/utils/components/tx/security/shared/utils' import { type ReactElement } from 'react' import * as hooks from '@/components/tx/SignOrExecuteForm/hooks' import * as useValidateTxData from '@/hooks/useValidateTxData' import { SignForm } from '@/components/tx/SignOrExecuteForm/SignForm' import { render } from '@/tests/test-utils' import { createMockSafeTransaction } from '@/tests/transactions' -import { OperationType } from '@safe-global/safe-core-sdk-types' +import { OperationType } from '@safe-global/types-kit' import { fireEvent, waitFor } from '@testing-library/react' // We assume that CheckWallet always returns true diff --git a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecute.test.tsx b/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecute.test.tsx index 16863737db..2dc0df0f8d 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecute.test.tsx +++ b/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecute.test.tsx @@ -40,7 +40,7 @@ describe('SignOrExecute', () => { jest.spyOn(useTxPreviewHooks, 'default').mockReturnValue([ { txInfo: {}, - txData: {}, + txData: { to: { value: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67' } }, } as TransactionPreview, undefined, false, diff --git a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteForm.test.tsx b/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteForm.test.tsx index 2abab2eef7..83ef933800 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteForm.test.tsx +++ b/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteForm.test.tsx @@ -1,6 +1,6 @@ import { SignOrExecuteForm } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' import * as hooks from '@/components/tx/SignOrExecuteForm/hooks' -import * as execThroughRoleHooks from '@/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks' +import * as execThroughRoleHooks from '@/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/hooks' import { safeTxBuilder } from '@/tests/builders/safeTx' import { render } from '@/tests/test-utils' import { fireEvent } from '@testing-library/react' diff --git a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteFormV2.test.tsx b/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteFormV2.test.tsx new file mode 100644 index 0000000000..63e0b6d65f --- /dev/null +++ b/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignOrExecuteFormV2.test.tsx @@ -0,0 +1,224 @@ +import * as hooks from '@/components/tx/SignOrExecuteForm/hooks' +import * as execThroughRoleHooks from '@/components/tx-flow/actions/ExecuteThroughRole/ExecuteThroughRoleForm/hooks' +import { safeTxBuilder } from '@/tests/builders/safeTx' +import { render } from '@/tests/test-utils' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { SignOrExecuteFormV2 } from '../SignOrExecuteFormV2' +import { encodeBytes32String } from 'ethers' +import { Status } from 'zodiac-roles-deployments' +import * as useIsSafeOwner from '@/hooks/useIsSafeOwner' + +const txDetails = { + safeAddress: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', + txId: 'multisig_0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67_0x938635afdeab5ab17b377896f10dbe161fcc44d488296bc0000b733623d57c80', + executedAt: null, + txStatus: 'AWAITING_EXECUTION', + txInfo: { + type: 'SettingsChange', + humanDescription: 'Add new owner 0xd8dA...6045 with threshold 1', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + valueDecoded: null, + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + valueDecoded: null, + }, + ], + }, + settingsInfo: { + type: 'ADD_OWNER', + owner: { + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + name: null, + logoUri: null, + }, + threshold: 1, + }, + }, + txData: { + hexData: + '0x0d582f13000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000000000000000001', + dataDecoded: { + method: 'addOwnerWithThreshold', + parameters: [ + { + name: 'owner', + type: 'address', + value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + valueDecoded: null, + }, + { + name: '_threshold', + type: 'uint256', + value: '1', + valueDecoded: null, + }, + ], + }, + to: { + value: '0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', + name: 'SafeProxy', + logoUri: null, + }, + value: '0', + operation: 0, + trustedDelegateCallTarget: null, + addressInfoIndex: null, + }, + txHash: null, + detailedExecutionInfo: { + type: 'MULTISIG', + submittedAt: 1726497729356, + nonce: 8, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { + value: '0x0000000000000000000000000000000000000000', + name: 'MetaMultiSigWallet', + logoUri: null, + }, + safeTxHash: '0x938635afdeab5ab17b377896f10dbe161fcc44d488296bc0000b733623d57c80', + executor: null, + signers: [ + { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: null, + logoUri: null, + }, + ], + confirmationsRequired: 1, + confirmations: [ + { + signer: { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: null, + logoUri: null, + }, + signature: + '0xd91721922d38384a4d40b20d923c49cefb56f60bfe0b357de11a4a044483d670075842d7bba26cf4aa84788ab0bd85137ad09c7f9cd84154db00d456b15e42dc1b', + submittedAt: 1726497740521, + }, + ], + rejectors: [], + gasTokenInfo: null, + trusted: true, + proposer: { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: null, + logoUri: null, + }, + }, + safeAppInfo: null, +} as unknown as TransactionDetails + +describe('SignOrExecuteFormV2', () => { + it('should offer to execute through a role if the user is a role member and the transaction is executable through the role', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_OK]) + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) + jest.spyOn(useIsSafeOwner, 'default').mockReturnValue(true) + + const { queryByTestId } = render( + <SignOrExecuteFormV2 + txDetails={txDetails} + txId="0x012312" + safeTxError={undefined} + safeTx={safeTxBuilder().build()} + onSubmit={jest.fn()} + onlyExecute={true} + isExecutable={false} + chainId="1" + />, + ) + expect(queryByTestId('execute-through-role-form-btn')).toBeInTheDocument() + }) + + it('should not offer to execute through a role if the user is a safe owner and role member but the role lacks permissions', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_TARGET_NOT_ALLOWED]) + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) + jest.spyOn(useIsSafeOwner, 'default').mockReturnValue(true) + + const { queryByTestId } = render( + <SignOrExecuteFormV2 + txDetails={txDetails} + txId="0x012312" + safeTxError={undefined} + safeTx={safeTxBuilder().build()} + onSubmit={jest.fn()} + onlyExecute={true} + isExecutable={false} + chainId="1" + />, + ) + expect(queryByTestId('execute-through-role-form-btn')).not.toBeInTheDocument() + }) + + it('should offer to execute through a role if the user is a role member but not a safe owner, even if the role lacks permissions', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_TARGET_NOT_ALLOWED]) + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + jest.spyOn(hooks, 'useImmediatelyExecutable').mockReturnValue(false) + jest.spyOn(useIsSafeOwner, 'default').mockReturnValue(false) + + const { queryByTestId } = render( + <SignOrExecuteFormV2 + txDetails={txDetails} + txId="0x012312" + safeTxError={undefined} + safeTx={safeTxBuilder().build()} + onSubmit={jest.fn()} + onlyExecute={true} + isExecutable={false} + chainId="1" + />, + ) + expect(queryByTestId('execute-through-role-form-btn')).toBeInTheDocument() + }) + + it('should not offer to execute through a role if the transaction can also be directly executed without going through the role', () => { + jest.spyOn(execThroughRoleHooks, 'useRoles').mockReturnValue([TEST_ROLE_OK]) + jest.spyOn(hooks, 'useValidateNonce').mockReturnValue(true) + jest.spyOn(useIsSafeOwner, 'default').mockReturnValue(true) + + const { queryByTestId } = render( + <SignOrExecuteFormV2 + txDetails={txDetails} + txId="0x012312" + safeTxError={undefined} + safeTx={safeTxBuilder().build()} + onSubmit={jest.fn()} + onlyExecute={true} + isExecutable={true} + chainId="1" + />, + ) + expect(queryByTestId('execute-through-role-form-btn')).not.toBeInTheDocument() + }) +}) + +const ROLES_MOD_ADDRESS = '0x1234567890000000000000000000000000000000' +const ROLE_KEY = encodeBytes32String('eth_wrapping') + +const TEST_ROLE_OK: execThroughRoleHooks.Role = { + modAddress: ROLES_MOD_ADDRESS, + roleKey: ROLE_KEY as `0x${string}`, + multiSend: '0x9641d764fc13c8b624c04430c7356c1c7c8102e2', + status: Status.Ok, +} + +const TEST_ROLE_TARGET_NOT_ALLOWED: execThroughRoleHooks.Role = { + modAddress: ROLES_MOD_ADDRESS, + roleKey: ROLE_KEY as `0x${string}`, + multiSend: '0x9641d764fc13c8b624c04430c7356c1c7c8102e2', + status: Status.TargetAddressNotAllowed, +} diff --git a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignerForm.test.tsx b/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignerForm.test.tsx deleted file mode 100644 index c96501f4e7..0000000000 --- a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/SignerForm.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners' -import useSafeInfo from '@/hooks/useSafeInfo' -import { render } from '@/tests/test-utils' -import { SignerForm } from '../SignerForm' -import { faker } from '@faker-js/faker' -import { extendedSafeInfoBuilder, addressExBuilder } from '@/tests/builders/safe' -import { generateRandomArray } from '@/tests/builders/utils' -import { type Eip1193Provider } from 'ethers' -import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' -import { type ReactElement, useState } from 'react' -import { WalletContext } from '@/components/common/WalletProvider' - -jest.mock('@/hooks/useNestedSafeOwners') -jest.mock('@/hooks/useSafeInfo') - -const TestWalletContextProvider = ({ - connectedWallet, - children, -}: { - connectedWallet: ConnectedWallet | null - children: ReactElement -}) => { - const [signerAddress, setSignerAddress] = useState<string>() - - return ( - <WalletContext.Provider - value={ - connectedWallet - ? { - connectedWallet, - setSignerAddress, - signer: { - address: signerAddress || connectedWallet.address, - chainId: '1', - provider: null, - isSafe: Boolean(signerAddress), - }, - } - : null - } - > - {children} - </WalletContext.Provider> - ) -} - -describe('SignerForm', () => { - const mockUseSafeInfo = useSafeInfo as jest.MockedFunction<typeof useSafeInfo> - const mockUseNestedSafeOwners = useNestedSafeOwners as jest.MockedFunction<typeof useNestedSafeOwners> - - const safeAddress = faker.finance.ethereumAddress() - // Safe with 3 owners - const mockSafeInfo = { - safeAddress, - safe: extendedSafeInfoBuilder() - .with({ address: { value: safeAddress } }) - .with({ chainId: '1' }) - .with({ owners: generateRandomArray(() => addressExBuilder().build(), { min: 3, max: 3 }) }) - .build(), - safeLoaded: true, - safeLoading: false, - } - - const mockOwners = mockSafeInfo.safe.owners - - beforeAll(() => { - mockUseSafeInfo.mockReturnValue(mockSafeInfo) - }) - - it('should not render anything if no wallet is connected', () => { - const result = render( - <TestWalletContextProvider connectedWallet={null}> - <SignerForm /> - </TestWalletContextProvider>, - ) - expect(result.queryByText('Sign with')).toBeNull() - }) - - it('should not render if there are no nested Safes', () => { - mockUseNestedSafeOwners.mockReturnValue([]) - - const result = render( - <TestWalletContextProvider - connectedWallet={{ - address: faker.finance.ethereumAddress(), - chainId: '1', - label: 'MetaMask', - provider: {} as Eip1193Provider, - }} - > - <SignerForm /> - </TestWalletContextProvider>, - ) - - expect(result.queryByText('Sign with')).toBeNull() - }) - - it('should render sign form if there are nested Safes', () => { - mockUseNestedSafeOwners.mockReturnValue([mockOwners[0].value]) - const result = render( - <TestWalletContextProvider - connectedWallet={{ - address: faker.finance.ethereumAddress(), - chainId: '1', - label: 'MetaMask', - provider: {} as Eip1193Provider, - }} - > - <SignerForm /> - </TestWalletContextProvider>, - ) - expect(result.queryByText('Sign with')).toBeVisible() - }) - - it('should render execution form if there are nested Safes', () => { - mockUseNestedSafeOwners.mockReturnValue([mockOwners[0].value]) - const result = render( - <TestWalletContextProvider - connectedWallet={{ - address: faker.finance.ethereumAddress(), - chainId: '1', - label: 'MetaMask', - provider: {} as Eip1193Provider, - }} - > - <SignerForm willExecute /> - </TestWalletContextProvider>, - ) - expect(result.queryByText('Execute with')).toBeVisible() - }) -}) diff --git a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/__snapshots__/SignOrExecute.test.tsx.snap b/apps/web/src/components/tx/SignOrExecuteForm/__tests__/__snapshots__/SignOrExecute.test.tsx.snap index 12cc38cb8e..8466dd8c18 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/__snapshots__/SignOrExecute.test.tsx.snap +++ b/apps/web/src/components/tx/SignOrExecuteForm/__tests__/__snapshots__/SignOrExecute.test.tsx.snap @@ -11,150 +11,608 @@ exports[`SignOrExecute should display a confirmation screen 1`] = ` data-testid="card-content" > <div - class="MuiStack-root css-1sazv7p-MuiStack-root" + class="MuiBox-root css-0" > <div - class="MuiBox-root css-0" + class="MuiStack-root css-1sazv7p-MuiStack-root" > <div - class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiAccordion-root MuiAccordion-rounded MuiAccordion-gutters css-1gdgqta-MuiPaper-root-MuiAccordion-root" - style="--Paper-shadow: none;" + class="MuiGrid-root MuiGrid-container css-1yff1ei-MuiGrid-root" > - <h3 - class="MuiAccordion-heading css-cy7rkm-MuiAccordion-heading" + <div + class="MuiGrid-root MuiGrid-item css-ezmk0c-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" > - <button - aria-expanded="false" - class="MuiButtonBase-root MuiAccordionSummary-root MuiAccordionSummary-gutters accordion css-10sjung-MuiButtonBase-root-MuiAccordionSummary-root" - data-testid="decoded-tx-summary" - tabindex="0" - type="button" + <span + class="MuiTypography-root MuiTypography-body2 css-1ew0eu5-MuiTypography-root" > - <span - class="MuiAccordionSummary-content MuiAccordionSummary-contentGutters css-1r0e0ir-MuiAccordionSummary-content" + Interacted with + </span> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + <div + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + <div + class="container" > - Advanced details - <span - class="" - data-mui-internal-clone-element="true" + <div + class="avatarContainer" + style="width: 20px; height: 20px;" > - <mock-icon - aria-hidden="" - class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-17ibv3e-MuiSvgIcon-root" - focusable="false" + <div + class="icon" + style="background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4IDgiIHNoYXBlLXJlbmRlcmluZz0ib3B0aW1pemVTcGVlZCIgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0Ij48cGF0aCBmaWxsPSJoc2woOTIgOTYlIDQzJSkiIGQ9Ik0wLDBIOFY4SDB6Ii8+PHBhdGggZmlsbD0iaHNsKDMxMCA4NCUgMzglKSIgZD0iTTEsMGgxdjFoLTF6TTYsMGgxdjFoLTF6TTIsMGgxdjFoLTF6TTUsMGgxdjFoLTF6TTMsMGgxdjFoLTF6TTQsMGgxdjFoLTF6TTAsMWgxdjFoLTF6TTcsMWgxdjFoLTF6TTEsMWgxdjFoLTF6TTYsMWgxdjFoLTF6TTIsMWgxdjFoLTF6TTUsMWgxdjFoLTF6TTMsMWgxdjFoLTF6TTQsMWgxdjFoLTF6TTAsMmgxdjFoLTF6TTcsMmgxdjFoLTF6TTIsMmgxdjFoLTF6TTUsMmgxdjFoLTF6TTMsMmgxdjFoLTF6TTQsMmgxdjFoLTF6TTMsM2gxdjFoLTF6TTQsM2gxdjFoLTF6TTAsNGgxdjFoLTF6TTcsNGgxdjFoLTF6TTEsNGgxdjFoLTF6TTYsNGgxdjFoLTF6TTIsNGgxdjFoLTF6TTUsNGgxdjFoLTF6TTEsNWgxdjFoLTF6TTYsNWgxdjFoLTF6TTIsNWgxdjFoLTF6TTUsNWgxdjFoLTF6TTMsNWgxdjFoLTF6TTQsNWgxdjFoLTF6TTIsNmgxdjFoLTF6TTUsNmgxdjFoLTF6TTMsNmgxdjFoLTF6TTQsNmgxdjFoLTF6TTMsN2gxdjFoLTF6TTQsN2gxdjFoLTF6Ii8+PHBhdGggZmlsbD0iaHNsKDM1MSA2MSUgNjUlKSIgZD0iTTMsNGgxdjFoLTF6TTQsNGgxdjFoLTF6TTAsNWgxdjFoLTF6TTcsNWgxdjFoLTF6TTAsN2gxdjFoLTF6TTcsN2gxdjFoLTF6Ii8+PC9zdmc+); width: 20px; height: 20px;" /> - </span> + </div> <div - class="MuiBox-root css-1rr4qq7" - /> - </span> - <span - class="MuiAccordionSummary-expandIconWrapper css-1wqf3nl-MuiAccordionSummary-expandIconWrapper" + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span> + 0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67 + </span> + </span> + </div> + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <button + aria-label="Copy to clipboard" + class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + tabindex="0" + type="button" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" + data-testid="copy-btn-icon" + focusable="false" + /> + </button> + </span> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + <div + class="MuiBox-root css-1yuhvjn" + > + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiAccordion-root MuiAccordion-rounded MuiAccordion-gutters css-wrhbuy-MuiPaper-root-MuiAccordion-root" + color="info" + style="--Paper-shadow: none;" + > + <h3 + class="MuiAccordion-heading css-cy7rkm-MuiAccordion-heading" + > + <button + aria-expanded="false" + class="MuiButtonBase-root MuiAccordionSummary-root MuiAccordionSummary-gutters accordion css-10sjung-MuiButtonBase-root-MuiAccordionSummary-root" + data-testid="decoded-tx-summary" + tabindex="0" + type="button" + > + <span + class="MuiAccordionSummary-content MuiAccordionSummary-contentGutters css-1r0e0ir-MuiAccordionSummary-content" + > + <div + class="MuiStack-root css-1bujbuo-MuiStack-root" > - <svg - aria-hidden="true" - class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-1dhtbeh-MuiSvgIcon-root" - data-testid="ExpandMoreIcon" - focusable="false" - viewBox="0 0 24 24" + <h6 + class="MuiTypography-root MuiTypography-subtitle2 css-8ydk8b-MuiTypography-root" + data-testid="tx-advanced-details" > - <path - d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" - /> - </svg> - </span> - </button> - </h3> + Transaction details + <span + class="" + data-mui-internal-clone-element="true" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-17ibv3e-MuiSvgIcon-root" + focusable="false" + /> + </span> + </h6> + </div> + </span> + <span + class="MuiAccordionSummary-expandIconWrapper css-1wqf3nl-MuiAccordionSummary-expandIconWrapper" + > + <svg + aria-hidden="true" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-1dhtbeh-MuiSvgIcon-root" + data-testid="ExpandMoreIcon" + focusable="false" + viewBox="0 0 24 24" + > + <path + d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" + /> + </svg> + </span> + </button> + </h3> + <div + class="MuiCollapse-root MuiCollapse-vertical MuiCollapse-hidden css-cwrbtg-MuiCollapse-root" + style="min-height: 0px;" + > <div - class="MuiCollapse-root MuiCollapse-vertical MuiCollapse-hidden css-cwrbtg-MuiCollapse-root" - style="min-height: 0px;" + class="MuiCollapse-wrapper MuiCollapse-vertical css-1x6hinx-MuiCollapse-wrapper" > <div - class="MuiCollapse-wrapper MuiCollapse-vertical css-1x6hinx-MuiCollapse-wrapper" + class="MuiCollapse-wrapperInner MuiCollapse-vertical css-1i4ywhz-MuiCollapse-wrapperInner" > <div - class="MuiCollapse-wrapperInner MuiCollapse-vertical css-1i4ywhz-MuiCollapse-wrapperInner" + class="MuiAccordion-region" + role="region" > <div - class="MuiAccordion-region" - role="region" + class="MuiAccordionDetails-root css-w74p4c-MuiAccordionDetails-root" + data-testid="decoded-tx-details" > <div - class="MuiAccordionDetails-root css-w74p4c-MuiAccordionDetails-root" - data-testid="decoded-tx-details" + class="MuiStack-root css-hwnj0i-MuiStack-root" > <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + class="MuiBox-root css-0" > - <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" - data-testid="tx-row-title" - style="word-break: break-word;" + <h6 + class="MuiTypography-root MuiTypography-subtitle2 css-4lr168-MuiTypography-root" > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" - > - safeTxGas: - </p> - </div> - <div - class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" - data-testid="tx-data-row" + Advanced details + </h6> + <p + class="MuiTypography-root MuiTypography-body2 css-gkag80-MuiTypography-root" > - <div - class="MuiBox-root css-rurw5h" + Cross-verify your transaction data with external tools like + + <a + class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineAlways css-r9pq5a-MuiTypography-root-MuiLink-root" + href="https://safeutils.openzeppelin.com" + rel="noreferrer noopener" + target="_blank" > - 0 - </div> - </div> - </div> - <div - class="MuiBox-root css-164r41r" - > - <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" - > - <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" - data-testid="tx-row-title" - style="word-break: break-word;" + <span + class="MuiBox-root css-u9xrjn" + > + Safe Utils + <svg + aria-hidden="true" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-tqxw8e-MuiSvgIcon-root" + data-testid="OpenInNewRoundedIcon" + focusable="false" + viewBox="0 0 24 24" + > + <path + d="M18 19H6c-.55 0-1-.45-1-1V6c0-.55.45-1 1-1h5c.55 0 1-.45 1-1s-.45-1-1-1H5c-1.11 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-6c0-.55-.45-1-1-1s-1 .45-1 1v5c0 .55-.45 1-1 1M14 4c0 .55.45 1 1 1h2.59l-9.13 9.13c-.39.39-.39 1.02 0 1.41s1.02.39 1.41 0L19 6.41V9c0 .55.45 1 1 1s1-.45 1-1V4c0-.55-.45-1-1-1h-5c-.55 0-1 .45-1 1" + /> + </svg> + </span> + </a> + and + + <a + class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineAlways css-r9pq5a-MuiTypography-root-MuiLink-root" + href="https://transaction-decoder.pages.dev" + rel="noreferrer noopener" + target="_blank" > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + <span + class="MuiBox-root css-u9xrjn" > - Raw data: - </p> - </div> + Transaction Decoder + <svg + aria-hidden="true" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-tqxw8e-MuiSvgIcon-root" + data-testid="OpenInNewRoundedIcon" + focusable="false" + viewBox="0 0 24 24" + > + <path + d="M18 19H6c-.55 0-1-.45-1-1V6c0-.55.45-1 1-1h5c.55 0 1-.45 1-1s-.45-1-1-1H5c-1.11 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-6c0-.55-.45-1-1-1s-1 .45-1 1v5c0 .55-.45 1-1 1M14 4c0 .55.45 1 1 1h2.59l-9.13 9.13c-.39.39-.39 1.02 0 1.41s1.02.39 1.41 0L19 6.41V9c0 .55.45 1 1 1s1-.45 1-1V4c0-.55-.45-1-1-1h-5c-.55 0-1 .45-1 1" + /> + </svg> + </span> + </a> + . + </p> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 css-shqplw-MuiPaper-root" + style="--Paper-shadow: none;" + > <div - class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" - data-testid="tx-data-row" + class="MuiStack-root css-1sazv7p-MuiStack-root" > <div - class="encodedData MuiBox-root css-0" - data-testid="tx-hexData" + class="MuiStack-root css-1n46f6x-MuiStack-root" > - <span - aria-label="Copy to clipboard" - class="" - data-mui-internal-clone-element="true" - style="cursor: pointer;" + <div + aria-label="text alignment" + class="MuiToggleButtonGroup-root MuiToggleButtonGroup-horizontal css-cinseo-MuiToggleButtonGroup-root" + role="group" > <button - aria-label="Copy to clipboard" - class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + aria-pressed="true" + class="MuiButtonBase-root MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButton-root Mui-selected MuiToggleButton-sizeSmall MuiToggleButton-standard MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButtonGroup-firstButton css-u2ogzi-MuiButtonBase-root-MuiToggleButton-root" + tabindex="0" + type="button" + value="0" + > + <div + class="MuiBox-root css-mro3c9" + > + Data + </div> + </button> + <button + aria-pressed="false" + class="MuiButtonBase-root MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButton-root MuiToggleButton-sizeSmall MuiToggleButton-standard MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButtonGroup-middleButton css-u2ogzi-MuiButtonBase-root-MuiToggleButton-root" tabindex="0" type="button" + value="1" > - <mock-icon - aria-hidden="" - class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" - data-testid="copy-btn-icon" - focusable="false" - /> + <div + class="MuiBox-root css-mro3c9" + > + Hashes + </div> </button> - </span> - 0x - + <button + aria-pressed="false" + class="MuiButtonBase-root MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButton-root MuiToggleButton-sizeSmall MuiToggleButton-standard MuiToggleButtonGroup-grouped MuiToggleButtonGroup-groupedHorizontal MuiToggleButtonGroup-lastButton css-u2ogzi-MuiButtonBase-root-MuiToggleButton-root" + tabindex="0" + type="button" + value="2" + > + <div + class="MuiBox-root css-mro3c9" + > + JSON + </div> + </button> + </div> + </div> + <div + class="MuiBox-root css-e0rnc0" + > + <div + class="MuiStack-root css-jfdv4h-MuiStack-root" + > + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + To + </p> + <div + class="MuiBox-root css-0" + > + <p + class="MuiTypography-root MuiTypography-body2 css-m9op2-MuiTypography-root" + > + <div + class="container" + > + <div + class="avatarContainer" + style="width: 20px; height: 20px;" + > + <div + class="icon" + style="background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4IDgiIHNoYXBlLXJlbmRlcmluZz0ib3B0aW1pemVTcGVlZCIgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0Ij48cGF0aCBmaWxsPSJoc2woMjEzIDY1JSA0OSUpIiBkPSJNMCwwSDhWOEgweiIvPjxwYXRoIGZpbGw9ImhzbCgzNTQgNjYlIDU2JSkiIGQ9Ik0yLDBoMXYxaC0xek01LDBoMXYxaC0xek0wLDFoMXYxaC0xek03LDFoMXYxaC0xek0xLDJoMXYxaC0xek02LDJoMXYxaC0xek0yLDJoMXYxaC0xek01LDJoMXYxaC0xek0xLDNoMXYxaC0xek02LDNoMXYxaC0xek0yLDNoMXYxaC0xek01LDNoMXYxaC0xek0zLDNoMXYxaC0xek00LDNoMXYxaC0xek0wLDRoMXYxaC0xek03LDRoMXYxaC0xek0yLDRoMXYxaC0xek01LDRoMXYxaC0xek0wLDVoMXYxaC0xek03LDVoMXYxaC0xek0zLDVoMXYxaC0xek00LDVoMXYxaC0xek0zLDdoMXYxaC0xek00LDdoMXYxaC0xeiIvPjxwYXRoIGZpbGw9ImhzbCgzMjcgNDElIDYzJSkiIGQ9Ik0wLDNoMXYxaC0xek03LDNoMXYxaC0xek0zLDRoMXYxaC0xek00LDRoMXYxaC0xek0wLDZoMXYxaC0xek03LDZoMXYxaC0xek0yLDdoMXYxaC0xek01LDdoMXYxaC0xeiIvPjwvc3ZnPg==); width: 20px; height: 20px;" + /> + </div> + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span> + 0x + <b> + 0000 + </b> + 00000000000000000000000000000000 + <b> + 0000 + </b> + </span> + </span> + </div> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </p> + </div> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + Value + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + 0x0 + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + Data + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-n5xdb5-MuiTypography-root" + > + <div + class="encodedData MuiBox-root css-0" + data-testid="tx-hexData" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span + class="monospace" + > + <b + aria-label="The first 4 bytes determine the contract method that is being called" + class="" + data-mui-internal-clone-element="true" + > + 0x + </b> + + </span> + </span> + </div> + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + Operation + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-1hy3mdy-MuiTypography-root" + > + 0 + ( + call + ) + <svg + aria-hidden="true" + class="MuiSvgIcon-root MuiSvgIcon-colorSuccess MuiSvgIcon-fontSizeInherit css-113mief-MuiSvgIcon-root" + data-testid="CheckIcon" + focusable="false" + viewBox="0 0 24 24" + > + <path + d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" + /> + </svg> + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + SafeTxGas + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + BaseGas + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + GasPrice + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + GasToken + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + <div + class="container" + > + <div + class="avatarContainer" + style="width: 20px; height: 20px;" + > + <span + class="MuiSkeleton-root MuiSkeleton-circular MuiSkeleton-pulse css-143xw5x-MuiSkeleton-root" + style="width: 20px; height: 20px;" + /> + </div> + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span /> + </span> + </div> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + RefundReceiver + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + <div + class="container" + > + <div + class="avatarContainer" + style="width: 20px; height: 20px;" + > + <span + class="MuiSkeleton-root MuiSkeleton-circular MuiSkeleton-pulse css-143xw5x-MuiSkeleton-root" + style="width: 20px; height: 20px;" + /> + </div> + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span /> + </span> + </div> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </p> + </div> + <hr + class="MuiDivider-root MuiDivider-fullWidth css-1facvfi-MuiDivider-root" + /> + <div + class="MuiStack-root css-665wph-MuiStack-root" + > + <p + class="MuiTypography-root MuiTypography-body2 css-1korokw-MuiTypography-root" + > + Nonce + </p> + <p + class="MuiTypography-root MuiTypography-body2 css-17vdyq3-MuiTypography-root" + > + 100 + </p> + </div> + </div> </div> </div> </div> @@ -169,6 +627,21 @@ exports[`SignOrExecute should display a confirmation screen 1`] = ` </div> </div> </div> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" + style="--Paper-shadow: none;" + > + <div + class="MuiCardContent-root cardContent css-1lt5qva-MuiCardContent-root" + data-testid="card-content" + > + <h5 + class="MuiTypography-root MuiTypography-h5 css-1whrnku-MuiTypography-root" + > + Transaction checks + </h5> + </div> + </div> <div class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" style="--Paper-shadow: none;" @@ -257,15 +730,20 @@ exports[`SignOrExecute should display a confirmation screen 1`] = ` class="" data-mui-internal-clone-element="true" > - <button - class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary Mui-disabled MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary css-1hp2ejb-MuiButtonBase-root-MuiButton-root" - data-testid="sign-btn" - disabled="" - tabindex="-1" - type="submit" + <span + class="" + data-mui-internal-clone-element="true" > - Sign - </button> + <button + class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary Mui-disabled MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary css-1hp2ejb-MuiButtonBase-root-MuiButton-root" + data-testid="sign-btn" + disabled="" + tabindex="-1" + type="submit" + > + Sign + </button> + </span> </span> </div> </div> @@ -342,6 +820,15 @@ exports[`SignOrExecute should display a loading component 1`] = ` exports[`SignOrExecute should display an error screen 1`] = ` <div> + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" + style="--Paper-shadow: none;" + > + <div + class="MuiCardContent-root cardContent css-1lt5qva-MuiCardContent-root" + data-testid="card-content" + /> + </div> <div class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiCard-root css-1w4z6qv-MuiPaper-root-MuiCard-root" style="--Paper-shadow: none;" @@ -350,163 +837,11 @@ exports[`SignOrExecute should display an error screen 1`] = ` class="MuiCardContent-root cardContent css-1lt5qva-MuiCardContent-root" data-testid="card-content" > - <div - class="MuiStack-root css-1sazv7p-MuiStack-root" + <h5 + class="MuiTypography-root MuiTypography-h5 css-1whrnku-MuiTypography-root" > - <div - class="MuiBox-root css-0" - > - <div - class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiAccordion-root MuiAccordion-rounded MuiAccordion-gutters css-1gdgqta-MuiPaper-root-MuiAccordion-root" - style="--Paper-shadow: none;" - > - <h3 - class="MuiAccordion-heading css-cy7rkm-MuiAccordion-heading" - > - <button - aria-expanded="false" - class="MuiButtonBase-root MuiAccordionSummary-root MuiAccordionSummary-gutters accordion css-10sjung-MuiButtonBase-root-MuiAccordionSummary-root" - data-testid="decoded-tx-summary" - tabindex="0" - type="button" - > - <span - class="MuiAccordionSummary-content MuiAccordionSummary-contentGutters css-1r0e0ir-MuiAccordionSummary-content" - > - Advanced details - <span - class="" - data-mui-internal-clone-element="true" - > - <mock-icon - aria-hidden="" - class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-17ibv3e-MuiSvgIcon-root" - focusable="false" - /> - </span> - <div - class="MuiBox-root css-1rr4qq7" - /> - </span> - <span - class="MuiAccordionSummary-expandIconWrapper css-1wqf3nl-MuiAccordionSummary-expandIconWrapper" - > - <svg - aria-hidden="true" - class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-1dhtbeh-MuiSvgIcon-root" - data-testid="ExpandMoreIcon" - focusable="false" - viewBox="0 0 24 24" - > - <path - d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" - /> - </svg> - </span> - </button> - </h3> - <div - class="MuiCollapse-root MuiCollapse-vertical MuiCollapse-hidden css-cwrbtg-MuiCollapse-root" - style="min-height: 0px;" - > - <div - class="MuiCollapse-wrapper MuiCollapse-vertical css-1x6hinx-MuiCollapse-wrapper" - > - <div - class="MuiCollapse-wrapperInner MuiCollapse-vertical css-1i4ywhz-MuiCollapse-wrapperInner" - > - <div - class="MuiAccordion-region" - role="region" - > - <div - class="MuiAccordionDetails-root css-w74p4c-MuiAccordionDetails-root" - data-testid="decoded-tx-details" - > - <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" - > - <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" - data-testid="tx-row-title" - style="word-break: break-word;" - > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" - > - safeTxGas: - </p> - </div> - <div - class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" - data-testid="tx-data-row" - > - <div - class="MuiBox-root css-rurw5h" - > - 0 - </div> - </div> - </div> - <div - class="MuiBox-root css-164r41r" - > - <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" - > - <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" - data-testid="tx-row-title" - style="word-break: break-word;" - > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" - > - Raw data: - </p> - </div> - <div - class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" - data-testid="tx-data-row" - > - <div - class="encodedData MuiBox-root css-0" - data-testid="tx-hexData" - > - <span - aria-label="Copy to clipboard" - class="" - data-mui-internal-clone-element="true" - style="cursor: pointer;" - > - <button - aria-label="Copy to clipboard" - class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" - tabindex="0" - type="button" - > - <mock-icon - aria-hidden="" - class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" - data-testid="copy-btn-icon" - focusable="false" - /> - </button> - </span> - 0x - - </div> - </div> - </div> - </div> - </div> - </div> - </div> - </div> - </div> - </div> - </div> - </div> + Transaction checks + </h5> </div> </div> <div @@ -597,15 +932,20 @@ exports[`SignOrExecute should display an error screen 1`] = ` class="" data-mui-internal-clone-element="true" > - <button - class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary Mui-disabled MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary css-1hp2ejb-MuiButtonBase-root-MuiButton-root" - data-testid="sign-btn" - disabled="" - tabindex="-1" - type="submit" + <span + class="" + data-mui-internal-clone-element="true" > - Sign - </button> + <button + class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary Mui-disabled MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary css-1hp2ejb-MuiButtonBase-root-MuiButton-root" + data-testid="sign-btn" + disabled="" + tabindex="-1" + type="submit" + > + Sign + </button> + </span> </span> </div> </div> diff --git a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/hooks.test.ts b/apps/web/src/components/tx/SignOrExecuteForm/__tests__/hooks.test.ts index c0e3f5cdff..07e000b1f3 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/__tests__/hooks.test.ts +++ b/apps/web/src/components/tx/SignOrExecuteForm/__tests__/hooks.test.ts @@ -19,7 +19,7 @@ import { useValidateNonce, } from '../hooks' import * as recommendedNonce from '@/services/tx/tx-sender/recommendedNonce' -import { defaultSafeInfo } from '@/store/safeInfoSlice' +import { defaultSafeInfo } from '@safe-global/store/slices/SafeInfo/utils' import { chainBuilder } from '@/tests/builders/chains' import * as useChains from '@/hooks/useChains' import { MockEip1193Provider } from '@/tests/mocks/providers' diff --git a/apps/web/src/components/tx/SignOrExecuteForm/hooks.ts b/apps/web/src/components/tx/SignOrExecuteForm/hooks.ts index 90f35f9056..9e27aaa180 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/hooks.ts +++ b/apps/web/src/components/tx/SignOrExecuteForm/hooks.ts @@ -1,6 +1,6 @@ import { assertTx, assertOnboard, assertChainInfo, assertProvider } from '@/utils/helpers' import { useMemo } from 'react' -import { type TransactionOptions, type SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { type TransactionOptions, type SafeTransaction } from '@safe-global/types-kit' import { sameString } from '@safe-global/protocol-kit/dist/src/utils' import useSafeInfo from '@/hooks/useSafeInfo' import useWallet, { useSigner } from '@/hooks/wallets/useWallet' @@ -16,7 +16,7 @@ import { } from '@/services/tx/tx-sender' import { useHasPendingTxs } from '@/hooks/usePendingTxs' import { getSafeTxGas, getNonces } from '@/services/tx/tx-sender/recommendedNonce' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { useUpdateBatch } from '@/hooks/useDraftBatch' import { type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { useCurrentChain } from '@/hooks/useChains' diff --git a/apps/web/src/components/tx/SignOrExecuteForm/index.tsx b/apps/web/src/components/tx/SignOrExecuteForm/index.tsx index 5c56b773b1..2752723f71 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/index.tsx +++ b/apps/web/src/components/tx/SignOrExecuteForm/index.tsx @@ -18,7 +18,6 @@ type SignOrExecuteExtendedProps = SignOrExecuteProps & { disableSubmit?: boolean origin?: string isCreation?: boolean - showMethodCall?: boolean } const SignOrExecute = (props: SignOrExecuteExtendedProps) => { diff --git a/apps/web/src/components/tx/SignOrExecuteForm/styles.module.css b/apps/web/src/components/tx/SignOrExecuteForm/styles.module.css index 96f1f2223e..562848ee8f 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/styles.module.css +++ b/apps/web/src/components/tx/SignOrExecuteForm/styles.module.css @@ -38,40 +38,3 @@ [data-theme='dark'] .execute svg { color: var(--color-success-dark); } - -.params { - margin-bottom: var(--space-2); -} - -.noBottomBorderRadius :global(.MuiPaper-root) { - border-bottom-left-radius: 0 !important; - border-bottom-right-radius: 0 !important; -} - -.noTopBorder > div { - margin-top: -1px; - border-top-left-radius: 0 !important; - border-top-right-radius: 0 !important; -} - -.mobileTxCheckMessages, -.mobileTxCheckMessages:empty { - display: none; -} - -@media (max-width: 899.95px) { - .mobileTxCheckMessages { - display: block; - } -} - -.zodiac { - color: var(--color-text-primary); -} - -.roleChip { - background-color: var(--color-background-main); - font-size: 12px; - border-radius: 4px; - padding: 2px 8px; -} diff --git a/apps/web/src/components/tx/SignOrExecuteForm/tracking.ts b/apps/web/src/components/tx/SignOrExecuteForm/tracking.ts index 50c6a6144c..8a4ffb1bf4 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/tracking.ts +++ b/apps/web/src/components/tx/SignOrExecuteForm/tracking.ts @@ -1,4 +1,5 @@ -import { trackEvent } from '@/services/analytics' +import { useCallback, useRef } from 'react' +import { MODALS_EVENTS, trackEvent } from '@/services/analytics' import { TX_EVENTS } from '@/services/analytics/events/transactions' import { getTransactionTrackingType } from '@/services/analytics/tx-tracking' import { isNestedConfirmationTxInfo } from '@/utils/transaction-guards' @@ -48,6 +49,7 @@ export function trackTxEvents( isProposerCreation: boolean, isParentSigner: boolean, origin?: string, + isMassPayout: boolean = false, ) { const isNestedConfirmation = !!details && isNestedConfirmationTxInfo(details.txInfo) @@ -65,7 +67,7 @@ export function trackTxEvents( return confirmationEvent })() - const txType = getTransactionTrackingType(details, origin) + const txType = getTransactionTrackingType(details, origin, isMassPayout) trackEvent({ ...event, label: txType }) // Immediate execution on creation @@ -73,3 +75,16 @@ export function trackTxEvents( trackEvent({ ...executionEvent, label: txType }) } } + +export function useTrackTimeSpent() { + const startTime = useRef(Date.now()) + + return useCallback(() => { + const secondsElapsed = Math.round((Date.now() - startTime.current) / 1000) + + trackEvent({ + ...MODALS_EVENTS.RECEIPT_TIME_SPENT, + label: secondsElapsed, + }) + }, [startTime]) +} diff --git a/apps/web/src/components/tx/SponsoredBy/styles.module.css b/apps/web/src/components/tx/SponsoredBy/styles.module.css index 2df83cc5b5..e6e43fc7d4 100644 --- a/apps/web/src/components/tx/SponsoredBy/styles.module.css +++ b/apps/web/src/components/tx/SponsoredBy/styles.module.css @@ -17,4 +17,15 @@ .logo { width: 16px; height: 16px; + margin-left: 2px; +} + +[data-theme='dark'] .logo { + filter: brightness(0) invert(1); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) .logo { + filter: brightness(0) invert(1); + } } diff --git a/apps/web/src/components/tx/TransactionWarnings/index.tsx b/apps/web/src/components/tx/TransactionWarnings/index.tsx new file mode 100644 index 0000000000..763e8aef30 --- /dev/null +++ b/apps/web/src/components/tx/TransactionWarnings/index.tsx @@ -0,0 +1,10 @@ +import { + UntrustedFallbackHandlerTxAlert, + useSetsUntrustedFallbackHandler, +} from '../confirmation-views/SettingsChange/UntrustedFallbackHandlerTxAlert' +import { type TransactionData } from '@safe-global/safe-gateway-typescript-sdk' + +export const TransactionWarnings = ({ txData }: { txData?: TransactionData }) => { + const isUntrustedFallbackHandler = useSetsUntrustedFallbackHandler(txData) + return <>{isUntrustedFallbackHandler && <UntrustedFallbackHandlerTxAlert />}</> +} diff --git a/apps/web/src/components/tx/confirmation-views/BatchTransactions/__snapshots__/BatchTransactions.test.tsx.snap b/apps/web/src/components/tx/confirmation-views/BatchTransactions/__snapshots__/BatchTransactions.test.tsx.snap index 0891cdba66..b117a0f304 100644 --- a/apps/web/src/components/tx/confirmation-views/BatchTransactions/__snapshots__/BatchTransactions.test.tsx.snap +++ b/apps/web/src/components/tx/confirmation-views/BatchTransactions/__snapshots__/BatchTransactions.test.tsx.snap @@ -55,7 +55,9 @@ exports[`BatchTransactions should render a list of batch transactions 1`] = ` class="container" data-mui-internal-clone-element="true" > - <b> + <b + class="tokenText" + > - 1000000000000 @@ -107,25 +109,23 @@ exports[`BatchTransactions should render a list of batch transactions 1`] = ` <div class="MuiBox-root css-8v90jo" > - <p - class="MuiTypography-root MuiTypography-body1 css-v6lhhw-MuiTypography-root" - > - Send - - <b> - <span - aria-label="1000000000000" - class="container" - data-mui-internal-clone-element="true" + Send + + <b> + <span + aria-label="1000000000000" + class="container" + data-mui-internal-clone-element="true" + > + <b + class="tokenText" > - <b> - 1000000000000 - - </b> - </span> - </b> - to: - </p> + 1000000000000 + + </b> + </span> + </b> + to </div> <div class="MuiBox-root css-7pf6at" @@ -146,7 +146,7 @@ exports[`BatchTransactions should render a list of batch transactions 1`] = ` class="MuiBox-root css-1lchl8k" > <div - class="MuiBox-root css-171onha" + class="ethHashInfo-name MuiBox-root css-171onha" title="GnosisSafeProxy" > <div @@ -218,24 +218,29 @@ exports[`BatchTransactions should render a list of batch transactions 1`] = ` </div> </div> <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + class="MuiGrid-root MuiGrid-container css-1yff1ei-MuiGrid-root" + data-testid="" > <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + class="MuiGrid-root MuiGrid-item css-ezmk0c-MuiGrid-root" data-testid="tx-row-title" style="word-break: break-word;" > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + <span + class="MuiTypography-root MuiTypography-body2 css-1ew0eu5-MuiTypography-root" > Created: - </p> + </span> </div> <div class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" data-testid="tx-data-row" > - 9/20/2024, 10:20:15 AM + <div + class="MuiTypography-root MuiTypography-body1 css-v6lhhw-MuiTypography-root" + > + 9/20/2024, 10:20:15 AM + </div> </div> </div> </div> diff --git a/apps/web/src/components/tx/confirmation-views/BridgeTransaction/BridgeRecipientWarnings.tsx b/apps/web/src/components/tx/confirmation-views/BridgeTransaction/BridgeRecipientWarnings.tsx new file mode 100644 index 0000000000..1680bf8685 --- /dev/null +++ b/apps/web/src/components/tx/confirmation-views/BridgeTransaction/BridgeRecipientWarnings.tsx @@ -0,0 +1,86 @@ +import { useSafeCreationData } from '@/features/multichain/hooks/useSafeCreationData' +import useChains from '@/hooks/useChains' +import useSafeInfo from '@/hooks/useSafeInfo' +import { Alert, AlertTitle, Typography } from '@mui/material' +import { useSafesGetSafeV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import { haveSameSetup } from '@safe-global/utils/utils/safe-setup-comparison' +import { useMemo } from 'react' +import { type BridgeAndSwapTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import useAddressBook from '@/hooks/useAddressBook' +import useOwnedSafes from '@/hooks/useOwnedSafes' +import { type BridgeWarning } from '@safe-global/utils/components/confirmation-views/BridgeTransaction/BridgeWarnings' +import { + useBridgeWarningLogic, + type BridgeWarningData, +} from '@safe-global/utils/components/confirmation-views/BridgeTransaction/useBridgeWarningLogic' + +const WarningAlert = ({ warning }: { warning: BridgeWarning }) => ( + <Alert severity={warning.severity} sx={{ marginTop: '16px' }}> + <AlertTitle> + <Typography fontWeight="700">{warning.title}</Typography> + </AlertTitle> + {warning.description} + </Alert> +) + +export const BridgeRecipientWarnings = ({ txInfo }: { txInfo: BridgeAndSwapTransactionInfo }) => { + const { safe } = useSafeInfo() + const { configs } = useChains() + const [_creationData, creationError] = useSafeCreationData(safe.address.value, configs) + const isSameAddress = sameAddress(txInfo.recipient.value, safe.address.value) + const destinationAddressBook = useAddressBook(txInfo.toChain) + const destinationOwnedSafes = useOwnedSafes(txInfo.toChain) + + const isMultiChainSafe = creationError === undefined + + const { data: otherSafe, error: otherSafeError } = useSafesGetSafeV1Query( + { chainId: txInfo.toChain, safeAddress: safe.address.value }, + { skip: !isSameAddress }, + ) + + const otherSafeExists = otherSafe !== undefined + + const hasSameSetup = useMemo(() => { + if (!otherSafeExists || otherSafeError) return false + return haveSameSetup(otherSafe, safe) + }, [otherSafeExists, otherSafeError, otherSafe, safe]) + + const isDestinationChainSupported = configs.some((chain) => chain.chainId === txInfo.toChain) + const isRecipientInAddressBook = destinationAddressBook[txInfo.recipient.value] !== undefined + const isRecipientOwnedSafe = destinationOwnedSafes[txInfo.toChain]?.some((ownedSafeAddress) => + sameAddress(ownedSafeAddress, txInfo.recipient.value), + ) + + // Prepare data for shared warning logic + const warningData: BridgeWarningData = useMemo( + () => ({ + isSameAddress, + isDestinationChainSupported, + isMultiChainSafe, + otherSafeExists, + hasSameSetup, + isRecipientInAddressBook, + isRecipientOwnedSafe, + }), + [ + isSameAddress, + isDestinationChainSupported, + isMultiChainSafe, + otherSafeExists, + hasSameSetup, + isRecipientInAddressBook, + isRecipientOwnedSafe, + ], + ) + + // Use shared warning logic + const warning = useBridgeWarningLogic(warningData) + + // Render warning if one exists + if (warning) { + return <WarningAlert warning={warning} /> + } + + return null +} diff --git a/apps/web/src/components/tx/confirmation-views/BridgeTransaction/__tests__/BridgeRecipientWarnings.test.tsx b/apps/web/src/components/tx/confirmation-views/BridgeTransaction/__tests__/BridgeRecipientWarnings.test.tsx new file mode 100644 index 0000000000..a8436834f5 --- /dev/null +++ b/apps/web/src/components/tx/confirmation-views/BridgeTransaction/__tests__/BridgeRecipientWarnings.test.tsx @@ -0,0 +1,243 @@ +import { render, waitFor } from '@/tests/test-utils' +import { BridgeRecipientWarnings } from '../BridgeRecipientWarnings' +import { BridgeWarnings } from '@safe-global/utils/components/confirmation-views/BridgeTransaction/BridgeWarnings' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import * as useSafeInfoHook from '@/hooks/useSafeInfo' +import * as useChainsHook from '@/hooks/useChains' +import * as useSafeCreationDataHook from '@/features/multichain/hooks/useSafeCreationData' +import { type BridgeAndSwapTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { chainBuilder } from '@/tests/builders/chains' +import { type ReplayedSafeProps } from '@safe-global/utils/features/counterfactual/store/types' +import { type AsyncResult } from '@safe-global/utils/hooks/useAsync' +import * as useSafesGetSafeV1QueryHook from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import * as useAddressBookHook from '@/hooks/useAddressBook' +import * as useOwnedSafesHook from '@/hooks/useOwnedSafes' +import { faker } from '@faker-js/faker' + +const mockSafeInfo = extendedSafeInfoBuilder().build() +const mockSourceChain = chainBuilder().with({ chainId: '1', chainName: 'Ethereum' }).build() +const mockDestinationChain = chainBuilder().with({ chainId: '100', chainName: 'Gnosis Chain' }).build() +const mockUnsupportedChain = chainBuilder().with({ chainId: '999', chainName: 'Unsupported Chain' }).build() + +const mockTxInfo: BridgeAndSwapTransactionInfo = { + type: 'SwapAndBridge', + humanDescription: null, + fromToken: { + address: '0x0000000000000000000000000000000000000000', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + logoUri: '', + trusted: true, + }, + recipient: { value: mockSafeInfo.address.value }, + explorerUrl: null, + status: 'PENDING', + substatus: 'WAIT_SOURCE_CONFIRMATIONS', + fees: null, + fromAmount: '1000000000000000000', + toChain: mockDestinationChain.chainId, + toToken: null, + toAmount: null, +} + +describe('BridgeRecipientWarnings', () => { + beforeEach(() => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation(() => ({ + safe: mockSafeInfo, + safeAddress: mockSafeInfo.address.value, + safeError: undefined, + safeLoading: false, + safeLoaded: true, + })) + + jest.spyOn(useChainsHook, 'default').mockImplementation(() => ({ + configs: [mockSourceChain, mockDestinationChain], + error: undefined, + loading: false, + })) + + jest + .spyOn(useSafeCreationDataHook, 'useSafeCreationData') + .mockImplementation(() => [undefined, undefined, false] as AsyncResult<ReplayedSafeProps>) + + jest.spyOn(useSafesGetSafeV1QueryHook, 'useSafesGetSafeV1Query').mockImplementation(() => ({ + data: undefined, + error: undefined, + isLoading: false, + isError: false, + isSuccess: false, + isFetching: false, + refetch: jest.fn(), + })) + + jest.spyOn(useAddressBookHook, 'default').mockImplementation(() => ({})) + + jest.spyOn(useOwnedSafesHook, 'default').mockImplementation(() => ({ + [mockDestinationChain.chainId]: [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + })) + }) + + it('should not show warning when bridging to same address with same setup', async () => { + jest.spyOn(useSafesGetSafeV1QueryHook, 'useSafesGetSafeV1Query').mockImplementation(() => ({ + data: { + ...mockSafeInfo, + owners: mockSafeInfo.owners, + threshold: mockSafeInfo.threshold, + }, + error: undefined, + isLoading: false, + isError: false, + isSuccess: true, + isFetching: false, + refetch: jest.fn(), + })) + + const { container } = render(<BridgeRecipientWarnings txInfo={mockTxInfo} />) + await waitFor(() => { + expect(container).toBeEmptyDOMElement() + }) + }) + + it('should show warning when bridging to same address with different setup', async () => { + jest + .spyOn(useSafeCreationDataHook, 'useSafeCreationData') + .mockImplementation(() => [undefined, undefined, false] as AsyncResult<ReplayedSafeProps>) + + jest.spyOn(useSafesGetSafeV1QueryHook, 'useSafesGetSafeV1Query').mockImplementation(() => ({ + data: { + ...mockSafeInfo, + owners: [{ value: faker.finance.ethereumAddress() }], + threshold: 1, + }, + error: undefined, + isLoading: false, + isError: false, + isSuccess: true, + isFetching: false, + refetch: jest.fn(), + })) + + const { getByText } = render(<BridgeRecipientWarnings txInfo={mockTxInfo} />) + await waitFor(() => { + expect(getByText('Different Safe setup on target chain')).toBeInTheDocument() + expect(getByText(BridgeWarnings.DIFFERENT_SETUP.description)).toBeInTheDocument() + }) + }) + + it('should show error when Safe does not support adding networks', async () => { + jest + .spyOn(useSafeCreationDataHook, 'useSafeCreationData') + .mockImplementation(() => [undefined, new Error('Not supported'), false] as AsyncResult<ReplayedSafeProps>) + + const { getByText } = render(<BridgeRecipientWarnings txInfo={mockTxInfo} />) + await waitFor(() => { + expect(getByText('Incompatible Safe version')).toBeInTheDocument() + expect(getByText(BridgeWarnings.NO_MULTICHAIN_SUPPORT.description)).toBeInTheDocument() + }) + }) + + it('should show warning when Safe is not deployed on destination chain', async () => { + jest.spyOn(useSafeCreationDataHook, 'useSafeCreationData').mockImplementation( + () => + [ + { + factoryAddress: faker.finance.ethereumAddress(), + safeVersion: '1.3.0', + masterCopy: faker.finance.ethereumAddress(), + safeAccountConfig: { + owners: [mockSafeInfo.owners[0].value], + threshold: mockSafeInfo.threshold, + fallbackHandler: faker.finance.ethereumAddress(), + to: faker.finance.ethereumAddress(), + data: faker.finance.ethereumAddress(), + paymentReceiver: faker.finance.ethereumAddress(), + }, + saltNonce: '0', + }, + undefined, + false, + ] as AsyncResult<ReplayedSafeProps>, + ) + + jest.spyOn(useSafesGetSafeV1QueryHook, 'useSafesGetSafeV1Query').mockImplementation(() => ({ + data: undefined, + error: undefined, + isLoading: false, + isError: false, + isSuccess: true, + isFetching: false, + refetch: jest.fn(), + })) + + const { getByText } = render(<BridgeRecipientWarnings txInfo={mockTxInfo} />) + await waitFor(() => { + expect(getByText('No ownership on target chain')).toBeInTheDocument() + expect(getByText(BridgeWarnings.SAFE_NOT_DEPLOYED.description)).toBeInTheDocument() + }) + }) + + it('should show warning when bridging to different address not in address book', async () => { + const differentAddressTxInfo = { + ...mockTxInfo, + recipient: { value: '0x0000000000000000000000000000000000000001' }, + } + + const { getByText } = render(<BridgeRecipientWarnings txInfo={differentAddressTxInfo} />) + await waitFor(() => { + expect(getByText('Unknown address')).toBeInTheDocument() + expect(getByText(BridgeWarnings.DIFFERENT_ADDRESS.description)).toBeInTheDocument() + }) + }) + + it('should not show warning when bridging to different address in address book', async () => { + const differentAddressTxInfo = { + ...mockTxInfo, + recipient: { value: '0x0000000000000000000000000000000000000001' }, + } + + jest.spyOn(useAddressBookHook, 'default').mockImplementation((chainId) => { + if (chainId === mockDestinationChain.chainId) { + return { + [differentAddressTxInfo.recipient.value]: 'Test Address', + } + } + return {} + }) + + const { container } = render(<BridgeRecipientWarnings txInfo={differentAddressTxInfo} />) + await waitFor(() => { + expect(container).toBeEmptyDOMElement() + }) + }) + + it('should not show warning when bridging to different address that is owned on target chain', async () => { + const ownedAddress = faker.finance.ethereumAddress() + const differentAddressTxInfo = { + ...mockTxInfo, + recipient: { value: ownedAddress }, + } + + jest.spyOn(useOwnedSafesHook, 'default').mockImplementation(() => ({ + [mockDestinationChain.chainId]: [faker.finance.ethereumAddress(), ownedAddress, faker.finance.ethereumAddress()], + })) + + const { container } = render(<BridgeRecipientWarnings txInfo={differentAddressTxInfo} />) + await waitFor(() => { + expect(container).toBeEmptyDOMElement() + }) + }) + + it('should show error when bridging to unsupported chain', async () => { + const unsupportedChainTxInfo = { + ...mockTxInfo, + toChain: mockUnsupportedChain.chainId, + } + + const { getByText } = render(<BridgeRecipientWarnings txInfo={unsupportedChainTxInfo} />) + await waitFor(() => { + expect(getByText('The target network is not supported')).toBeInTheDocument() + expect(getByText(BridgeWarnings.UNKNOWN_CHAIN.description)).toBeInTheDocument() + }) + }) +}) diff --git a/apps/web/src/components/tx/confirmation-views/BridgeTransaction/index.tsx b/apps/web/src/components/tx/confirmation-views/BridgeTransaction/index.tsx new file mode 100644 index 0000000000..06684f05e3 --- /dev/null +++ b/apps/web/src/components/tx/confirmation-views/BridgeTransaction/index.tsx @@ -0,0 +1,188 @@ +import ChainIndicator from '@/components/common/ChainIndicator' +import NamedAddressInfo from '@/components/common/NamedAddressInfo' +import { DataRow } from '@/components/common/Table/DataRow' +import { DataTable } from '@/components/common/Table/DataTable' +import TokenAmount from '@/components/common/TokenAmount' +import useChainId from '@/hooks/useChainId' +import useChains from '@/hooks/useChains' +import { Stack, Typography } from '@mui/material' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type BridgeAndSwapTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { formatAmount } from '@safe-global/utils/utils/formatNumber' +import { formatUnits } from 'ethers' +import { BridgeRecipientWarnings } from './BridgeRecipientWarnings' +import ExternalLink from '@/components/common/ExternalLink' +import css from './styles.module.css' + +interface BridgeTransactionProps { + txInfo: BridgeAndSwapTransactionInfo + showWarnings?: boolean +} + +const BridgeTxRecipientRow = ({ txInfo }: BridgeTransactionProps) => { + return ( + <DataRow datatestid="recipient" key="recipient" title="Recipient"> + <Stack> + <NamedAddressInfo + address={txInfo.recipient.value} + showCopyButton + hasExplorer + showAvatar={false} + onlyName + showPrefix + chainId={txInfo.toChain} + /> + </Stack> + </DataRow> + ) +} + +function pendingBridgeTransactionRows(txInfo: BridgeAndSwapTransactionInfo & { status: 'PENDING' }) { + const actualFromAmount = + BigInt(txInfo.fromAmount) + BigInt(txInfo.fees?.integratorFee ?? 0n) + BigInt(txInfo.fees?.lifiFee ?? 0n) + + return [ + <DataRow datatestid="amount" key="amount" title="Amount"> + <Typography display="flex" alignItems="center" flexDirection="row" gap={1}> + Sending{' '} + <TokenAmount + value={actualFromAmount.toString()} + decimals={txInfo.fromToken.decimals} + logoUri={txInfo.fromToken.logoUri ?? ''} + tokenSymbol={txInfo.fromToken.symbol} + />{' '} + to <ChainIndicator chainId={txInfo.toChain} inline /> + </Typography> + </DataRow>, + ] +} + +function failedBridgeTransactionRows(txInfo: BridgeAndSwapTransactionInfo & { status: 'FAILED' }) { + const actualFromAmount = + BigInt(txInfo.fromAmount) + BigInt(txInfo.fees?.integratorFee ?? 0n) + BigInt(txInfo.fees?.lifiFee ?? 0n) + return [ + <DataRow datatestid="amount" key="amount" title="Amount"> + <Typography display="flex" alignItems="center" flexDirection="row" gap={1}> + Failed to send{' '} + <TokenAmount + value={actualFromAmount.toString()} + decimals={txInfo.fromToken.decimals} + logoUri={txInfo.fromToken.logoUri ?? ''} + tokenSymbol={txInfo.fromToken.symbol} + />{' '} + to <ChainIndicator chainId={txInfo.toChain} inline /> + </Typography> + </DataRow>, + <DataRow datatestid="substatus" key="substatus" title="Substatus"> + {txInfo.substatus} + </DataRow>, + ] +} + +function successfulBridgeTransactionRows( + txInfo: BridgeAndSwapTransactionInfo & { status: 'DONE' }, + chainId: string, + chainConfigs: ChainInfo[], +) { + const actualFromAmount = + BigInt(txInfo.fromAmount) + BigInt(txInfo.fees?.integratorFee ?? 0n) + BigInt(txInfo.fees?.lifiFee ?? 0n) + const fromAmountDecimals = formatUnits(actualFromAmount, txInfo.fromToken.decimals) + const toAmountDecimals = + txInfo.toAmount && txInfo.toToken ? formatUnits(txInfo.toAmount, txInfo.toToken.decimals) : undefined + const exchangeRate = toAmountDecimals ? Number(toAmountDecimals) / Number(fromAmountDecimals) : undefined + + const fromChainConfig = chainConfigs.find((config) => config.chainId === chainId) + const toChainConfig = chainConfigs.find((config) => config.chainId === txInfo.toChain) + + const rows = [] + + rows.push( + <DataRow datatestid="amount" key="amount" title="Amount"> + <Stack spacing={0.5}> + <Typography display="flex" alignItems="center" flexDirection="row" gap={1}> + Sell{' '} + <TokenAmount + value={actualFromAmount.toString()} + decimals={txInfo.fromToken.decimals} + logoUri={txInfo.fromToken.logoUri ?? ''} + tokenSymbol={txInfo.fromToken.symbol} + chainId={chainId} + />{' '} + on {fromChainConfig?.chainName ?? 'Unknown Chain'} + </Typography> + <Typography display="flex" alignItems="center" flexDirection="row" gap={1}> + {txInfo.toToken && txInfo.toAmount ? ( + <> + For{' '} + <TokenAmount + value={txInfo.toAmount} + decimals={txInfo.toToken.decimals} + logoUri={txInfo.toToken.logoUri ?? ''} + tokenSymbol={txInfo.toToken.symbol} + chainId={txInfo.toChain} + />{' '} + on {toChainConfig?.chainName ?? 'Unknown Chain'} + </> + ) : ( + <>Could not find buy token information.</> + )} + </Typography> + </Stack> + </DataRow>, + ) + if (exchangeRate) { + rows.push( + <DataRow datatestid="exchange-rate" key="Exchange Rate" title="Exchange Rate"> + 1 {txInfo.fromToken.symbol} = {formatAmount(exchangeRate)} {txInfo.toToken!.symbol} + </DataRow>, + ) + } + + return rows +} + +function BridgeTransaction({ txInfo, showWarnings = false }: BridgeTransactionProps) { + const chainId = useChainId() + const { configs } = useChains() + + const totalFee = formatUnits( + BigInt(txInfo.fees?.integratorFee ?? 0n) + BigInt(txInfo.fees?.lifiFee ?? 0n), + txInfo.fromToken.decimals, + ) + + let rows = [] + if (txInfo.status === 'PENDING' || txInfo.status === 'AWAITING_EXECUTION') { + rows.push(...pendingBridgeTransactionRows(txInfo as BridgeAndSwapTransactionInfo & { status: 'PENDING' })) + } else if (txInfo.status === 'FAILED') { + rows.push(...failedBridgeTransactionRows(txInfo as BridgeAndSwapTransactionInfo & { status: 'FAILED' })) + } else if (txInfo.status === 'DONE') { + rows.push( + ...successfulBridgeTransactionRows(txInfo as BridgeAndSwapTransactionInfo & { status: 'DONE' }, chainId, configs), + ) + } + rows.push( + <BridgeTxRecipientRow txInfo={txInfo} />, + <DataRow datatestid="total-fee" key="fees" title="Fees"> + {formatAmount(totalFee)} {txInfo.fromToken.symbol} + </DataRow>, + ) + + if (txInfo.explorerUrl) { + rows.push( + <DataRow datatestid="lifi-explorer-url" key="lifi-explorer-url" title="Lifi Explorer"> + <ExternalLink className={css.externalLink} href={txInfo.explorerUrl}> + View in LiFi Explorer + </ExternalLink> + </DataRow>, + ) + } + + return ( + <Stack> + <DataTable rows={rows} /> + {showWarnings && txInfo.status === 'AWAITING_EXECUTION' && <BridgeRecipientWarnings txInfo={txInfo} />} + </Stack> + ) +} + +export default BridgeTransaction diff --git a/apps/web/src/components/tx/confirmation-views/BridgeTransaction/styles.module.css b/apps/web/src/components/tx/confirmation-views/BridgeTransaction/styles.module.css new file mode 100644 index 0000000000..43c2bc925c --- /dev/null +++ b/apps/web/src/components/tx/confirmation-views/BridgeTransaction/styles.module.css @@ -0,0 +1,5 @@ +.externalLink { + text-decoration: none; + font-weight: 400 !important; + color: var(--color-text-primary); +} diff --git a/apps/web/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.stories.tsx b/apps/web/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.stories.tsx index 40b6a280d7..9340a947f7 100644 --- a/apps/web/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.stories.tsx +++ b/apps/web/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.stories.tsx @@ -2,23 +2,26 @@ import type { Meta, StoryObj } from '@storybook/react' import { Paper } from '@mui/material' import { StoreDecorator } from '@/stories/storeDecorator' import ChangeThreshold from './index' -import { ChangeThresholdReviewContext } from '@/components/tx-flow/flows/ChangeThreshold/context' +import { SettingsInfoType, type TransactionInfo, TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' const meta = { component: ChangeThreshold, - parameters: { - layout: 'centered', - newThreshold: 1, + args: { + txInfo: { + type: TransactionInfoType.SETTINGS_CHANGE, + settingsInfo: { + type: SettingsInfoType.CHANGE_THRESHOLD, + threshold: 1, + }, + } as TransactionInfo, }, decorators: [ - (Story, { parameters }) => { + (Story) => { return ( <StoreDecorator initialState={{}}> - <ChangeThresholdReviewContext.Provider value={{ newThreshold: parameters.newThreshold }}> - <Paper sx={{ padding: 2 }}> - <Story /> - </Paper> - </ChangeThresholdReviewContext.Provider> + <Paper sx={{ padding: 2 }}> + <Story /> + </Paper> </StoreDecorator> ) }, diff --git a/apps/web/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.test.tsx b/apps/web/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.test.tsx index e4f7a4c6d4..69b8445ddb 100644 --- a/apps/web/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.test.tsx +++ b/apps/web/src/components/tx/confirmation-views/ChangeThreshold/ChangeThreshold.test.tsx @@ -1,8 +1,8 @@ import { render } from '@/tests/test-utils' import ChangeThreshold from '.' -import { ChangeThresholdReviewContext } from '@/components/tx-flow/flows/ChangeThreshold/context' import * as useSafeInfo from '@/hooks/useSafeInfo' import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import { SettingsInfoType, type TransactionInfo, TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' const extendedSafeInfo = extendedSafeInfoBuilder().build() @@ -20,9 +20,14 @@ jest.spyOn(useSafeInfo, 'default').mockImplementation(() => ({ describe('ChangeThreshold', () => { it('should display the ChangeThreshold component with the new threshold range', () => { const { container, getByLabelText } = render( - <ChangeThresholdReviewContext.Provider value={{ newThreshold: 3 }}> - <ChangeThreshold /> - </ChangeThresholdReviewContext.Provider>, + <ChangeThreshold + txInfo={ + { + type: TransactionInfoType.SETTINGS_CHANGE, + settingsInfo: { type: SettingsInfoType.CHANGE_THRESHOLD, threshold: 3 }, + } as TransactionInfo + } + />, ) expect(container).toMatchSnapshot() diff --git a/apps/web/src/components/tx/confirmation-views/ChangeThreshold/index.tsx b/apps/web/src/components/tx/confirmation-views/ChangeThreshold/index.tsx index f81cd203f0..eee44d0ea3 100644 --- a/apps/web/src/components/tx/confirmation-views/ChangeThreshold/index.tsx +++ b/apps/web/src/components/tx/confirmation-views/ChangeThreshold/index.tsx @@ -1,13 +1,12 @@ import { Box, Divider, Typography } from '@mui/material' -import React, { useContext } from 'react' +import React from 'react' import commonCss from '@/components/tx-flow/common/styles.module.css' import useSafeInfo from '@/hooks/useSafeInfo' -import { ChangeThresholdReviewContext } from '@/components/tx-flow/flows/ChangeThreshold/context' import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' import { type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { isChangeThresholdView } from '../utils' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' interface ChangeThresholdProps { txInfo?: TransactionDetails['txInfo'] @@ -15,7 +14,6 @@ interface ChangeThresholdProps { function ChangeThreshold({ txInfo }: ChangeThresholdProps) { const { safe } = useSafeInfo() - const { newThreshold } = useContext(ChangeThresholdReviewContext) const threshold = txInfo && isChangeThresholdView(txInfo) && txInfo.settingsInfo?.threshold return ( @@ -28,7 +26,7 @@ function ChangeThreshold({ txInfo }: ChangeThresholdProps) { </Typography> <Typography aria-label="threshold"> - <b>{newThreshold || threshold}</b> out of{' '} + <b>{threshold}</b> out of{' '} <b> {safe.owners.length} signer{maybePlural(safe.owners)} </b> diff --git a/apps/web/src/components/tx/confirmation-views/ConfirmationView.test.tsx b/apps/web/src/components/tx/confirmation-views/ConfirmationView.test.tsx index dfc3fb5098..82756d32af 100644 --- a/apps/web/src/components/tx/confirmation-views/ConfirmationView.test.tsx +++ b/apps/web/src/components/tx/confirmation-views/ConfirmationView.test.tsx @@ -97,6 +97,10 @@ const txDetails = createMockTransactionDetails({ value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', name: '', }, + proposedByDelegate: { + value: '0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b', + name: '', + }, }, }) const safeTx = safeTxBuilder().build() @@ -109,11 +113,9 @@ const safeTxWithNativeData = { data: '0x', }, } -describe('ConfirmationView', () => { +xdescribe('ConfirmationView', () => { it('should display a confirmation screen for a SETTINGS_CHANGE transaction', () => { - const { container } = render( - <ConfirmationView safeTx={safeTxWithNativeData} txDetails={txDetails} txId={txDetails.txId} isApproval />, - ) + const { container } = render(<ConfirmationView safeTx={safeTxWithNativeData} txDetails={txDetails} isApproval />) expect(container).toMatchSnapshot() }) @@ -122,12 +124,7 @@ describe('ConfirmationView', () => { const CustomTxDetails = { ...txDetails, txInfo: { ...txDetails.txInfo, type: TransactionInfoType.CUSTOM } } const { container } = render( - <ConfirmationView - safeTx={safeTxWithNativeData} - txDetails={CustomTxDetails as TransactionDetails} - txId={txDetails.txId} - isApproval - />, + <ConfirmationView safeTx={safeTxWithNativeData} txDetails={CustomTxDetails as TransactionDetails} isApproval />, ) expect(container).toMatchSnapshot() diff --git a/apps/web/src/components/tx/confirmation-views/LifiSwapTransaction/index.tsx b/apps/web/src/components/tx/confirmation-views/LifiSwapTransaction/index.tsx new file mode 100644 index 0000000000..ec25d3410d --- /dev/null +++ b/apps/web/src/components/tx/confirmation-views/LifiSwapTransaction/index.tsx @@ -0,0 +1,100 @@ +import { DataTable } from '@/components/common/Table/DataTable' +import { Stack, Typography } from '@mui/material' +import { type SwapTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { formatUnits } from 'ethers' +import SwapTokens from '@/features/swap/components/SwapTokens' +import NamedAddressInfo from '@/components/common/NamedAddressInfo' +import { DataRow } from '@/components/common/Table/DataRow' +import { formatAmount } from '@safe-global/utils/utils/formatNumber' +import TokenAmount from '@/components/common/TokenAmount' +import ExternalLink from '@/components/common/ExternalLink' +import css from './styles.module.css' + +const PreviewSwapAmount = ({ txInfo }: { txInfo: SwapTransactionInfo }) => ( + <div key="amount"> + <SwapTokens + first={{ + value: txInfo.fromAmount, + label: 'Sell', + tokenInfo: txInfo.fromToken, + }} + second={{ + value: txInfo.toAmount, + label: 'For at least', + tokenInfo: txInfo.toToken, + }} + /> + </div> +) + +const ListSwapAmount = ({ txInfo }: { txInfo: SwapTransactionInfo }) => ( + <DataRow datatestid="amount" key="amount" title="Amount"> + <Stack spacing={0.5}> + <Typography display="flex" alignItems="center" flexDirection="row" gap={1}> + Sell{' '} + <TokenAmount + value={txInfo.fromAmount} + decimals={txInfo.fromToken.decimals} + logoUri={txInfo.fromToken.logoUri ?? ''} + tokenSymbol={txInfo.fromToken.symbol} + /> + </Typography> + <Typography display="flex" alignItems="center" flexDirection="row" gap={1}> + For{' '} + <TokenAmount + value={txInfo.toAmount} + decimals={txInfo.toToken.decimals} + logoUri={txInfo.toToken.logoUri ?? ''} + tokenSymbol={txInfo.toToken.symbol} + /> + </Typography> + </Stack> + </DataRow> +) + +export const LifiSwapTransaction = ({ txInfo, isPreview }: { txInfo: SwapTransactionInfo; isPreview: boolean }) => { + const totalFee = formatUnits( + BigInt(txInfo.fees?.integratorFee ?? 0n) + BigInt(txInfo.fees?.lifiFee ?? 0n), + txInfo.fromToken.decimals, + ) + + const fromAmountDecimals = formatUnits(txInfo.fromAmount, txInfo.fromToken.decimals) + const toAmountDecimals = formatUnits(txInfo.toAmount, txInfo.toToken.decimals) + const exchangeRate = Number(toAmountDecimals) / Number(fromAmountDecimals) + + const rows = [ + isPreview ? <PreviewSwapAmount txInfo={txInfo} /> : <ListSwapAmount txInfo={txInfo} />, + <DataRow datatestid="price" key="price" title="Price"> + 1 {txInfo.fromToken.symbol} = {formatAmount(exchangeRate)} {txInfo.toToken!.symbol} + </DataRow>, + <DataRow datatestid="receiver" key="Receiver" title="Receiver"> + <NamedAddressInfo + address={txInfo.recipient.value} + name={txInfo.recipient.name} + hasExplorer + showAvatar={false} + onlyName + showCopyButton + /> + </DataRow>, + <DataRow datatestid="total-fee" key="fees" title="Fees"> + {formatAmount(totalFee)} {txInfo.fromToken.symbol} + </DataRow>, + ] + + if (txInfo.lifiExplorerUrl) { + rows.push( + <DataRow datatestid="lifi-explorer-url" key="lifi-explorer-url" title="Lifi Explorer"> + <ExternalLink className={css.externalLink} href={txInfo.lifiExplorerUrl}> + View in LiFi explorer + </ExternalLink> + </DataRow>, + ) + } + + return ( + <Stack> + <DataTable rows={rows} /> + </Stack> + ) +} diff --git a/apps/web/src/components/tx/confirmation-views/LifiSwapTransaction/styles.module.css b/apps/web/src/components/tx/confirmation-views/LifiSwapTransaction/styles.module.css new file mode 100644 index 0000000000..43c2bc925c --- /dev/null +++ b/apps/web/src/components/tx/confirmation-views/LifiSwapTransaction/styles.module.css @@ -0,0 +1,5 @@ +.externalLink { + text-decoration: none; + font-weight: 400 !important; + color: var(--color-text-primary); +} diff --git a/apps/web/src/components/tx/confirmation-views/ManageSigners/get-new-safe-setup.test.ts b/apps/web/src/components/tx/confirmation-views/ManageSigners/get-new-safe-setup.test.ts new file mode 100644 index 0000000000..ce3b4d2d89 --- /dev/null +++ b/apps/web/src/components/tx/confirmation-views/ManageSigners/get-new-safe-setup.test.ts @@ -0,0 +1,225 @@ +import { Safe__factory } from '@safe-global/utils/types/contracts' +import { checksumAddress } from '@safe-global/utils/utils/addresses' +import { TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' + +import { txInfoBuilder } from '@/tests/builders/safeTx' +import { _getTransactionsData, getNewSafeSetup } from './get-new-safe-setup' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import { faker } from '@faker-js/faker' + +const safeInterface = Safe__factory.createInterface() + +describe('getNewSafeSetup', () => { + it('should return new owners/threshold for addOwnerWithThreshold', () => { + const ownerToAdd = faker.finance.ethereumAddress() + const thresholdToSet = faker.number.int({ min: 1, max: 10 }) + const safe = extendedSafeInfoBuilder().build() + const txInfo = txInfoBuilder().build() + const txData = { + hexData: safeInterface.encodeFunctionData('addOwnerWithThreshold', [ownerToAdd, thresholdToSet]), + } as TransactionDetails['txData'] + + const result = getNewSafeSetup({ + txInfo, + txData, + safe, + }) + + expect(result).toEqual({ + newOwners: [...safe.owners.map((owner) => owner.value), checksumAddress(ownerToAdd)], + newThreshold: thresholdToSet, + }) + }) + + it('should return new owners/threshold for removeOwner', () => { + const prevOwner = faker.finance.ethereumAddress() + const ownerToRemove = faker.finance.ethereumAddress() + const thresholdToSet = faker.number.int({ min: 1, max: 10 }) + const safe = extendedSafeInfoBuilder() + .with({ + owners: [ + { + value: prevOwner, + }, + { + // Test checksum comparison by using lowercase + value: ownerToRemove.toLowerCase(), + }, + ], + }) + .build() + const txInfo = txInfoBuilder().build() + const txData = { + hexData: safeInterface.encodeFunctionData('removeOwner', [prevOwner, ownerToRemove, thresholdToSet]), + } as TransactionDetails['txData'] + + const result = getNewSafeSetup({ + txInfo, + txData, + safe, + }) + + expect(result).toEqual({ + newOwners: [prevOwner], + newThreshold: thresholdToSet, + }) + }) + + it('should return new owners/threshold for swapOwner', () => { + const prevOwner = faker.finance.ethereumAddress() + const ownerToRemove = faker.finance.ethereumAddress() + const ownerToAdd = faker.finance.ethereumAddress() + const safe = extendedSafeInfoBuilder() + .with({ + owners: [ + { + value: prevOwner, + }, + { + // Test checksum comparison by using lowercase + value: ownerToRemove.toLowerCase(), + }, + ], + }) + .build() + const txInfo = txInfoBuilder().build() + const txData = { + hexData: safeInterface.encodeFunctionData('swapOwner', [prevOwner, ownerToRemove, ownerToAdd]), + } as TransactionDetails['txData'] + + const result = getNewSafeSetup({ + txInfo, + txData, + safe, + }) + + expect(result).toEqual({ + newOwners: [prevOwner, checksumAddress(ownerToAdd)], + newThreshold: safe.threshold, + }) + }) + + it('should return new owners/threshold for changeThreshold', () => { + const thresholdToSet = faker.number.int({ min: 1, max: 10 }) + const safe = extendedSafeInfoBuilder().build() + const txInfo = txInfoBuilder().build() + const txData = { + hexData: safeInterface.encodeFunctionData('changeThreshold', [thresholdToSet]), + } as TransactionDetails['txData'] + + const result = getNewSafeSetup({ + txInfo, + txData, + safe, + }) + + expect(result).toEqual({ + newOwners: safe.owners.map((owner) => owner.value), + newThreshold: thresholdToSet, + }) + }) + + it('should return new owners/threshold for batched signer management', () => { + const ownerToAdd = faker.finance.ethereumAddress() + const prevOwner = faker.finance.ethereumAddress() + const ownerToRemove = faker.finance.ethereumAddress() + const thresholdToSet = faker.number.int({ min: 1, max: 10 }) + const safe = extendedSafeInfoBuilder() + .with({ + owners: [ + { + value: prevOwner, + }, + { + // Test checksum comparison by using lowercase + value: ownerToRemove.toLowerCase(), + }, + ], + }) + .build() + const txInfo = txInfoBuilder() + .with({ + type: TransactionInfoType.CUSTOM, + methodName: 'multiSend', + actionCount: 3, + }) + .build() + const txData = { + dataDecoded: { + parameters: [ + { + valueDecoded: [ + { + data: safeInterface.encodeFunctionData('addOwnerWithThreshold', [ownerToAdd, thresholdToSet]), + }, + { + data: safeInterface.encodeFunctionData('removeOwner', [prevOwner, ownerToRemove, thresholdToSet]), + }, + { + data: safeInterface.encodeFunctionData('changeThreshold', [thresholdToSet]), + }, + ], + }, + ], + }, + } as TransactionDetails['txData'] + + const result = getNewSafeSetup({ + txInfo, + txData, + safe, + }) + + expect(result).toEqual({ + newOwners: [prevOwner, checksumAddress(ownerToAdd)], + newThreshold: thresholdToSet, + }) + }) +}) + +describe('getTransactionsData', () => { + it('should return the direct data of non-multiSend transactions', () => { + const txInfo = txInfoBuilder().build() + const txData = { + hexData: '0x1', + } as TransactionDetails['txData'] + + const result = _getTransactionsData(txInfo, txData) + + expect(result).toEqual(['0x1']) + }) + + it('should return the decoded data of multiSend transactions', () => { + const txInfo = txInfoBuilder() + .with({ + type: TransactionInfoType.CUSTOM, + methodName: 'multiSend', + actionCount: 3, + }) + .build() + const txData = { + dataDecoded: { + parameters: [ + { + valueDecoded: [ + { + data: '0x1', + }, + { + data: '0x2', + }, + { + data: '0x3', + }, + ], + }, + ], + }, + } as TransactionDetails['txData'] + + const result = _getTransactionsData(txInfo, txData) + + expect(result).toEqual(['0x1', '0x2', '0x3']) + }) +}) diff --git a/apps/web/src/components/tx/confirmation-views/ManageSigners/get-new-safe-setup.ts b/apps/web/src/components/tx/confirmation-views/ManageSigners/get-new-safe-setup.ts new file mode 100644 index 0000000000..1bb246556b --- /dev/null +++ b/apps/web/src/components/tx/confirmation-views/ManageSigners/get-new-safe-setup.ts @@ -0,0 +1,77 @@ +import { checksumAddress, sameAddress } from '@safe-global/utils/utils/addresses' +import { Safe__factory } from '@safe-global/utils/types/contracts' +import type { TransactionInfo, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import type { ExtendedSafeInfo } from '@safe-global/store/slices/SafeInfo/types' + +import { isMultiSendTxInfo } from '@/utils/transaction-guards' + +const safeInterface = Safe__factory.createInterface() + +export function getNewSafeSetup({ + txInfo, + txData, + safe, +}: { + txInfo: TransactionInfo + txData: TransactionDetails['txData'] + safe: ExtendedSafeInfo +}): { + newOwners: Array<string> + newThreshold: number +} { + let newOwners = safe.owners.map((owner) => owner.value) + let newThreshold = safe.threshold + + for (const data of _getTransactionsData(txInfo, txData)) { + const decodedData = safeInterface.parseTransaction({ data }) + + if (!decodedData) { + continue + } + + switch (decodedData.name) { + case 'addOwnerWithThreshold': { + const [ownerToAdd, thresholdToSet] = decodedData.args + newOwners = [...newOwners, checksumAddress(ownerToAdd)] + newThreshold = Number(thresholdToSet) + break + } + case 'removeOwner': { + const [, ownerToRemove, thresholdToSet] = decodedData.args + newOwners = newOwners.filter((owner) => !sameAddress(owner, ownerToRemove)) + newThreshold = Number(thresholdToSet) + break + } + case 'swapOwner': { + const [, ownerToRemove, ownerToAdd] = decodedData.args + newOwners = newOwners.map((owner) => (sameAddress(owner, ownerToRemove) ? checksumAddress(ownerToAdd) : owner)) + break + } + case 'changeThreshold': { + const [thresholdToSet] = decodedData.args + newThreshold = Number(thresholdToSet) + break + } + default: { + break + } + } + } + + return { + newOwners, + newThreshold, + } +} + +export function _getTransactionsData(txInfo: TransactionInfo, txData: TransactionDetails['txData']): Array<string> { + let transactions: Array<string | null | undefined> | undefined + + if (!isMultiSendTxInfo(txInfo)) { + transactions = [txData?.hexData] + } else { + transactions = txData?.dataDecoded?.parameters?.[0].valueDecoded?.map(({ data }) => data) ?? [] + } + + return transactions.filter((x) => x != null) +} diff --git a/apps/web/src/components/tx/confirmation-views/ManageSigners/index.tsx b/apps/web/src/components/tx/confirmation-views/ManageSigners/index.tsx new file mode 100644 index 0000000000..c31226ca1e --- /dev/null +++ b/apps/web/src/components/tx/confirmation-views/ManageSigners/index.tsx @@ -0,0 +1,116 @@ +import { useMemo } from 'react' +import type { ReactElement } from 'react' + +import MinusIcon from '@/public/images/common/minus.svg' +import EthHashInfo from '@/components/common/EthHashInfo' +import useSafeInfo from '@/hooks/useSafeInfo' +import { Stack, Box } from '@mui/material' +import { maybePlural } from '@safe-global/utils/utils/formatters' +import FieldsGrid from '../../FieldsGrid' +import { getNewSafeSetup } from './get-new-safe-setup' +import type { TransactionInfo, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' +import { OwnerList } from '@/components/tx-flow/common/OwnerList' + +export function ManageSigners({ + txInfo, + txData, +}: { + txInfo: TransactionInfo + txData: TransactionDetails['txData'] +}): ReactElement { + const { safe } = useSafeInfo() + const { newOwners, newThreshold } = useMemo(() => { + return getNewSafeSetup({ + txInfo, + txData, + safe, + }) + }, [txInfo, txData, safe]) + + return ( + <Stack display="flex" flexDirection="column" gap={3} sx={{ '& .MuiGrid-container': { alignItems: 'flex-start' } }}> + <ChangeSignerSetupWarning /> + + <Actions newOwners={newOwners} /> + + <Signers owners={newOwners} /> + + <Threshold owners={newOwners} threshold={newThreshold} /> + </Stack> + ) +} + +function Actions({ newOwners }: { newOwners: Array<string> }): ReactElement | null { + const { safe } = useSafeInfo() + + const addedOwners = newOwners + .filter((owner) => safe.owners.every(({ value }) => value !== owner)) + .map((addedOwner) => ({ value: addedOwner })) + const removedOwners = safe.owners + .filter((owner) => !newOwners.includes(owner.value)) + .map((removedOwner) => ({ + value: removedOwner.value, + name: removedOwner.name ?? undefined, + })) + + if (addedOwners.length === 0 && removedOwners.length === 0) { + return null + } + + return ( + <FieldsGrid title="Actions"> + {removedOwners.length > 0 && ( + <OwnerList + owners={removedOwners} + title={`Remove owner${maybePlural(removedOwners)}`} + icon={MinusIcon} + sx={{ backgroundColor: ({ palette }) => `${palette.warning.background} !important`, mb: 2 }} + /> + )} + + {addedOwners.length > 0 && <OwnerList owners={addedOwners} />} + </FieldsGrid> + ) +} + +function Signers({ owners }: { owners: Array<string> }): ReactElement { + return ( + <FieldsGrid title="Signers"> + <Box display="flex" flexDirection="column" gap={2} padding="var(--space-2)" fontSize="14px"> + {owners.map((owner) => ( + <EthHashInfo + avatarSize={32} + key={owner} + showName + address={owner} + shortAddress={false} + showCopyButton + hasExplorer + /> + ))} + </Box> + </FieldsGrid> + ) +} + +function Threshold({ owners, threshold }: { owners: Array<string>; threshold: number }): ReactElement { + return ( + <FieldsGrid title="Threshold"> + <Box + component="span" + sx={{ + // sx must be used as component is set + backgroundColor: 'background.main', + py: 0.5, + px: 1, + borderRadius: ({ shape }) => `${shape.borderRadius}px`, + fontWeight: 700, + }} + > + {threshold} of {owners.length} signer{maybePlural(owners)} + </Box>{' '} + required to confirm new transactions + </FieldsGrid> + ) +} diff --git a/apps/web/src/components/tx/confirmation-views/MigrateToL2Information/index.tsx b/apps/web/src/components/tx/confirmation-views/MigrateToL2Information/index.tsx index 594300fb2c..50c3c8c53e 100644 --- a/apps/web/src/components/tx/confirmation-views/MigrateToL2Information/index.tsx +++ b/apps/web/src/components/tx/confirmation-views/MigrateToL2Information/index.tsx @@ -1,21 +1,8 @@ -import { useMemo } from 'react' import { Alert, AlertTitle, Box, SvgIcon, Typography } from '@mui/material' import type { TransactionData } from '@safe-global/safe-gateway-typescript-sdk' import InfoOutlinedIcon from '@/public/images/notifications/info.svg' -import NamedAddressInfo from '@/components/common/NamedAddressInfo' -import { extractMigrationL2MasterCopyAddress } from '@/features/multichain/utils/extract-migration-data' - -export const MigrateToL2Information = ({ - variant, - txData, -}: { - variant: 'history' | 'queue' - txData?: TransactionData -}) => { - const newMasterCopy = useMemo(() => { - return txData && extractMigrationL2MasterCopyAddress(txData) - }, [txData]) +export const MigrateToL2Information = ({ variant }: { variant: 'history' | 'queue'; txData?: TransactionData }) => { return ( <Box> <Alert severity="info" icon={<SvgIcon component={InfoOutlinedIcon} color="info" />}> @@ -29,15 +16,6 @@ export const MigrateToL2Information = ({ ? 'This Safe was using an incompatible base contract. This transaction includes the migration to a supported base contract.' : 'This Safe is currently using an incompatible base contract. The transaction was automatically modified to first migrate to a supported base contract.'} </Typography> - - {newMasterCopy && ( - <Box mt={2}> - <Typography variant="overline" color="text.secondary" fontWeight={700}> - New contract - </Typography> - <NamedAddressInfo address={newMasterCopy} shortAddress={false} showCopyButton hasExplorer /> - </Box> - )} </Alert> </Box> ) diff --git a/apps/web/src/components/tx/confirmation-views/NestedSafeCreation/index.tsx b/apps/web/src/components/tx/confirmation-views/NestedSafeCreation/index.tsx new file mode 100644 index 0000000000..023d3ade50 --- /dev/null +++ b/apps/web/src/components/tx/confirmation-views/NestedSafeCreation/index.tsx @@ -0,0 +1,45 @@ +import { Box, Typography } from '@mui/material' +import type { ReactElement } from 'react' +import type { TransactionData } from '@safe-global/safe-gateway-typescript-sdk' + +import useAsync from '@safe-global/utils/hooks/useAsync' +import { predictSafeAddress } from '@/features/multichain/utils/utils' +import { useWeb3ReadOnly } from '@/hooks/wallets/web3' +import EthHashInfo from '@/components/common/EthHashInfo' +import useAddressBook from '@/hooks/useAddressBook' +import { _getFactoryAddressAndSetupData } from '@/utils/nested-safes' + +export function NestedSafeCreation({ txData }: { txData: TransactionData }): ReactElement | null { + const addressBook = useAddressBook() + const provider = useWeb3ReadOnly() + + const [predictedSafeAddress] = useAsync(async () => { + if (provider) { + const { factoryAddress, ...setupData } = _getFactoryAddressAndSetupData(txData) + return predictSafeAddress(setupData, factoryAddress, provider) + } + }, [provider, txData]) + + if (!predictedSafeAddress) { + return null + } + + return ( + <Box display="flex" alignItems="center" justifyContent="space-between"> + <Typography variant="body2" color="text.secondary" whiteSpace="nowrap"> + Nested Safe + </Typography> + + <div> + <EthHashInfo + name={addressBook[predictedSafeAddress]} + address={predictedSafeAddress} + shortAddress={false} + hasExplorer + showCopyButton + showAvatar + /> + </div> + </Box> + ) +} diff --git a/apps/web/src/components/tx/confirmation-views/SettingsChange/SettingsChange.test.tsx b/apps/web/src/components/tx/confirmation-views/SettingsChange/SettingsChange.test.tsx index 6a50ab63ef..a19a472ee1 100644 --- a/apps/web/src/components/tx/confirmation-views/SettingsChange/SettingsChange.test.tsx +++ b/apps/web/src/components/tx/confirmation-views/SettingsChange/SettingsChange.test.tsx @@ -17,7 +17,7 @@ describe('SettingsChange', () => { ) expect(container).toMatchSnapshot() - expect(getByText('New signer')).toBeInTheDocument() + expect(getByText('Add owner')).toBeInTheDocument() expect(getByText(ownerAddress)).toBeInTheDocument() }) diff --git a/apps/web/src/components/tx/confirmation-views/SettingsChange/UntrustedFallbackHandlerTxAlert.tsx b/apps/web/src/components/tx/confirmation-views/SettingsChange/UntrustedFallbackHandlerTxAlert.tsx index fbad826be9..d01dbae559 100644 --- a/apps/web/src/components/tx/confirmation-views/SettingsChange/UntrustedFallbackHandlerTxAlert.tsx +++ b/apps/web/src/components/tx/confirmation-views/SettingsChange/UntrustedFallbackHandlerTxAlert.tsx @@ -1,9 +1,31 @@ -import type { ReactElement } from 'react' +import { useMemo, type ReactElement } from 'react' import { Alert, SvgIcon } from '@mui/material' import InfoOutlinedIcon from '@/public/images/notifications/info.svg' -import { useIsOfficialFallbackHandler } from '@/hooks/useIsOfficialFallbackHandler' -import { useIsTWAPFallbackHandler } from '@/features/swap/hooks/useIsTWAPFallbackHandler' +import { useHasUntrustedFallbackHandler } from '@/hooks/useHasUntrustedFallbackHandler' import { FallbackHandlerWarning } from '@/components/settings/FallbackHandler' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' + +export const useSetsUntrustedFallbackHandler = (txData: TransactionDetails['txData']): boolean => { + // multiSend method receives one parameter `transactions` + const multiSendTransactions = + txData?.dataDecoded?.method === 'multiSend' && txData?.dataDecoded?.parameters?.[0]?.valueDecoded + + const fallbackHandlers = useMemo(() => { + const transactions = Array.isArray(multiSendTransactions) ? multiSendTransactions : txData ? [txData] : [] + + return Array.isArray(transactions) + ? transactions + .map(({ dataDecoded }) => + dataDecoded?.method === 'setFallbackHandler' + ? dataDecoded?.parameters?.find(({ name }) => name === 'handler')?.value + : undefined, + ) + .filter((handler) => typeof handler === 'string') + : [] + }, [multiSendTransactions, txData]) + + return useHasUntrustedFallbackHandler(fallbackHandlers) +} export const UntrustedFallbackHandlerTxText = ({ isTxExecuted = false }: { isTxExecuted?: boolean }) => ( <> @@ -26,26 +48,16 @@ export const UntrustedFallbackHandlerTxText = ({ isTxExecuted = false }: { isTxE ) export const UntrustedFallbackHandlerTxAlert = ({ - fallbackHandler, isTxExecuted = false, }: { - fallbackHandler: string isTxExecuted?: boolean -}): ReactElement | null => { - const isOfficial = useIsOfficialFallbackHandler(fallbackHandler) - const isTWAPFallbackHandler = useIsTWAPFallbackHandler(fallbackHandler) - - if (isOfficial || isTWAPFallbackHandler) { - return null - } - - return ( - <Alert - severity="warning" - icon={<SvgIcon component={InfoOutlinedIcon} inheritViewBox color="error" />} - sx={{ mb: 1 }} - > - <UntrustedFallbackHandlerTxText isTxExecuted={isTxExecuted} /> - </Alert> - ) -} +}): ReactElement | null => ( + <Alert + data-testid="untrusted-fallback-handler-alert" + severity="warning" + icon={<SvgIcon component={InfoOutlinedIcon} inheritViewBox color="error" />} + sx={{ mb: 1 }} + > + <UntrustedFallbackHandlerTxText isTxExecuted={isTxExecuted} /> + </Alert> +) diff --git a/apps/web/src/components/tx/confirmation-views/SettingsChange/__snapshots__/SettingsChange.test.tsx.snap b/apps/web/src/components/tx/confirmation-views/SettingsChange/__snapshots__/SettingsChange.test.tsx.snap index a9fa3b9c02..9f60e5574b 100644 --- a/apps/web/src/components/tx/confirmation-views/SettingsChange/__snapshots__/SettingsChange.test.tsx.snap +++ b/apps/web/src/components/tx/confirmation-views/SettingsChange/__snapshots__/SettingsChange.test.tsx.snap @@ -32,7 +32,7 @@ exports[`SettingsChange should display the SettingsChange component with newOwne class="MuiBox-root css-1lchl8k" > <div - class="MuiBox-root css-171onha" + class="ethHashInfo-name MuiBox-root css-171onha" title="Bob" > <div @@ -90,14 +90,14 @@ exports[`SettingsChange should display the SettingsChange component with newOwne style="--Paper-shadow: none;" > <p - class="MuiTypography-root MuiTypography-body1 css-14yu9oo-MuiTypography-root" + class="MuiTypography-root MuiTypography-body1 css-1950vt0-MuiTypography-root" > <mock-icon aria-hidden="" class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-1hr8a3z-MuiSvgIcon-root" focusable="false" /> - New signer + Add owner </p> <div class="container" @@ -115,7 +115,7 @@ exports[`SettingsChange should display the SettingsChange component with newOwne class="MuiBox-root css-1lchl8k" > <div - class="MuiBox-root css-171onha" + class="ethHashInfo-name MuiBox-root css-171onha" title="Alice" > <div @@ -181,14 +181,14 @@ exports[`SettingsChange should display the SettingsChange component with owner d style="--Paper-shadow: none;" > <p - class="MuiTypography-root MuiTypography-body1 css-14yu9oo-MuiTypography-root" + class="MuiTypography-root MuiTypography-body1 css-1950vt0-MuiTypography-root" > <mock-icon aria-hidden="" class="MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-1hr8a3z-MuiSvgIcon-root" focusable="false" /> - New signer + Add owner </p> <div class="container" @@ -206,7 +206,7 @@ exports[`SettingsChange should display the SettingsChange component with owner d class="MuiBox-root css-1lchl8k" > <div - class="MuiBox-root css-171onha" + class="ethHashInfo-name MuiBox-root css-171onha" title="Nevinha" > <div diff --git a/apps/web/src/components/tx/confirmation-views/SettingsChange/index.tsx b/apps/web/src/components/tx/confirmation-views/SettingsChange/index.tsx index c03315f9f0..befc18e6ae 100644 --- a/apps/web/src/components/tx/confirmation-views/SettingsChange/index.tsx +++ b/apps/web/src/components/tx/confirmation-views/SettingsChange/index.tsx @@ -5,15 +5,14 @@ import { OwnerList } from '@/components/tx-flow/common/OwnerList' import MinusIcon from '@/public/images/common/minus.svg' import commonCss from '@/components/tx-flow/common/styles.module.css' import useSafeInfo from '@/hooks/useSafeInfo' -import { SettingsInfoType, type SettingsChange } from '@safe-global/safe-gateway-typescript-sdk' +import { SettingsInfoType, type SettingsChange as SettingsChangeType } from '@safe-global/safe-gateway-typescript-sdk' import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' import { useContext } from 'react' import { SettingsChangeContext } from '@/components/tx-flow/flows/AddOwner/context' -import { maybePlural } from '@/utils/formatters' -import { UntrustedFallbackHandlerTxAlert } from '@/components/tx/confirmation-views/SettingsChange/UntrustedFallbackHandlerTxAlert' +import { maybePlural } from '@safe-global/utils/utils/formatters' export interface SettingsChangeProps extends NarrowConfirmationViewProps { - txInfo: SettingsChange + txInfo: SettingsChangeType } const SettingsChange: React.FC<SettingsChangeProps> = ({ txInfo: { settingsInfo } }) => { @@ -25,12 +24,9 @@ const SettingsChange: React.FC<SettingsChangeProps> = ({ txInfo: { settingsInfo const shouldShowChangeSigner = 'owner' in settingsInfo || 'newOwner' in params const hasNewOwner = 'newOwner' in params const newSignersLength = safe.owners.length + ('removedOwner' in settingsInfo ? 0 : 1) - const setsFallbackHandler = settingsInfo.type === SettingsInfoType.SET_FALLBACK_HANDLER return ( <> - {setsFallbackHandler && <UntrustedFallbackHandlerTxAlert fallbackHandler={settingsInfo.handler.value} />} - {'oldOwner' in settingsInfo && ( <Paper sx={{ backgroundColor: ({ palette }) => palette.warning.background, p: 2 }}> <Typography color="text.secondary" mb={2} display="flex" alignItems="center"> diff --git a/apps/web/src/components/tx/confirmation-views/UpdateSafe/index.test.tsx b/apps/web/src/components/tx/confirmation-views/UpdateSafe/index.test.tsx index b3989d8570..d0d8a72372 100644 --- a/apps/web/src/components/tx/confirmation-views/UpdateSafe/index.test.tsx +++ b/apps/web/src/components/tx/confirmation-views/UpdateSafe/index.test.tsx @@ -1,26 +1,113 @@ -import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { ChainInfo, TransactionData } from '@safe-global/safe-gateway-typescript-sdk' import { _UpdateSafe as UpdateSafe } from './index' import { render } from '@/tests/test-utils' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import { Gnosis_safe__factory } from '@safe-global/utils/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.1.1' +import { getSafeMigrationDeployment, getSafeSingletonDeployment } from '@safe-global/safe-deployments' +import { Safe_migration__factory } from '@safe-global/utils/types/contracts' +import { faker } from '@faker-js/faker' const chain = { recommendedMasterCopyVersion: '1.4.1', } as ChainInfo +const Safe_111_interface = Gnosis_safe__factory.createInterface() + const warningText = 'This upgrade will invalidate all queued transactions!' +const unknownTargetWarningText = + 'The target contract for this upgrade is unknown. Verify the transaction data and the target contract address before executing this transaction.' + describe('Container', () => { it('renders correctly with a queue warning', async () => { - const container = render(<UpdateSafe safeVersion="1.1.1" queueSize="10+" chain={chain} />) + const newSingleton = getSafeSingletonDeployment({ version: '1.4.1' })?.defaultAddress! + const safe = extendedSafeInfoBuilder().with({ version: '1.1.1' }).build() + const txData: TransactionData = { + operation: 0, + to: { value: safe.address.value, name: safe.address.name ?? undefined }, + trustedDelegateCallTarget: true, + value: '0', + hexData: Safe_111_interface.encodeFunctionData('changeMasterCopy', [newSingleton]), + } + const container = render( + <UpdateSafe + txData={txData} + safeInfo={{ safe, safeAddress: safe.address.value, safeLoaded: true, safeLoading: false }} + queueSize="10+" + chain={chain} + />, + ) await expect(container.findByText(warningText)).resolves.not.toBeNull() + expect(container.queryByText('Current version: 1.1.1')).toBeVisible() + expect(container.queryByText('New version: 1.4.1')).toBeVisible() }) it('renders correctly without a queue warning because no queue', async () => { - const container = render(<UpdateSafe safeVersion="1.1.1" queueSize="" chain={chain} />) + const newSingleton = getSafeSingletonDeployment({ version: '1.4.1' })?.defaultAddress! + const safe = extendedSafeInfoBuilder().with({ version: '1.1.1' }).build() + const txData: TransactionData = { + operation: 0, + to: { value: safe.address.value, name: safe.address.name ?? undefined }, + trustedDelegateCallTarget: true, + value: '0', + hexData: Safe_111_interface.encodeFunctionData('changeMasterCopy', [newSingleton]), + } + const container = render( + <UpdateSafe + txData={txData} + safeInfo={{ safe, safeAddress: safe.address.value, safeLoaded: true, safeLoading: false }} + queueSize="" + chain={chain} + />, + ) await expect(container.findByText(warningText)).rejects.toThrowError(Error) + expect(container.queryByText('Current version: 1.1.1')).toBeVisible() + expect(container.queryByText('New version: 1.4.1')).toBeVisible() }) it('renders correctly without a queue warning because of compatible Safe version', async () => { - const container = render(<UpdateSafe safeVersion="1.3.0" queueSize="10" chain={chain} />) + const migrationAddress = getSafeMigrationDeployment({ version: '1.4.1' })?.defaultAddress! + const safe = extendedSafeInfoBuilder().with({ version: '1.3.0' }).build() + const txData: TransactionData = { + operation: 1, + to: { value: migrationAddress }, + trustedDelegateCallTarget: true, + value: '0', + hexData: Safe_migration__factory.createInterface().encodeFunctionData('migrateSingleton'), + } + const container = render( + <UpdateSafe + txData={txData} + safeInfo={{ safe, safeAddress: safe.address.value, safeLoaded: true, safeLoading: false }} + queueSize="10+" + chain={chain} + />, + ) await expect(container.findByText(warningText)).rejects.toThrowError(Error) + expect(container.queryByText('Current version: 1.3.0')).toBeVisible() + expect(container.queryByText('New version: 1.4.1')).toBeVisible() + }) + + it('renders correctly with a unknown contract warning if the target contract is not known', async () => { + const newSingleton = faker.finance.ethereumAddress() + const safe = extendedSafeInfoBuilder().with({ version: '1.1.1' }).build() + const txData: TransactionData = { + operation: 0, + to: { value: safe.address.value, name: safe.address.name ?? undefined }, + trustedDelegateCallTarget: true, + value: '0', + hexData: Safe_111_interface.encodeFunctionData('changeMasterCopy', [newSingleton]), + } + const container = render( + <UpdateSafe + txData={txData} + safeInfo={{ safe, safeAddress: safe.address.value, safeLoaded: true, safeLoading: false }} + queueSize="0" + chain={chain} + />, + ) + expect(container.queryByText('Current version: 1.1.1')).toBeVisible() + expect(container.queryAllByText('Unknown contract')).toHaveLength(2) + expect(container.queryByText(unknownTargetWarningText)).toBeVisible() }) }) diff --git a/apps/web/src/components/tx/confirmation-views/UpdateSafe/index.tsx b/apps/web/src/components/tx/confirmation-views/UpdateSafe/index.tsx index 520b6be0fd..93f279ab39 100644 --- a/apps/web/src/components/tx/confirmation-views/UpdateSafe/index.tsx +++ b/apps/web/src/components/tx/confirmation-views/UpdateSafe/index.tsx @@ -1,58 +1,71 @@ import type { ReactNode } from 'react' import { Alert, AlertTitle, Box, Divider, Stack, Typography } from '@mui/material' import semverSatisfies from 'semver/functions/satisfies' -import { LATEST_SAFE_VERSION } from '@/config/constants' import { useCurrentChain } from '@/hooks/useChains' import useSafeInfo from '@/hooks/useSafeInfo' import { useQueuedTxsLength } from '@/hooks/useTxQueue' import ExternalLink from '@/components/common/ExternalLink' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' import madProps from '@/utils/mad-props' +import { type TransactionData } from '@safe-global/safe-gateway-typescript-sdk' +import { extractTargetVersionFromUpdateSafeTx } from '@/services/tx/safeUpdateParams' const QUEUE_WARNING_VERSION = '<1.3.0' -function BgBox({ children, light }: { children: ReactNode; light?: boolean }) { +function BgBox({ children, light, warning }: { children: ReactNode; light?: boolean; warning?: boolean }) { + const bgcolor = warning ? 'warning.background' : light ? 'background.light' : 'border.background' return ( - <Box - flex={1} - bgcolor={light ? 'background.light' : 'border.background'} - p={2} - textAlign="center" - fontWeight={700} - fontSize={18} - borderRadius={1} - > + <Box flex={1} bgcolor={bgcolor} p={2} textAlign="center" fontWeight={700} fontSize={18} borderRadius={1}> {children} </Box> ) } export function _UpdateSafe({ - safeVersion, + safeInfo, queueSize, chain, + txData, }: { - safeVersion: string + safeInfo: ReturnType<typeof useSafeInfo> queueSize: string chain: ReturnType<typeof useCurrentChain> + txData: TransactionData | undefined }) { - const showQueueWarning = queueSize && semverSatisfies(safeVersion, QUEUE_WARNING_VERSION) - const latestSafeVersion = chain?.recommendedMasterCopyVersion || LATEST_SAFE_VERSION + const { safe } = safeInfo + if (!safe.version) { + return null + } + const showQueueWarning = queueSize && semverSatisfies(safe.version, QUEUE_WARNING_VERSION) + const newVersion = extractTargetVersionFromUpdateSafeTx(txData, safe) return ( <> <Stack direction="row" alignItems="center" spacing={2}> - <BgBox>Current version: {safeVersion}</BgBox> + <BgBox>Current version: {safe.version}</BgBox> <Box fontSize={28}>→</Box> - <BgBox light>New version: {latestSafeVersion}</BgBox> + {newVersion !== undefined ? ( + <BgBox light> + New version: {newVersion} {chain?.l2 ? '+L2' : ''} + </BgBox> + ) : ( + <BgBox warning>Unknown contract</BgBox> + )} </Stack> - - <Typography> - Read about the updates in the new Safe contracts version in the{' '} - <ExternalLink href={`https://github.com/safe-global/safe-contracts/releases/tag/v${latestSafeVersion}`}> - version {latestSafeVersion} changelog - </ExternalLink> - </Typography> + {newVersion !== undefined ? ( + <Typography> + Read about the updates in the new Safe contracts version in the{' '} + <ExternalLink href={`https://github.com/safe-global/safe-contracts/releases/tag/v${newVersion}`}> + version {newVersion} changelog + </ExternalLink> + </Typography> + ) : ( + <Alert severity="error"> + <AlertTitle sx={{ fontWeight: 700 }}>Unknown contract</AlertTitle> + The target contract for this upgrade is unknown. Verify the transaction data and the target contract address + before executing this transaction. + </Alert> + )} {showQueueWarning && ( <Alert severity="warning"> @@ -67,14 +80,9 @@ export function _UpdateSafe({ ) } -function useSafeVersion() { - const { safe } = useSafeInfo() - return safe?.version || '' -} - const UpdateSafe = madProps(_UpdateSafe, { chain: useCurrentChain, - safeVersion: useSafeVersion, + safeInfo: useSafeInfo, queueSize: useQueuedTxsLength, }) diff --git a/apps/web/src/components/tx/confirmation-views/__snapshots__/ConfirmationView.test.tsx.snap b/apps/web/src/components/tx/confirmation-views/__snapshots__/ConfirmationView.test.tsx.snap index e38a16da12..4004cef32e 100644 --- a/apps/web/src/components/tx/confirmation-views/__snapshots__/ConfirmationView.test.tsx.snap +++ b/apps/web/src/components/tx/confirmation-views/__snapshots__/ConfirmationView.test.tsx.snap @@ -260,17 +260,9 @@ exports[`ConfirmationView should display a confirmation screen for a SETTINGS_CH </div> </div> </div> - <div - class="MuiBox-root css-kz5wc9" - /> <div class="MuiBox-root css-0" > - <p - class="MuiTypography-root MuiTypography-body1 css-111k8jw-MuiTypography-root" - > - Parameters - </p> <div class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" > @@ -328,7 +320,7 @@ exports[`ConfirmationView should display a confirmation screen for a SETTINGS_CH style="cursor: pointer;" > <span> - 0xd8dA...6045 + 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 </span> </span> </div> @@ -401,123 +393,82 @@ exports[`ConfirmationView should display a confirmation screen for a SETTINGS_CH class="MuiBox-root css-kz5wc9" /> <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + class="MuiBox-root css-zv7ju9" > - <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" - data-testid="tx-row-title" - style="word-break: break-word;" + <p + class="MuiTypography-root MuiTypography-body1 css-111k8jw-MuiTypography-root" > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" - > - safeTxGas: - </p> - </div> + Transaction data + </p> <div - class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" - data-testid="tx-data-row" + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" > <div - class="MuiBox-root css-rurw5h" + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" > - 0 + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + to: + </p> </div> - </div> - </div> - <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" - > - <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" - data-testid="tx-row-title" - style="word-break: break-word;" - > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" - > - baseGas: - </p> - </div> - <div - class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" - data-testid="tx-data-row" - > - [object Object] - </div> - </div> - <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" - > - <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" - data-testid="tx-row-title" - style="word-break: break-word;" - > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" - > - refundReceiver: - </p> - </div> - <div - class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" - data-testid="tx-data-row" - > <div - class="container" + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" > <div - class="MuiBox-root css-1lchl8k" + class="container" > <div - class="addressContainer" + class="MuiBox-root css-1lchl8k" > <div - class="MuiBox-root css-b5p5gz" + class="addressContainer" > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span> + 0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b + </span> + </span> + </div> <span aria-label="Copy to clipboard" class="" data-mui-internal-clone-element="true" style="cursor: pointer;" > - <span> - 0x7996...aC52 - </span> + <button + aria-label="Copy to clipboard" + class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + tabindex="0" + type="button" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" + data-testid="copy-btn-icon" + focusable="false" + /> + </button> </span> + <div + class="MuiBox-root css-yjghm1" + /> </div> - <span - aria-label="Copy to clipboard" - class="" - data-mui-internal-clone-element="true" - style="cursor: pointer;" - > - <button - aria-label="Copy to clipboard" - class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" - tabindex="0" - type="button" - > - <mock-icon - aria-hidden="" - class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" - data-testid="copy-btn-icon" - focusable="false" - /> - </button> - </span> - <div - class="MuiBox-root css-yjghm1" - /> </div> </div> </div> </div> - </div> - <div - class="MuiBox-root css-164r41r" - > <div class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" > @@ -529,7 +480,32 @@ exports[`ConfirmationView should display a confirmation screen for a SETTINGS_CH <p class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" > - Raw data: + value: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + <p + class="MuiTypography-root MuiTypography-body1 css-mxvcov-MuiTypography-root" + > + 0x0 + </p> + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + data: </p> </div> <div @@ -565,140 +541,288 @@ exports[`ConfirmationView should display a confirmation screen for a SETTINGS_CH </div> </div> </div> - </div> - </div> - </div> - </div> - </div> - </div> - </div> - </div> - </div> -</div> -`; - -exports[`ConfirmationView should display a confirmation with method call when the transaction type is not found in the ConfirmationView's mapper 1`] = ` -<div> - <div - class="MuiStack-root css-1sazv7p-MuiStack-root" - > - <div - class="MuiBox-root css-0" - > - <div - class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiAccordion-root MuiAccordion-rounded MuiAccordion-gutters css-1gdgqta-MuiPaper-root-MuiAccordion-root" - style="--Paper-shadow: none;" - > - <h3 - class="MuiAccordion-heading css-cy7rkm-MuiAccordion-heading" - > - <button - aria-expanded="false" - class="MuiButtonBase-root MuiAccordionSummary-root MuiAccordionSummary-gutters accordion css-10sjung-MuiButtonBase-root-MuiAccordionSummary-root" - data-testid="decoded-tx-summary" - tabindex="0" - type="button" - > - <span - class="MuiAccordionSummary-content MuiAccordionSummary-contentGutters css-1r0e0ir-MuiAccordionSummary-content" - > - Advanced details - <span - class="" - data-mui-internal-clone-element="true" - > - <mock-icon - aria-hidden="" - class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-17ibv3e-MuiSvgIcon-root" - focusable="false" - /> - </span> - <div - class="MuiBox-root css-1rr4qq7" - /> - addOwnerWithThreshold - </span> - <span - class="MuiAccordionSummary-expandIconWrapper css-1wqf3nl-MuiAccordionSummary-expandIconWrapper" - > - <svg - aria-hidden="true" - class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-1dhtbeh-MuiSvgIcon-root" - data-testid="ExpandMoreIcon" - focusable="false" - viewBox="0 0 24 24" - > - <path - d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" - /> - </svg> - </span> - </button> - </h3> - <div - class="MuiCollapse-root MuiCollapse-vertical MuiCollapse-hidden css-cwrbtg-MuiCollapse-root" - style="min-height: 0px;" - > - <div - class="MuiCollapse-wrapper MuiCollapse-vertical css-1x6hinx-MuiCollapse-wrapper" - > - <div - class="MuiCollapse-wrapperInner MuiCollapse-vertical css-1i4ywhz-MuiCollapse-wrapperInner" - > - <div - class="MuiAccordion-region" - role="region" - > - <div - class="MuiAccordionDetails-root css-w74p4c-MuiAccordionDetails-root" - data-testid="decoded-tx-details" - > - <div - class="MuiStack-root css-1sazv7p-MuiStack-root" - > <div - class="MuiTypography-root MuiTypography-body1 css-13ch7tx-MuiTypography-root" + class="MuiBox-root css-1v3caum" + /> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" > - Call - <code - class="MuiTypography-root MuiTypography-body2 css-1vdua93-MuiTypography-root" - > - addOwnerWithThreshold - </code> - - on <div - class="container" + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" > - <div - class="avatarContainer" - style="width: 24px; height: 24px;" + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" > - <div - class="icon" - style="background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4IDgiIHNoYXBlLXJlbmRlcmluZz0ib3B0aW1pemVTcGVlZCIgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0Ij48cGF0aCBmaWxsPSJoc2woOTIgOTYlIDQzJSkiIGQ9Ik0wLDBIOFY4SDB6Ii8+PHBhdGggZmlsbD0iaHNsKDMxMCA4NCUgMzglKSIgZD0iTTEsMGgxdjFoLTF6TTYsMGgxdjFoLTF6TTIsMGgxdjFoLTF6TTUsMGgxdjFoLTF6TTMsMGgxdjFoLTF6TTQsMGgxdjFoLTF6TTAsMWgxdjFoLTF6TTcsMWgxdjFoLTF6TTEsMWgxdjFoLTF6TTYsMWgxdjFoLTF6TTIsMWgxdjFoLTF6TTUsMWgxdjFoLTF6TTMsMWgxdjFoLTF6TTQsMWgxdjFoLTF6TTAsMmgxdjFoLTF6TTcsMmgxdjFoLTF6TTIsMmgxdjFoLTF6TTUsMmgxdjFoLTF6TTMsMmgxdjFoLTF6TTQsMmgxdjFoLTF6TTMsM2gxdjFoLTF6TTQsM2gxdjFoLTF6TTAsNGgxdjFoLTF6TTcsNGgxdjFoLTF6TTEsNGgxdjFoLTF6TTYsNGgxdjFoLTF6TTIsNGgxdjFoLTF6TTUsNGgxdjFoLTF6TTEsNWgxdjFoLTF6TTYsNWgxdjFoLTF6TTIsNWgxdjFoLTF6TTUsNWgxdjFoLTF6TTMsNWgxdjFoLTF6TTQsNWgxdjFoLTF6TTIsNmgxdjFoLTF6TTUsNmgxdjFoLTF6TTMsNmgxdjFoLTF6TTQsNmgxdjFoLTF6TTMsN2gxdjFoLTF6TTQsN2gxdjFoLTF6Ii8+PHBhdGggZmlsbD0iaHNsKDM1MSA2MSUgNjUlKSIgZD0iTTMsNGgxdjFoLTF6TTQsNGgxdjFoLTF6TTAsNWgxdjFoLTF6TTcsNWgxdjFoLTF6TTAsN2gxdjFoLTF6TTcsN2gxdjFoLTF6Ii8+PC9zdmc+); width: 24px; height: 24px;" - /> - </div> - <div - class="inline MuiBox-root css-1lchl8k" + operation: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + 0 (call) + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" > - <div - class="addressContainer inline" - > - <div - class="MuiBox-root css-b5p5gz" - > - <span - aria-label="Copy to clipboard" - class="" - data-mui-internal-clone-element="true" - style="cursor: pointer;" - > - <span> - 0xE20C...5f67 - </span> + nonce: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + 4880438995992060 + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + safeTxGas: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + [object Object] + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + baseGas: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + [object Object] + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + gasPrice: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + [object Object] + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + gasToken: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + <div + class="container" + > + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span> + 0x0000...0000 + </span> + </span> + </div> + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <button + aria-label="Copy to clipboard" + class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + tabindex="0" + type="button" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" + data-testid="copy-btn-icon" + focusable="false" + /> + </button> + </span> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + refundReceiver: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + <div + class="container" + > + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span> + 0x7996...aC52 + </span> + </span> + </div> + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <button + aria-label="Copy to clipboard" + class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + tabindex="0" + type="button" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" + data-testid="copy-btn-icon" + focusable="false" + /> + </button> </span> + <div + class="MuiBox-root css-yjghm1" + /> </div> + </div> + </div> + </div> + </div> + <div + class="MuiBox-root css-1v3caum" + /> + <div + class="MuiBox-root css-kz5wc9" + /> + <p + class="MuiTypography-root MuiTypography-body1 css-111k8jw-MuiTypography-root" + > + Transaction hashes + </p> + <div + class="MuiStack-root css-hwnj0i-MuiStack-root" + > + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + Domain hash: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + <div + class="encodedData MuiBox-root css-0" + data-testid="tx-hexData" + > <span aria-label="Copy to clipboard" class="" @@ -707,125 +831,725 @@ exports[`ConfirmationView should display a confirmation with method call when th > <button aria-label="Copy to clipboard" - class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" - tabindex="0" - type="button" + class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + tabindex="0" + type="button" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" + data-testid="copy-btn-icon" + focusable="false" + /> + </button> + </span> + + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> +</div> +`; + +exports[`ConfirmationView should display a confirmation with method call when the transaction type is not found in the ConfirmationView's mapper 1`] = ` +<div> + <div + class="MuiStack-root css-1sazv7p-MuiStack-root" + > + <div + class="MuiBox-root css-0" + > + <div + class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation0 MuiAccordion-root MuiAccordion-rounded MuiAccordion-gutters css-1gdgqta-MuiPaper-root-MuiAccordion-root" + style="--Paper-shadow: none;" + > + <h3 + class="MuiAccordion-heading css-cy7rkm-MuiAccordion-heading" + > + <button + aria-expanded="false" + class="MuiButtonBase-root MuiAccordionSummary-root MuiAccordionSummary-gutters accordion css-10sjung-MuiButtonBase-root-MuiAccordionSummary-root" + data-testid="decoded-tx-summary" + tabindex="0" + type="button" + > + <span + class="MuiAccordionSummary-content MuiAccordionSummary-contentGutters css-1r0e0ir-MuiAccordionSummary-content" + > + Advanced details + <span + class="" + data-mui-internal-clone-element="true" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-17ibv3e-MuiSvgIcon-root" + focusable="false" + /> + </span> + <div + class="MuiBox-root css-1rr4qq7" + /> + addOwnerWithThreshold + </span> + <span + class="MuiAccordionSummary-expandIconWrapper css-1wqf3nl-MuiAccordionSummary-expandIconWrapper" + > + <svg + aria-hidden="true" + class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-1dhtbeh-MuiSvgIcon-root" + data-testid="ExpandMoreIcon" + focusable="false" + viewBox="0 0 24 24" + > + <path + d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" + /> + </svg> + </span> + </button> + </h3> + <div + class="MuiCollapse-root MuiCollapse-vertical MuiCollapse-hidden css-cwrbtg-MuiCollapse-root" + style="min-height: 0px;" + > + <div + class="MuiCollapse-wrapper MuiCollapse-vertical css-1x6hinx-MuiCollapse-wrapper" + > + <div + class="MuiCollapse-wrapperInner MuiCollapse-vertical css-1i4ywhz-MuiCollapse-wrapperInner" + > + <div + class="MuiAccordion-region" + role="region" + > + <div + class="MuiAccordionDetails-root css-w74p4c-MuiAccordionDetails-root" + data-testid="decoded-tx-details" + > + <div + class="MuiStack-root css-1sazv7p-MuiStack-root" + > + <div + class="MuiTypography-root MuiTypography-body1 css-13ch7tx-MuiTypography-root" + > + Call + <code + class="MuiTypography-root MuiTypography-body2 css-1vdua93-MuiTypography-root" + > + addOwnerWithThreshold + </code> + + on + <div + class="container" + > + <div + class="avatarContainer" + style="width: 24px; height: 24px;" + > + <div + class="icon" + style="background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4IDgiIHNoYXBlLXJlbmRlcmluZz0ib3B0aW1pemVTcGVlZCIgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0Ij48cGF0aCBmaWxsPSJoc2woOTIgOTYlIDQzJSkiIGQ9Ik0wLDBIOFY4SDB6Ii8+PHBhdGggZmlsbD0iaHNsKDMxMCA4NCUgMzglKSIgZD0iTTEsMGgxdjFoLTF6TTYsMGgxdjFoLTF6TTIsMGgxdjFoLTF6TTUsMGgxdjFoLTF6TTMsMGgxdjFoLTF6TTQsMGgxdjFoLTF6TTAsMWgxdjFoLTF6TTcsMWgxdjFoLTF6TTEsMWgxdjFoLTF6TTYsMWgxdjFoLTF6TTIsMWgxdjFoLTF6TTUsMWgxdjFoLTF6TTMsMWgxdjFoLTF6TTQsMWgxdjFoLTF6TTAsMmgxdjFoLTF6TTcsMmgxdjFoLTF6TTIsMmgxdjFoLTF6TTUsMmgxdjFoLTF6TTMsMmgxdjFoLTF6TTQsMmgxdjFoLTF6TTMsM2gxdjFoLTF6TTQsM2gxdjFoLTF6TTAsNGgxdjFoLTF6TTcsNGgxdjFoLTF6TTEsNGgxdjFoLTF6TTYsNGgxdjFoLTF6TTIsNGgxdjFoLTF6TTUsNGgxdjFoLTF6TTEsNWgxdjFoLTF6TTYsNWgxdjFoLTF6TTIsNWgxdjFoLTF6TTUsNWgxdjFoLTF6TTMsNWgxdjFoLTF6TTQsNWgxdjFoLTF6TTIsNmgxdjFoLTF6TTUsNmgxdjFoLTF6TTMsNmgxdjFoLTF6TTQsNmgxdjFoLTF6TTMsN2gxdjFoLTF6TTQsN2gxdjFoLTF6Ii8+PHBhdGggZmlsbD0iaHNsKDM1MSA2MSUgNjUlKSIgZD0iTTMsNGgxdjFoLTF6TTQsNGgxdjFoLTF6TTAsNWgxdjFoLTF6TTcsNWgxdjFoLTF6TTAsN2gxdjFoLTF6TTcsN2gxdjFoLTF6Ii8+PC9zdmc+); width: 24px; height: 24px;" + /> + </div> + <div + class="inline MuiBox-root css-1lchl8k" + > + <div + class="addressContainer inline" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span> + 0xE20C...5f67 + </span> + </span> + </div> + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <button + aria-label="Copy to clipboard" + class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + tabindex="0" + type="button" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" + data-testid="copy-btn-icon" + focusable="false" + /> + </button> + </span> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </div> + <div + class="MuiBox-root css-0" + > + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + <span + class="MuiTypography-root MuiTypography-body1 css-v6lhhw-MuiTypography-root" + > + owner + </span> + + <span + class="MuiTypography-root MuiTypography-body1 css-fw8ds0-MuiTypography-root" + > + address + </span> + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + <div + class="container" + > + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="MuiBox-root css-171onha" + title="MetaMultiSigWallet" + > + <div + class="MuiBox-root css-1o4wo1x" + > + MetaMultiSigWallet + </div> + </div> + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span> + 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + </span> + </span> + </div> + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <button + aria-label="Copy to clipboard" + class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + tabindex="0" + type="button" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" + data-testid="copy-btn-icon" + focusable="false" + /> + </button> + </span> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + <span + class="MuiTypography-root MuiTypography-body1 css-v6lhhw-MuiTypography-root" + > + _threshold + </span> + + <span + class="MuiTypography-root MuiTypography-body1 css-fw8ds0-MuiTypography-root" + > + uint256 + </span> + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + <p + class="MuiTypography-root MuiTypography-body1 css-mxvcov-MuiTypography-root" + > + 1 + </p> + </div> + </div> + </div> + </div> + <div + class="MuiBox-root css-kz5wc9" + /> + <div + class="MuiBox-root css-zv7ju9" + > + <p + class="MuiTypography-root MuiTypography-body1 css-111k8jw-MuiTypography-root" + > + Transaction data + </p> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + to: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + <div + class="container" + > + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span> + 0xDa5e9FA404881Ff36DDa97b41Da402dF6430EE6b + </span> + </span> + </div> + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <button + aria-label="Copy to clipboard" + class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + tabindex="0" + type="button" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" + data-testid="copy-btn-icon" + focusable="false" + /> + </button> + </span> + <div + class="MuiBox-root css-yjghm1" + /> + </div> + </div> + </div> + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + value: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + <p + class="MuiTypography-root MuiTypography-body1 css-mxvcov-MuiTypography-root" + > + 0x0 + </p> + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + data: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + <div + class="encodedData MuiBox-root css-0" + data-testid="tx-hexData" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <button + aria-label="Copy to clipboard" + class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + tabindex="0" + type="button" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" + data-testid="copy-btn-icon" + focusable="false" + /> + </button> + </span> + 0x + + </div> + </div> + </div> + <div + class="MuiBox-root css-1v3caum" + /> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + operation: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + 0 (call) + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + nonce: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + 4880438995992060 + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + safeTxGas: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + [object Object] + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + baseGas: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + [object Object] + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + gasPrice: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + [object Object] + </div> + </div> + <div + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + > + <div + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" + > + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" + > + gasToken: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > + <div + class="container" + > + <div + class="MuiBox-root css-1lchl8k" + > + <div + class="addressContainer" + > + <div + class="MuiBox-root css-b5p5gz" + > + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <span> + 0x0000...0000 + </span> + </span> + </div> + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" > - <mock-icon - aria-hidden="" - class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" - data-testid="copy-btn-icon" - focusable="false" - /> - </button> - </span> - <div - class="MuiBox-root css-yjghm1" - /> + <button + aria-label="Copy to clipboard" + class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + tabindex="0" + type="button" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" + data-testid="copy-btn-icon" + focusable="false" + /> + </button> + </span> + <div + class="MuiBox-root css-yjghm1" + /> + </div> </div> </div> </div> </div> <div - class="MuiBox-root css-kz5wc9" - /> - <div - class="MuiBox-root css-0" + class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" > - <p - class="MuiTypography-root MuiTypography-body1 css-111k8jw-MuiTypography-root" - > - Parameters - </p> <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" + class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" + data-testid="tx-row-title" + style="word-break: break-word;" > - <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" - data-testid="tx-row-title" - style="word-break: break-word;" + <p + class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" - > - <span - class="MuiTypography-root MuiTypography-body1 css-v6lhhw-MuiTypography-root" - > - owner - </span> - - <span - class="MuiTypography-root MuiTypography-body1 css-fw8ds0-MuiTypography-root" - > - address - </span> - </p> - </div> + refundReceiver: + </p> + </div> + <div + class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" + data-testid="tx-data-row" + > <div - class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" - data-testid="tx-data-row" + class="container" > <div - class="container" + class="MuiBox-root css-1lchl8k" > <div - class="MuiBox-root css-1lchl8k" + class="addressContainer" > <div - class="MuiBox-root css-171onha" - title="MetaMultiSigWallet" - > - <div - class="MuiBox-root css-1o4wo1x" - > - MetaMultiSigWallet - </div> - </div> - <div - class="addressContainer" + class="MuiBox-root css-b5p5gz" > - <div - class="MuiBox-root css-b5p5gz" - > - <span - aria-label="Copy to clipboard" - class="" - data-mui-internal-clone-element="true" - style="cursor: pointer;" - > - <span> - 0xd8dA...6045 - </span> - </span> - </div> <span aria-label="Copy to clipboard" class="" data-mui-internal-clone-element="true" style="cursor: pointer;" > - <button - aria-label="Copy to clipboard" - class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" - tabindex="0" - type="button" - > - <mock-icon - aria-hidden="" - class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" - data-testid="copy-btn-icon" - focusable="false" - /> - </button> + <span> + 0x7996...aC52 + </span> </span> - <div - class="MuiBox-root css-yjghm1" - /> </div> + <span + aria-label="Copy to clipboard" + class="" + data-mui-internal-clone-element="true" + style="cursor: pointer;" + > + <button + aria-label="Copy to clipboard" + class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" + tabindex="0" + type="button" + > + <mock-icon + aria-hidden="" + class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" + data-testid="copy-btn-icon" + focusable="false" + /> + </button> + </span> + <div + class="MuiBox-root css-yjghm1" + /> </div> </div> </div> </div> + </div> + <div + class="MuiBox-root css-1v3caum" + /> + <div + class="MuiBox-root css-kz5wc9" + /> + <p + class="MuiTypography-root MuiTypography-body1 css-111k8jw-MuiTypography-root" + > + Transaction hashes + </p> + <div + class="MuiStack-root css-hwnj0i-MuiStack-root" + > <div class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" > @@ -837,122 +1561,17 @@ exports[`ConfirmationView should display a confirmation with method call when th <p class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" > - <span - class="MuiTypography-root MuiTypography-body1 css-v6lhhw-MuiTypography-root" - > - _threshold - </span> - - <span - class="MuiTypography-root MuiTypography-body1 css-fw8ds0-MuiTypography-root" - > - uint256 - </span> + Domain hash: </p> </div> <div class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" data-testid="tx-data-row" - > - <p - class="MuiTypography-root MuiTypography-body1 css-mxvcov-MuiTypography-root" - > - 1 - </p> - </div> - </div> - </div> - </div> - <div - class="MuiBox-root css-kz5wc9" - /> - <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" - > - <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" - data-testid="tx-row-title" - style="word-break: break-word;" - > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" - > - safeTxGas: - </p> - </div> - <div - class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" - data-testid="tx-data-row" - > - <div - class="MuiBox-root css-rurw5h" - > - 0 - </div> - </div> - </div> - <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" - > - <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" - data-testid="tx-row-title" - style="word-break: break-word;" - > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" - > - baseGas: - </p> - </div> - <div - class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" - data-testid="tx-data-row" - > - [object Object] - </div> - </div> - <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" - > - <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" - data-testid="tx-row-title" - style="word-break: break-word;" - > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" - > - refundReceiver: - </p> - </div> - <div - class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" - data-testid="tx-data-row" - > - <div - class="container" - > - <div - class="MuiBox-root css-1lchl8k" > <div - class="addressContainer" + class="encodedData MuiBox-root css-0" + data-testid="tx-hexData" > - <div - class="MuiBox-root css-b5p5gz" - > - <span - aria-label="Copy to clipboard" - class="" - data-mui-internal-clone-element="true" - style="cursor: pointer;" - > - <span> - 0x7996...aC52 - </span> - </span> - </div> <span aria-label="Copy to clipboard" class="" @@ -973,65 +1592,12 @@ exports[`ConfirmationView should display a confirmation with method call when th /> </button> </span> - <div - class="MuiBox-root css-yjghm1" - /> + </div> </div> </div> </div> </div> - <div - class="MuiBox-root css-164r41r" - > - <div - class="MuiGrid-root MuiGrid-container css-86qz5o-MuiGrid-root" - > - <div - class="MuiGrid-root MuiGrid-item css-1wehkfy-MuiGrid-root" - data-testid="tx-row-title" - style="word-break: break-word;" - > - <p - class="MuiTypography-root MuiTypography-body1 css-shf88x-MuiTypography-root" - > - Raw data: - </p> - </div> - <div - class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-true css-1vd824g-MuiGrid-root" - data-testid="tx-data-row" - > - <div - class="encodedData MuiBox-root css-0" - data-testid="tx-hexData" - > - <span - aria-label="Copy to clipboard" - class="" - data-mui-internal-clone-element="true" - style="cursor: pointer;" - > - <button - aria-label="Copy to clipboard" - class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-xyrzjn-MuiButtonBase-root-MuiIconButton-root" - tabindex="0" - type="button" - > - <mock-icon - aria-hidden="" - class="MuiSvgIcon-root MuiSvgIcon-colorBorder MuiSvgIcon-fontSizeSmall css-gvpe62-MuiSvgIcon-root" - data-testid="copy-btn-icon" - focusable="false" - /> - </button> - </span> - 0x - - </div> - </div> - </div> - </div> </div> </div> </div> diff --git a/apps/web/src/components/tx/confirmation-views/index.tsx b/apps/web/src/components/tx/confirmation-views/index.tsx index 2a80b18587..26749e46f1 100644 --- a/apps/web/src/components/tx/confirmation-views/index.tsx +++ b/apps/web/src/components/tx/confirmation-views/index.tsx @@ -1,26 +1,28 @@ import type { TransactionPreview } from '@safe-global/safe-gateway-typescript-sdk' import { type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' -import DecodedTx from '../DecodedTx' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction } from '@safe-global/types-kit' import { isAnyStakingTxInfo, + isBridgeOrderTxInfo, isCustomTxInfo, isExecTxData, + isLifiSwapTxInfo, isOnChainConfirmationTxData, - isOrderTxInfo, - isSafeToL2MigrationTxData, + isOnChainSignMessageTxData, + isSafeMigrationTxData, isSafeUpdateTxData, isSwapOrderTxInfo, isTwapOrderTxInfo, + isVaultDepositTxInfo, + isVaultRedeemTxInfo, } from '@/utils/transaction-guards' -import { type ReactNode, useContext, useMemo } from 'react' -import TxData from '@/components/transactions/TxDetails/TxData' +import { type ReactNode, useContext, useMemo, useRef, useState, useEffect } from 'react' import type { NarrowConfirmationViewProps } from './types' import SettingsChange from './SettingsChange' import ChangeThreshold from './ChangeThreshold' import BatchTransactions from './BatchTransactions' import { TxModalContext } from '@/components/tx-flow' -import { isSettingsChangeView, isChangeThresholdView, isConfirmBatchView } from './utils' +import { isSettingsChangeView, isChangeThresholdView, isConfirmBatchView, isManageSignersView } from './utils' import { OnChainConfirmation } from '@/components/transactions/TxDetails/TxData/NestedTransaction/OnChainConfirmation' import { ExecTransaction } from '@/components/transactions/TxDetails/TxData/NestedTransaction/ExecTransaction' import { type ReactElement } from 'react' @@ -28,17 +30,30 @@ import SwapOrder from './SwapOrder' import StakingTx from './StakingTx' import UpdateSafe from './UpdateSafe' import { MigrateToL2Information } from './MigrateToL2Information' +import { NestedSafeCreation } from './NestedSafeCreation' +import { isNestedSafeCreation } from '@/utils/nested-safes' +import VaultDepositConfirmation from 'src/features/earn/components/VaultDepositConfirmation' +import VaultRedeemConfirmation from '@/features/earn/components/VaultRedeemConfirmation' +import Summary from '@/components/transactions/TxDetails/Summary' +import TxData from '@/components/transactions/TxDetails/TxData' +import { isMultiSendCalldata } from '@/utils/transaction-calldata' +import useChainId from '@/hooks/useChainId' +import { ManageSigners } from './ManageSigners' +import { TransactionWarnings } from '../TransactionWarnings' +import { Box } from '@mui/material' +import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData' +import BridgeTransaction from './BridgeTransaction' +import { LifiSwapTransaction } from './LifiSwapTransaction' type ConfirmationViewProps = { txDetails?: TransactionDetails txPreview?: TransactionPreview safeTx?: SafeTransaction - txId?: string isBatch?: boolean isApproval?: boolean isCreation?: boolean - showMethodCall?: boolean // @TODO: remove this prop when we migrate all tx types children?: ReactNode + withDecodedData?: boolean } const getConfirmationViewComponent = ({ @@ -46,11 +61,17 @@ const getConfirmationViewComponent = ({ txData, txFlow, }: NarrowConfirmationViewProps & { txFlow?: ReactElement }) => { + if (txData && isManageSignersView(txInfo, txData)) return <ManageSigners txInfo={txInfo} txData={txData} /> + if (isChangeThresholdView(txInfo)) return <ChangeThreshold txInfo={txInfo} /> if (isConfirmBatchView(txFlow)) return <BatchTransactions /> - if (isSettingsChangeView(txInfo)) return <SettingsChange txInfo={txInfo as SettingsChange} /> + if (isBridgeOrderTxInfo(txInfo)) return <BridgeTransaction txInfo={txInfo} showWarnings /> + + if (isLifiSwapTxInfo(txInfo)) return <LifiSwapTransaction txInfo={txInfo} isPreview={true} /> + + if (isSettingsChangeView(txInfo)) return <SettingsChange txInfo={txInfo} /> if (isOnChainConfirmationTxData(txData)) return <OnChainConfirmation data={txData} isConfirmationView /> @@ -60,19 +81,45 @@ const getConfirmationViewComponent = ({ if (isAnyStakingTxInfo(txInfo)) return <StakingTx txInfo={txInfo} /> - if (isCustomTxInfo(txInfo) && isSafeUpdateTxData(txData)) return <UpdateSafe /> + // @ts-expect-error TODO: Fix these if there is time + if (isVaultDepositTxInfo(txInfo)) return <VaultDepositConfirmation txInfo={txInfo} /> + + // @ts-expect-error TODO: Fix these if there is time + if (isVaultRedeemTxInfo(txInfo)) return <VaultRedeemConfirmation txInfo={txInfo} /> - if (isCustomTxInfo(txInfo) && isSafeToL2MigrationTxData(txData)) { + if (isCustomTxInfo(txInfo) && isSafeUpdateTxData(txData)) return <UpdateSafe txData={txData} /> + + if (isCustomTxInfo(txInfo) && isSafeMigrationTxData(txData)) { return <MigrateToL2Information variant="queue" txData={txData} /> } + if (isCustomTxInfo(txInfo) && txData && isNestedSafeCreation(txData)) { + return <NestedSafeCreation txData={txData} /> + } + return null } -const ConfirmationView = ({ safeTx, txPreview, txDetails, ...props }: ConfirmationViewProps) => { - const { txId } = props +const ConfirmationView = ({ + safeTx, + txPreview, + txDetails, + withDecodedData = true, + ...props +}: ConfirmationViewProps) => { const { txFlow } = useContext(TxModalContext) const details = txDetails ?? txPreview + const chainId = useChainId() + + // Used to check if the decoded data was rendered inside the TxData component + // If it was, we hide the decoded data in the Summary to avoid showing it twice + const decodedDataRef = useRef(null) + const [isDecodedDataVisible, setIsDecodedDataVisible] = useState(false) + + useEffect(() => { + // If decodedDataRef.current is not null, the decoded data was rendered inside the TxData component + setIsDecodedDataVisible(!!decodedDataRef.current) + }, []) const ConfirmationViewComponent = useMemo(() => { return details @@ -85,29 +132,34 @@ const ConfirmationView = ({ safeTx, txPreview, txDetails, ...props }: Confirmati }, [details, txFlow]) const showTxDetails = - txId && - !props.isCreation && - txDetails && - !isCustomTxInfo(txDetails.txInfo) && - !isAnyStakingTxInfo(txDetails.txInfo) && - !isOrderTxInfo(txDetails.txInfo) + details !== undefined && + !isMultiSendCalldata(details.txData?.hexData ?? '0x') && + !isOnChainSignMessageTxData(details?.txData, chainId) return ( <> - {ConfirmationViewComponent || - (showTxDetails && details && ( - <TxData txData={details?.txData} txInfo={details?.txInfo} txDetails={txDetails} imitation={false} trusted /> - ))} + <TransactionWarnings txData={details?.txData} /> + {withDecodedData && + (ConfirmationViewComponent || + (details && showTxDetails && ( + <TxData txData={details?.txData} txInfo={details?.txInfo} txDetails={txDetails} imitation={false} trusted> + <Box ref={decodedDataRef}> + <DecodedData + txData={details.txData} + toInfo={isCustomTxInfo(details.txInfo) ? details.txInfo.to : details.txData?.to} + /> + </Box> + </TxData> + )))} {props.children} - <DecodedTx - tx={safeTx} + <Summary + safeTxData={safeTx?.data} txDetails={txDetails} txData={details?.txData} txInfo={details?.txInfo} - showMultisend={!props.isBatch} - showMethodCall={props.showMethodCall && !ConfirmationViewComponent && !showTxDetails && !props.isApproval} + showDecodedData={!isDecodedDataVisible} /> </> ) diff --git a/apps/web/src/components/tx/confirmation-views/useTxPreview.ts b/apps/web/src/components/tx/confirmation-views/useTxPreview.ts index 17867da67d..4dc4dd26b0 100644 --- a/apps/web/src/components/tx/confirmation-views/useTxPreview.ts +++ b/apps/web/src/components/tx/confirmation-views/useTxPreview.ts @@ -1,6 +1,6 @@ import { Operation, getTxPreview } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import useAsync from '@/hooks/useAsync' +import type { SafeTransaction } from '@safe-global/types-kit' +import useAsync from '@safe-global/utils/hooks/useAsync' import useSafeInfo from '@/hooks/useSafeInfo' const useTxPreview = ( diff --git a/apps/web/src/components/tx/confirmation-views/utils.ts b/apps/web/src/components/tx/confirmation-views/utils.ts index 5aedd30658..e7fa9bdb49 100644 --- a/apps/web/src/components/tx/confirmation-views/utils.ts +++ b/apps/web/src/components/tx/confirmation-views/utils.ts @@ -1,9 +1,51 @@ -import type { ChangeThreshold, SettingsChange, TransactionInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { + ChangeThreshold, + SettingsChange, + TransactionDetails, + TransactionInfo, +} from '@safe-global/safe-gateway-typescript-sdk' import { SettingsInfoType, TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' import { ConfirmBatchFlow } from '@/components/tx-flow/flows' +import { isMultiSendTxInfo } from '@/utils/transaction-guards' +import { + isAddOwnerWithThresholdCalldata, + isChangeThresholdCalldata, + isRemoveOwnerCalldata, + isSwapOwnerCalldata, +} from '@/utils/transaction-calldata' import { type ReactElement } from 'react' -export const isSettingsChangeView = (txInfo: TransactionInfo) => txInfo.type === TransactionInfoType.SETTINGS_CHANGE +const MANAGE_SIGNERS_SETTING_INFO_TYPES = [ + SettingsInfoType.ADD_OWNER, + SettingsInfoType.REMOVE_OWNER, + SettingsInfoType.SWAP_OWNER, + SettingsInfoType.CHANGE_THRESHOLD, +] + +const MANAGE_SIGNERS_CALLDATA_GUARDS = [ + isAddOwnerWithThresholdCalldata, + isRemoveOwnerCalldata, + isSwapOwnerCalldata, + isChangeThresholdCalldata, +] + +export function isManageSignersView(txInfo: TransactionInfo, txData: TransactionDetails['txData']): boolean { + if (txInfo.type === TransactionInfoType.SETTINGS_CHANGE) { + return !!txInfo.settingsInfo && MANAGE_SIGNERS_SETTING_INFO_TYPES.includes(txInfo.settingsInfo.type) + } + + if (isMultiSendTxInfo(txInfo) && txData?.dataDecoded?.parameters?.[0]?.valueDecoded) { + return txData.dataDecoded.parameters[0].valueDecoded.every(({ data }) => { + return data && MANAGE_SIGNERS_CALLDATA_GUARDS.some((guard) => guard(data)) + }) + } + + return false +} + +export const isSettingsChangeView = (txInfo: TransactionInfo): txInfo is SettingsChange => + txInfo.type === TransactionInfoType.SETTINGS_CHANGE && + txInfo.settingsInfo?.type !== SettingsInfoType.SET_FALLBACK_HANDLER export const isConfirmBatchView = (txFlow?: ReactElement) => txFlow?.type === ConfirmBatchFlow diff --git a/apps/web/src/components/tx/security/blockaid/BlockaidBalanceChange.tsx b/apps/web/src/components/tx/security/blockaid/BlockaidBalanceChange.tsx index 4b5d60f37b..d451624666 100644 --- a/apps/web/src/components/tx/security/blockaid/BlockaidBalanceChange.tsx +++ b/apps/web/src/components/tx/security/blockaid/BlockaidBalanceChange.tsx @@ -3,8 +3,7 @@ import TokenIcon from '@/components/common/TokenIcon' import useBalances from '@/hooks/useBalances' import useChainId from '@/hooks/useChainId' import { useHasFeature } from '@/hooks/useChains' -import { sameAddress } from '@/utils/addresses' -import { FEATURES } from '@/utils/chains' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { Box, Chip, CircularProgress, Grid, SvgIcon, Tooltip, Typography } from '@mui/material' import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' import { ErrorBoundary } from '@sentry/react' @@ -26,8 +25,9 @@ import type { Erc721TokenDetails, GeneralAssetDiff, NativeDiff, -} from '@/services/security/modules/BlockaidModule/types' -import { formatAmount } from '@/utils/formatNumber' +} from '@safe-global/utils/services/security/modules/BlockaidModule/types' +import { formatAmount } from '@safe-global/utils/utils/formatNumber' +import { FEATURES } from '@safe-global/utils/utils/chains' const FungibleBalanceChange = ({ change, asset }: { asset: AssetDiff['asset']; change: Erc20Diff | NativeDiff }) => { const { balances } = useBalances() diff --git a/apps/web/src/components/tx/security/blockaid/ContractChangeWarning.tsx b/apps/web/src/components/tx/security/blockaid/ContractChangeWarning.tsx new file mode 100644 index 0000000000..2e29b377bb --- /dev/null +++ b/apps/web/src/components/tx/security/blockaid/ContractChangeWarning.tsx @@ -0,0 +1,53 @@ +import { Box, Stack, Typography } from '@mui/material' +import type { + ModulesChangeManagement, + OwnershipChangeManagement, + ProxyUpgradeManagement, +} from '@safe-global/utils/services/security/modules/BlockaidModule/types' +import { SecuritySeverity } from '@safe-global/utils/services/security/modules/types' +import { mapSecuritySeverity } from '../utils' +import EthHashInfo from '@/components/common/EthHashInfo' +import { Warning } from '.' +import { CONTRACT_CHANGE_TITLES_MAPPING } from '@safe-global/utils/components/tx/security/blockaid/utils' + +const ProxyUpgradeSummary = ({ beforeAddress, afterAddress }: { beforeAddress: string; afterAddress: string }) => { + return ( + <Stack direction="column" spacing={0.5}> + <Typography variant="body2" sx={{ marginBottom: 'var(--space-2) !important' }}> + Please verify that this change is intended and correct as it may overwrite the ownership of your account + </Typography> + <Typography variant="overline">Current mastercopy:</Typography> + <Box sx={{ padding: '6px 12px', borderRadius: '6px', backgroundColor: 'background.paper' }}> + <EthHashInfo address={beforeAddress} showCopyButton hasExplorer shortAddress={false} showAvatar={false} /> + </Box> + + <Typography variant="overline">New mastercopy:</Typography> + <Box sx={{ padding: '6px 12px', borderRadius: '6px', backgroundColor: 'background.paper' }}> + <EthHashInfo address={afterAddress} showCopyButton hasExplorer shortAddress={false} showAvatar={false} /> + </Box> + </Stack> + ) +} + +export const ContractChangeWarning = ({ + contractChange, +}: { + contractChange: ProxyUpgradeManagement | OwnershipChangeManagement | ModulesChangeManagement +}) => { + const title = CONTRACT_CHANGE_TITLES_MAPPING[contractChange.type] + const severityProps = mapSecuritySeverity[SecuritySeverity.MEDIUM] + const { before, after, type } = contractChange + const isProxyUpgrade = type === 'PROXY_UPGRADE' + + const warningContent = ( + <> + {isProxyUpgrade ? ( + <ProxyUpgradeSummary beforeAddress={before.address} afterAddress={after.address} /> + ) : ( + <Typography variant="body2">Please verify that this change is intended and correct.</Typography> + )} + </> + ) + + return <Warning title={title} severityProps={severityProps} content={warningContent} isTransaction={true} /> +} diff --git a/apps/web/src/components/tx/security/blockaid/__tests__/useBlockaid.test.ts b/apps/web/src/components/tx/security/blockaid/__tests__/useBlockaid.test.ts index f8b3fc81d1..8e43a42336 100644 --- a/apps/web/src/components/tx/security/blockaid/__tests__/useBlockaid.test.ts +++ b/apps/web/src/components/tx/security/blockaid/__tests__/useBlockaid.test.ts @@ -1,17 +1,20 @@ import * as useChains from '@/hooks/useChains' import * as useWallet from '@/hooks/wallets/useWallet' -import { SecuritySeverity } from '@/services/security/modules/types' +import { SecuritySeverity } from '@safe-global/utils/services/security/modules/types' import { eip712TypedDataBuilder } from '@/tests/builders/messages' import { safeTxBuilder } from '@/tests/builders/safeTx' import { parseUnits, toBeHex } from 'ethers' import { useBlockaid } from '../useBlockaid' -import { type AssetDiff, type TransactionScanResponse } from '@/services/security/modules/BlockaidModule/types' +import { + type AssetDiff, + type TransactionScanResponse, +} from '@safe-global/utils/services/security/modules/BlockaidModule/types' import { faker } from '@faker-js/faker/locale/af_ZA' import useSafeInfo from '@/hooks/useSafeInfo' import { safeInfoBuilder } from '@/tests/builders/safe' -import { CLASSIFICATION_MAPPING, REASON_MAPPING } from '..' import { renderHook, waitFor } from '@/tests/test-utils' import { type SignerWallet } from '@/components/common/WalletProvider' +import { CLASSIFICATION_MAPPING, REASON_MAPPING } from '@safe-global/utils/components/tx/security/blockaid/utils' const setupFetchStub = (data: any) => () => { return Promise.resolve({ @@ -22,8 +25,8 @@ const setupFetchStub = (data: any) => () => { } // Mock BLOCKAID_API -jest.mock('@/config/constants', () => ({ - ...jest.requireActual('@/config/constants'), +jest.mock('@safe-global/utils/config/constants', () => ({ + ...jest.requireActual('@safe-global/utils/config/constants'), BLOCKAID_CLIENT_ID: 'some-client-id', })) @@ -112,13 +115,13 @@ describe.each([TEST_CASES.MESSAGE, TEST_CASES.TRANSACTION])('useBlockaid for %s' jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true) const mockFetch = jest.spyOn(global, 'fetch') - mockFetch.mockImplementation(() => Promise.reject({ message: '403 not authorized' })) + mockFetch.mockImplementation(() => Promise.reject(new Error('403 not authorized'))) const { result } = renderHook(() => useBlockaid(mockPayload)) await waitFor(() => { expect(result.current[0]).toBeUndefined() - expect(result.current[1]).toEqual(new Error('Unavailable')) + expect(result.current[1]).toEqual(new Error('403 not authorized')) expect(result.current[2]).toBeFalsy() }) }) @@ -158,7 +161,7 @@ describe.each([TEST_CASES.MESSAGE, TEST_CASES.TRANSACTION])('useBlockaid for %s' await waitFor(() => { expect(result.current[0]).toBeDefined() - expect(result.current[1]).toEqual(new Error('Simulation failed')) + expect(result.current[1]).toEqual(new Error('Simulation failed: GS13')) expect(result.current[2]).toBeFalsy() }) }) diff --git a/apps/web/src/components/tx/security/blockaid/index.tsx b/apps/web/src/components/tx/security/blockaid/index.tsx index 3a61000bd8..a6d5d8f98e 100644 --- a/apps/web/src/components/tx/security/blockaid/index.tsx +++ b/apps/web/src/components/tx/security/blockaid/index.tsx @@ -1,8 +1,8 @@ +import type { ReactNode } from 'react' import { useContext } from 'react' -import { TxSecurityContext, type TxSecurityContextProps } from '@/components/tx/security/shared/TxSecurityContext' +import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext' import groupBy from 'lodash/groupBy' import { Alert, AlertTitle, Box, Checkbox, FormControlLabel, Stack, Typography } from '@mui/material' -import { FEATURES } from '@/utils/chains' import { useHasFeature } from '@/hooks/useChains' import { ErrorBoundary } from '@sentry/react' import css from './styles.module.css' @@ -14,113 +14,53 @@ import BlockaidIcon from '@/public/images/transactions/blockaid-icon.svg' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import { type SecurityWarningProps, mapSecuritySeverity } from '../utils' import { BlockaidHint } from './BlockaidHint' -import { SecuritySeverity } from '@/services/security/modules/types' - -export const REASON_MAPPING: Record<string, string> = { - raw_ether_transfer: 'transfers native currency', - signature_farming: 'is a raw signed transaction', - transfer_farming: 'transfers tokens', - approval_farming: 'approves erc20 tokens', - set_approval_for_all: 'approves all tokens of the account', - permit_farming: 'authorizes access or permissions', - seaport_farming: 'authorizes transfer of assets via Opeansea marketplace', - blur_farming: 'authorizes transfer of assets via Blur marketplace', - delegatecall_execution: 'involves a delegate call', -} - -export const CLASSIFICATION_MAPPING: Record<string, string> = { - known_malicious: 'to a known malicious address', - unverified_contract: 'to an unverified contract', - new_address: 'to a new address', - untrusted_address: 'to an untrusted address', - address_poisoning: 'to a poisoned address', - losing_mint: 'resulting in a mint for a new token with a significantly higher price than the known price', - losing_assets: 'resulting in a loss of assets without any compensation', - losing_trade: 'resulting in a losing trade', - drainer_contract: 'to a known drainer contract', - user_mistake: 'resulting in a loss of assets due to an innocent mistake', - gas_farming_attack: 'resulting in a waste of the account address’ gas to generate tokens for a scammer', - other: 'resulting in a malicious outcome', -} - -const BlockaidResultWarning = ({ - blockaidResponse, +import { ContractChangeWarning } from './ContractChangeWarning' +import { FEATURES } from '@safe-global/utils/utils/chains' +import { CLASSIFICATION_MAPPING, REASON_MAPPING } from '@safe-global/utils/components/tx/security/blockaid/utils' +import ErrorMessage from '../../ErrorMessage' + +export const Warning = ({ + title, + content, severityProps, - needsRiskConfirmation, - isRiskConfirmed, - isTransaction, + needsRiskConfirmation = false, + isRiskConfirmed = true, + isTransaction = true, toggleConfirmation, }: { - blockaidResponse?: TxSecurityContextProps['blockaidResponse'] + title: ReactNode + content: ReactNode severityProps?: SecurityWarningProps - needsRiskConfirmation: boolean - isRiskConfirmed: boolean - isTransaction: boolean - toggleConfirmation: () => void + needsRiskConfirmation?: boolean + isRiskConfirmed?: boolean + isTransaction?: boolean + toggleConfirmation?: () => void }) => { return ( <Box> - {blockaidResponse && blockaidResponse.severity !== SecuritySeverity.NONE && ( - <> - <Alert - severity={severityProps?.color} - className={css.customAlert} - sx={ - needsRiskConfirmation - ? { - borderBottomLeftRadius: '0px', - borderBottomRightRadius: '0px', - } - : undefined - } - > - <AlertTitle fontWeight="700 !important" mb={1}> - <ResultDescription - classification={blockaidResponse.classification} - reason={blockaidResponse.reason} - description={blockaidResponse.description} - /> - </AlertTitle> - <BlockaidMessage /> - </Alert> - {needsRiskConfirmation && ( - <Box - className={css.riskConfirmationBlock} - sx={{ - pl: 2, - }} - > - <Track {...MODALS_EVENTS.ACCEPT_RISK}> - <FormControlLabel - label={ - <Typography variant="body2" color="static.main"> - I understand the risks and would like to sign this {isTransaction ? 'transaction' : 'message'} - </Typography> - } - control={<Checkbox checked={isRiskConfirmed} onChange={toggleConfirmation} color="primary" />} - /> - </Track> - </Box> - )} - <Stack - direction="row" - spacing={0.5} - sx={{ - alignItems: 'center', - mt: 1, - }} - > - <Typography - variant="caption" - sx={{ - color: 'text.secondary', - }} - > - Powered by - </Typography> - <BlockaidIcon /> - </Stack> - </> + <Alert + severity={severityProps?.color} + className={css.customAlert} + sx={needsRiskConfirmation ? { borderBottomLeftRadius: '0px', borderBottomRightRadius: '0px' } : undefined} + > + <AlertTitle fontWeight="700 !important" mb={1}> + {title} + </AlertTitle> + {content} + </Alert> + {needsRiskConfirmation && ( + <Box className={css.riskConfirmationBlock} sx={{ pl: 2 }}> + <Track {...MODALS_EVENTS.ACCEPT_RISK}> + <FormControlLabel + label={ + <Typography variant="body2" color="static.main"> + I understand the risks and would like to sign this {isTransaction ? 'transaction' : 'message'} + </Typography> + } + control={<Checkbox checked={isRiskConfirmed} onChange={toggleConfirmation} color="primary" />} + /> + </Track> + </Box> )} </Box> ) @@ -155,24 +95,14 @@ const ResultDescription = ({ ) } -const BlockaidError = () => { +const BlockaidError = ({ error }: { error: Error }) => { return ( - <Alert severity="warning" className={css.customAlert}> - <AlertTitle> - <Typography - variant="subtitle1" - sx={{ - fontWeight: 700, - }} - > - Proceed with caution - </Typography> - </AlertTitle> + <ErrorMessage level="warning" className={css.customAlert} title="Proceed with caution" error={error}> <Typography variant="body2"> The transaction could not be checked for security alerts. Verify the details and addresses before proceeding. </Typography> <BlockaidMessage /> - </Alert> + </ErrorMessage> ) } @@ -190,7 +120,7 @@ export const Blockaid = () => { ) } -const BlockaidWarning = () => { +export const BlockaidWarning = () => { const { blockaidResponse, setIsRiskConfirmed, needsRiskConfirmation, isRiskConfirmed } = useContext(TxSecurityContext) const { severity, isLoading, error } = blockaidResponse ?? {} @@ -206,25 +136,57 @@ const BlockaidWarning = () => { } if (error) { - return <BlockaidError /> + return <BlockaidError error={error} /> } - if (isLoading || !blockaidResponse || !blockaidResponse.severity) { + if (isLoading || !blockaidResponse) { return null } return ( - <BlockaidResultWarning - isRiskConfirmed={isRiskConfirmed} - isTransaction={isTransaction} - needsRiskConfirmation={needsRiskConfirmation} - toggleConfirmation={toggleConfirmation} - blockaidResponse={blockaidResponse} - severityProps={severityProps} - /> + <> + {!!blockaidResponse.severity ? ( + <Box> + <Warning + isRiskConfirmed={isRiskConfirmed} + isTransaction={isTransaction} + needsRiskConfirmation={needsRiskConfirmation} + toggleConfirmation={toggleConfirmation} + title={ + <ResultDescription + classification={blockaidResponse.classification} + reason={blockaidResponse.reason} + description={blockaidResponse.description} + /> + } + content={<BlockaidMessage />} + severityProps={severityProps} + /> + <PoweredByBlockaid /> + </Box> + ) : blockaidResponse?.contractManagement && blockaidResponse.contractManagement.length > 0 ? ( + <Box> + <Stack direction="column" spacing={1}> + {blockaidResponse?.contractManagement.map((contractChange) => ( + <ContractChangeWarning key={contractChange.type} contractChange={contractChange} /> + ))} + </Stack> + <PoweredByBlockaid /> + </Box> + ) : null} + </> ) } +const PoweredByBlockaid = () => ( + <Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', mt: 1 }}> + <Typography variant="caption" sx={{ color: 'text.secondary' }}> + Powered by + </Typography> + <BlockaidIcon /> + </Stack> +) + export const BlockaidMessage = () => { const { blockaidResponse } = useContext(TxSecurityContext) if (!blockaidResponse) { diff --git a/apps/web/src/components/tx/security/blockaid/useBlockaid.ts b/apps/web/src/components/tx/security/blockaid/useBlockaid.ts index 5d1768f618..4945b416be 100644 --- a/apps/web/src/components/tx/security/blockaid/useBlockaid.ts +++ b/apps/web/src/components/tx/security/blockaid/useBlockaid.ts @@ -1,22 +1,24 @@ -import useAsync, { type AsyncResult } from '@/hooks/useAsync' +import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' +import useAsync, { type AsyncResult } from '@safe-global/utils/hooks/useAsync' import { useHasFeature } from '@/hooks/useChains' import useSafeInfo from '@/hooks/useSafeInfo' import { useSigner } from '@/hooks/wallets/useWallet' import { MODALS_EVENTS, trackEvent } from '@/services/analytics' -import type { SecurityResponse } from '@/services/security/modules/types' -import { FEATURES } from '@/utils/chains' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import { useEffect, useMemo } from 'react' +import type { SecurityResponse } from '@safe-global/utils/services/security/modules/types' +import type { SafeTransaction } from '@safe-global/types-kit' -import type { EIP712TypedData } from '@safe-global/safe-gateway-typescript-sdk' -import { BlockaidModule, type BlockaidModuleResponse } from '@/services/security/modules/BlockaidModule' +import { useEffect, useMemo } from 'react' +import { + BlockaidModule, + type BlockaidModuleResponse, +} from '@safe-global/utils/services/security/modules/BlockaidModule' +import { FEATURES } from '@safe-global/utils/utils/chains' +import { Errors, logError } from '@/services/exceptions' const BlockaidModuleInstance = new BlockaidModule() -const DEFAULT_ERROR_MESSAGE = 'Unavailable' - export const useBlockaid = ( - data: SafeTransaction | EIP712TypedData | undefined, + data: SafeTransaction | TypedData | undefined, origin?: string, ): AsyncResult<SecurityResponse<BlockaidModuleResponse>> => { const { safe, safeAddress } = useSafeInfo() @@ -50,10 +52,11 @@ export const useBlockaid = ( } }, [loading, blockaidPayload]) - const errorMsg = useMemo( - () => (blockaidErrors ? new Error(DEFAULT_ERROR_MESSAGE) : blockaidPayload?.payload?.error), + const errorMsg = useMemo(() => blockaidErrors ?? blockaidPayload?.payload?.error, [blockaidErrors, blockaidPayload]) + + useEffect(() => { + logError(Errors._201, errorMsg) + }, [errorMsg]) - [blockaidErrors, blockaidPayload], - ) return [blockaidPayload, errorMsg, loading] } diff --git a/apps/web/src/components/tx/security/shared/TxSecurityContext.tsx b/apps/web/src/components/tx/security/shared/TxSecurityContext.tsx index 0803a09fd4..0d25fa00fa 100644 --- a/apps/web/src/components/tx/security/shared/TxSecurityContext.tsx +++ b/apps/web/src/components/tx/security/shared/TxSecurityContext.tsx @@ -1,54 +1,9 @@ -import { SecuritySeverity } from '@/services/security/modules/types' +import { SecuritySeverity } from '@safe-global/utils/services/security/modules/types' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' -import { - createContext, - type Dispatch, - type SetStateAction, - useContext, - useMemo, - useState, - type ReactElement, -} from 'react' -import type { BlockaidModuleResponse } from '@/services/security/modules/BlockaidModule' +import { createContext, type ReactElement, useContext, useMemo, useState } from 'react' import { useBlockaid } from '../blockaid/useBlockaid' - -export const defaultSecurityContextValues = { - blockaidResponse: { - warnings: [], - description: undefined, - classification: undefined, - reason: undefined, - balanceChange: undefined, - severity: SecuritySeverity.NONE, - isLoading: false, - error: undefined, - }, - needsRiskConfirmation: false, - isRiskConfirmed: false, - setIsRiskConfirmed: () => {}, - isRiskIgnored: false, - setIsRiskIgnored: () => {}, -} - -export type TxSecurityContextProps = { - blockaidResponse: - | { - description: BlockaidModuleResponse['description'] - classification: BlockaidModuleResponse['classification'] - reason: BlockaidModuleResponse['reason'] - warnings: NonNullable<BlockaidModuleResponse['issues']> - balanceChange: BlockaidModuleResponse['balanceChange'] | undefined - severity: SecuritySeverity | undefined - isLoading: boolean - error: Error | undefined - } - | undefined - needsRiskConfirmation: boolean - isRiskConfirmed: boolean - setIsRiskConfirmed: Dispatch<SetStateAction<boolean>> - isRiskIgnored: boolean - setIsRiskIgnored: Dispatch<SetStateAction<boolean>> -} +import { defaultSecurityContextValues } from '@safe-global/utils/components/tx/security/shared/utils' +import { type TxSecurityContextProps } from '@safe-global/utils/components/tx/security/shared/types' export const TxSecurityContext = createContext<TxSecurityContextProps>(defaultSecurityContextValues) @@ -68,6 +23,7 @@ export const TxSecurityProvider = ({ children }: { children: ReactElement }) => severity: blockaidResponse?.severity, warnings: blockaidResponse?.payload?.issues || [], balanceChange: blockaidResponse?.payload?.balanceChange, + contractManagement: blockaidResponse?.payload?.contractManagement, error: blockaidError, isLoading: blockaidLoading, }, diff --git a/apps/web/src/components/tx/security/tenderly/__tests__/useSimulation.test.ts b/apps/web/src/components/tx/security/tenderly/__tests__/useSimulation.test.ts index 3e48b678e3..ecceaff275 100644 --- a/apps/web/src/components/tx/security/tenderly/__tests__/useSimulation.test.ts +++ b/apps/web/src/components/tx/security/tenderly/__tests__/useSimulation.test.ts @@ -4,7 +4,7 @@ import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { renderHook, waitFor } from '@/tests/test-utils' import { useSimulation } from '@/components/tx/security/tenderly/useSimulation' import * as utils from '@/components/tx/security/tenderly/utils' -import { FETCH_STATUS, type TenderlySimulation } from '@/components/tx/security/tenderly/types' +import { FETCH_STATUS, type TenderlySimulation } from '@safe-global/utils/components/tx/security/tenderly/types' const setupFetchStub = (data: any) => () => { return Promise.resolve({ diff --git a/apps/web/src/components/tx/security/tenderly/__tests__/utils.test.ts b/apps/web/src/components/tx/security/tenderly/__tests__/utils.test.ts index a18f94562e..0f8d531f09 100644 --- a/apps/web/src/components/tx/security/tenderly/__tests__/utils.test.ts +++ b/apps/web/src/components/tx/security/tenderly/__tests__/utils.test.ts @@ -1,11 +1,7 @@ -import type { MetaTransactionData, SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { MetaTransactionData, SafeTransaction } from '@safe-global/types-kit' import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { zeroPadValue, Interface } from 'ethers' -import { - getSimulationPayload, - NONCE_STORAGE_POSITION, - THRESHOLD_STORAGE_POSITION, -} from '@/components/tx/security/tenderly/utils' +import { getSimulationPayload } from '@/components/tx/security/tenderly/utils' import * as safeContracts from '@/services/contracts/safeContracts' import { getMultiSendCallOnlyDeployment, getSafeSingletonDeployment } from '@safe-global/safe-deployments' import EthSafeTransaction from '@safe-global/protocol-kit/dist/src/utils/transactions/SafeTransaction' @@ -13,6 +9,10 @@ import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants import { generatePreValidatedSignature } from '@safe-global/protocol-kit/dist/src/utils/signatures' import { toBeHex } from 'ethers' import * as Web3 from '@/hooks/wallets/web3' +import { + NONCE_STORAGE_POSITION, + THRESHOLD_STORAGE_POSITION, +} from '@safe-global/utils/components/tx/security/tenderly/utils' const SIGNATURE_LENGTH = 65 * 2 diff --git a/apps/web/src/components/tx/security/tenderly/index.tsx b/apps/web/src/components/tx/security/tenderly/index.tsx index b6641be266..30ba1f3a38 100644 --- a/apps/web/src/components/tx/security/tenderly/index.tsx +++ b/apps/web/src/components/tx/security/tenderly/index.tsx @@ -1,5 +1,5 @@ import useIsSafeOwner from '@/hooks/useIsSafeOwner' -import { Alert, Button, Paper, SvgIcon, Tooltip, Typography } from '@mui/material' +import { Alert, Box, Button, Paper, SvgIcon, Tooltip, Typography } from '@mui/material' import { useContext, useEffect } from 'react' import type { ReactElement } from 'react' @@ -11,8 +11,10 @@ import { useDarkMode } from '@/hooks/useDarkMode' import CircularProgress from '@mui/material/CircularProgress' import ExternalLink from '@/components/common/ExternalLink' import { useCurrentChain } from '@/hooks/useChains' -import { isTxSimulationEnabled } from '@/components/tx/security/tenderly/utils' -import type { SimulationTxParams } from '@/components/tx/security/tenderly/utils' +import { + isTxSimulationEnabled, + type SimulationTxParams, +} from '@safe-global/utils/components/tx/security/tenderly/utils' import css from './styles.module.css' import sharedCss from '@/components/tx/security/shared/styles.module.css' @@ -21,131 +23,170 @@ import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import InfoIcon from '@/public/images/notifications/info.svg' import Track from '@/components/common/Track' import { MODALS_EVENTS } from '@/services/analytics' +import useAsync from '@safe-global/utils/hooks/useAsync' +import { getSafeInfo } from '@safe-global/safe-gateway-typescript-sdk' export type TxSimulationProps = { transactions?: SimulationTxParams['transactions'] gasLimit?: number disabled: boolean executionOwner?: string + title?: string + nestedSafe?: string } // TODO: Investigate resetting on gasLimit change as we are not simulating with the gasLimit of the tx // otherwise remove all usage of gasLimit in simulation. Note: this was previously being done. // TODO: Test this component -const TxSimulationBlock = ({ transactions, disabled, gasLimit, executionOwner }: TxSimulationProps): ReactElement => { +const TxSimulationBlock = ({ + transactions, + disabled, + gasLimit, + executionOwner, + nestedSafe, + title = 'Run a simulation', +}: TxSimulationProps): ReactElement => { const { safe } = useSafeInfo() + const chain = useCurrentChain() const signer = useSigner() const isSafeOwner = useIsSafeOwner() const isDarkMode = useDarkMode() const { safeTx } = useContext(SafeTxContext) const { simulation: { simulateTransaction, resetSimulation }, - status: { isFinished, isError, isSuccess, isCallTraceError, isLoading }, + status, + nestedTx, } = useContext(TxInfoContext) + const [nestedSafeInfo] = useAsync( + () => (!!chain && !!nestedSafe ? getSafeInfo(chain.chainId, nestedSafe) : undefined), + [chain, nestedSafe], + ) + const handleSimulation = async () => { - if (!signer) { + if (!signer || !transactions) { return } - simulateTransaction({ - safe, + const simulationTxParams = { + safe: nestedSafeInfo ?? safe, // fall back to the first owner of the safe in case the transaction is created by a proposer - executionOwner: (executionOwner ?? isSafeOwner) ? signer.address : safe.owners[0].value, + executionOwner: executionOwner ?? (isSafeOwner ? signer.address : safe.owners[0].value), transactions, gasLimit, - } as SimulationTxParams) + } as SimulationTxParams + + if (!!nestedSafe) { + nestedTx.simulation.simulateTransaction(simulationTxParams) + } else { + simulateTransaction(simulationTxParams) + } } + const { isFinished, isError, isSuccess, isCallTraceError, isLoading } = !!nestedSafe ? nestedTx.status : status + // Reset simulation if safeTx changes useEffect(() => { resetSimulation() }, [safeTx, resetSimulation]) return ( - <Paper variant="outlined" className={sharedCss.wrapper}> - <div className={css.wrapper}> - <Typography variant="body2" fontWeight={700}> - Run a simulation - <Tooltip - title="This transaction can be simulated before execution to ensure that it will be succeed, generating a detailed report of the transaction execution." - arrow - placement="top" - > - <span> - <SvgIcon - component={InfoIcon} - inheritViewBox - color="border" - fontSize="small" - sx={{ - verticalAlign: 'middle', - ml: 0.5, - }} - /> - </span> - </Tooltip> - </Typography> - <Typography variant="caption" className={sharedCss.poweredBy}> - Powered by{' '} - <img - src={isDarkMode ? '/images/transactions/tenderly-light.svg' : '/images/transactions/tenderly-dark.svg'} - alt="Tenderly" - width="65px" - height="15px" - /> - </Typography> - </div> - - <div className={sharedCss.result}> - {isLoading ? ( - <CircularProgress - size={22} - sx={{ - color: ({ palette }) => palette.text.secondary, - }} - /> - ) : isFinished ? ( - !isSuccess || isError || isCallTraceError ? ( - <Typography - variant="body2" - className={sharedCss.result} - sx={{ - color: 'error.main', - }} + <Box> + <Paper variant="outlined" className={sharedCss.wrapper} sx={{ backgroundColor: 'transparent' }}> + <div className={css.wrapper}> + <Typography variant="body2" fontWeight={700}> + {title} + + <Tooltip + title="This transaction can be simulated before execution to ensure that it will be succeed, generating a detailed report of the transaction execution." + arrow + placement="top" > - <SvgIcon component={CloseIcon} inheritViewBox fontSize="small" sx={{ verticalAlign: 'middle', mr: 1 }} /> - Error - </Typography> - ) : ( - <Typography - data-testid="simulation-success-msg" - variant="body2" - className={sharedCss.result} + <span> + <SvgIcon + component={InfoIcon} + inheritViewBox + color="border" + fontSize="small" + sx={{ + verticalAlign: 'middle', + ml: 0.5, + }} + /> + </span> + </Tooltip> + </Typography> + <Typography variant="caption" className={sharedCss.poweredBy}> + Powered by{' '} + <img + src={isDarkMode ? '/images/transactions/tenderly-light.svg' : '/images/transactions/tenderly-dark.svg'} + alt="Tenderly" + width="65px" + height="15px" + /> + </Typography> + </div> + + <div className={sharedCss.result}> + {isLoading ? ( + <CircularProgress + size={22} sx={{ - color: 'success.main', + color: ({ palette }) => palette.text.secondary, }} - > - <SvgIcon component={CheckIcon} inheritViewBox fontSize="small" sx={{ verticalAlign: 'middle', mr: 1 }} /> - Success - </Typography> - ) - ) : ( - <Track {...MODALS_EVENTS.SIMULATE_TX}> - <Button - data-testid="simulate-btn" - variant="outlined" - size="small" - className={css.simulate} - onClick={handleSimulation} - disabled={!transactions || disabled} - > - Simulate - </Button> - </Track> - )} - </div> - </Paper> + /> + ) : isFinished ? ( + !isSuccess || isError || isCallTraceError ? ( + <Typography + variant="body2" + className={sharedCss.result} + sx={{ + color: 'error.main', + }} + > + <SvgIcon + component={CloseIcon} + inheritViewBox + fontSize="small" + sx={{ verticalAlign: 'middle', mr: 1 }} + /> + Error + </Typography> + ) : ( + <Typography + data-testid="simulation-success-msg" + variant="body2" + className={sharedCss.result} + sx={{ + color: 'success.main', + }} + > + <SvgIcon + component={CheckIcon} + inheritViewBox + fontSize="small" + sx={{ verticalAlign: 'middle', mr: 1 }} + /> + Success + </Typography> + ) + ) : ( + <Track {...MODALS_EVENTS.SIMULATE_TX}> + <Button + data-testid="simulate-btn" + variant="outlined" + size="small" + className={css.simulate} + onClick={handleSimulation} + disabled={!transactions || disabled} + > + Simulate + </Button> + </Track> + )} + </div> + </Paper> + </Box> ) } @@ -160,11 +201,11 @@ export const TxSimulation = (props: TxSimulationProps): ReactElement | null => { } // TODO: Test this component -export const TxSimulationMessage = () => { - const { - simulation: { simulationLink, simulation, requestError }, - status: { isError, isSuccess, isCallTraceError, isFinished }, - } = useContext(TxInfoContext) +export const TxSimulationMessage = ({ isNested = false }: { isNested?: boolean }) => { + const txInfo = useContext(TxInfoContext) + + const { isFinished, isError, isSuccess, isCallTraceError } = isNested ? txInfo.nestedTx.status : txInfo.status + const { simulationLink, simulation, requestError } = isNested ? txInfo.nestedTx.simulation : txInfo.simulation if (!isFinished) { return null @@ -173,15 +214,15 @@ export const TxSimulationMessage = () => { if (!isSuccess || isError || isCallTraceError) { return ( <Alert severity="error" sx={{ border: 'unset' }}> - <Typography variant="body2" fontWeight={700}> + <Typography variant="body1" fontWeight={700}> Simulation failed </Typography> {requestError ? ( - <Typography color="error"> + <Typography color="error" variant="body2"> An unexpected error occurred during simulation: <b>{requestError}</b>. </Typography> ) : ( - <Typography> + <Typography variant="body2"> {isCallTraceError ? ( <>The transaction failed during the simulation.</> ) : ( diff --git a/apps/web/src/components/tx/security/tenderly/useSimulation.ts b/apps/web/src/components/tx/security/tenderly/useSimulation.ts index b957d3d3d1..668cf7832c 100644 --- a/apps/web/src/components/tx/security/tenderly/useSimulation.ts +++ b/apps/web/src/components/tx/security/tenderly/useSimulation.ts @@ -1,29 +1,17 @@ import { useCallback, useMemo, useState } from 'react' -import { getSimulation, getSimulationLink } from '@/components/tx/security/tenderly/utils' -import { FETCH_STATUS, type TenderlySimulation } from '@/components/tx/security/tenderly/types' -import { getSimulationPayload, type SimulationTxParams } from '@/components/tx/security/tenderly/utils' +import { getSimulationPayload } from '@/components/tx/security/tenderly/utils' +import { FETCH_STATUS, type TenderlySimulation } from '@safe-global/utils/components/tx/security/tenderly/types' import { useAppSelector } from '@/store' import { selectTenderly } from '@/store/settingsSlice' -import { asError } from '@/services/exceptions/utils' - -export type UseSimulationReturn = - | { - _simulationRequestStatus: FETCH_STATUS.NOT_ASKED | FETCH_STATUS.ERROR | FETCH_STATUS.LOADING - simulation: undefined - simulateTransaction: (params: SimulationTxParams) => void - simulationLink: string - requestError?: string - resetSimulation: () => void - } - | { - _simulationRequestStatus: FETCH_STATUS.SUCCESS - simulation: TenderlySimulation - simulateTransaction: (params: SimulationTxParams) => void - simulationLink: string - requestError?: string - resetSimulation: () => void - } +import { asError } from '@safe-global/utils/services/exceptions/utils' +import { type UseSimulationReturn } from '@safe-global/utils/components/tx/security/tenderly/useSimulation' +import { + getSimulation, + getSimulationLink, + type SimulationTxParams, +} from '@safe-global/utils/components/tx/security/tenderly/utils' +import { Errors, logError } from '@/services/exceptions' export const useSimulation = (): UseSimulationReturn => { const [simulation, setSimulation] = useState<TenderlySimulation | undefined>() @@ -52,7 +40,7 @@ export const useSimulation = (): UseSimulationReturn => { setSimulation(data) setSimulationRequestStatus(FETCH_STATUS.SUCCESS) } catch (error) { - console.error(error) + logError(Errors._200, error) setRequestError(asError(error).message) setSimulationRequestStatus(FETCH_STATUS.ERROR) diff --git a/apps/web/src/components/tx/security/tenderly/utils.ts b/apps/web/src/components/tx/security/tenderly/utils.ts index 92bda40950..2bb70408d9 100644 --- a/apps/web/src/components/tx/security/tenderly/utils.ts +++ b/apps/web/src/components/tx/security/tenderly/utils.ts @@ -1,19 +1,31 @@ import { generatePreValidatedSignature } from '@safe-global/protocol-kit/dist/src/utils/signatures' import EthSafeTransaction from '@safe-global/protocol-kit/dist/src/utils/transactions/SafeTransaction' import { encodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils/transactions/utils' -import { type SafeInfo, type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -import type { MetaTransactionData, SafeTransaction } from '@safe-global/safe-core-sdk-types' import { - getReadOnlyMultiSendCallOnlyContract, getReadOnlyCurrentGnosisSafeContract, + getReadOnlyMultiSendCallOnlyContract, } from '@/services/contracts/safeContracts' -import { TENDERLY_SIMULATE_ENDPOINT_URL, TENDERLY_ORG_NAME, TENDERLY_PROJECT_NAME } from '@/config/constants' -import { FEATURES, hasFeature } from '@/utils/chains' -import type { StateObject, TenderlySimulatePayload, TenderlySimulation } from '@/components/tx/security/tenderly/types' +import type { + TenderlySimulatePayload, + TenderlySimulation, +} from '@safe-global/utils/components/tx/security/tenderly/types' import { getWeb3ReadOnly } from '@/hooks/wallets/web3' -import { toBeHex } from 'ethers' -import type { EnvState } from '@/store/settingsSlice' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { EnvState } from '@safe-global/store/settingsSlice' +import type { SafeTransaction, MetaTransactionData } from '@safe-global/types-kit' +import { + isSingleTransactionSimulation, + getStateOverwrites, + _getStateOverride, +} from '@safe-global/utils/components/tx/security/tenderly/utils' +import { + TENDERLY_SIMULATE_ENDPOINT_URL, + TENDERLY_ORG_NAME, + TENDERLY_PROJECT_NAME, +} from '@safe-global/utils/config/constants' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' export const isTxSimulationEnabled = (chain?: ChainInfo): boolean => { if (!chain) { @@ -112,7 +124,7 @@ export const _getSingleTransactionPayload = async ( ]) return { - to: await readOnlySafeContract.getAddress(), + to: readOnlySafeContract.getAddress(), input, } } @@ -120,86 +132,15 @@ export const _getSingleTransactionPayload = async ( export const _getMultiSendCallOnlyPayload = async ( params: MultiSendTransactionSimulationParams, ): Promise<Pick<TenderlySimulatePayload, 'to' | 'input'>> => { - const data = encodeMultiSendData(params.transactions) + const data = encodeMultiSendData(params.transactions) as `0x${string}` const readOnlyMultiSendContract = await getReadOnlyMultiSendCallOnlyContract(params.safe.version) return { - to: await readOnlyMultiSendContract.getAddress(), + to: readOnlyMultiSendContract.getAddress(), input: readOnlyMultiSendContract.encode('multiSend', [data]), } } -export const _getStateOverride = ( - address: string, - balance?: string, - code?: string, - storage?: Record<string, string>, -): Record<string, StateObject> => { - return { - [address]: { - balance, - code, - storage, - }, - } -} - -const isSingleTransactionSimulation = (params: SimulationTxParams): params is SingleTransactionSimulationParams => { - return !Array.isArray(params.transactions) -} - -/** - * @returns true for single MultiSig transactions if the provided signatures plus the current owner's signature (if missing) - * do not reach the safe's threshold. - */ -const isOverwriteThreshold = (params: SimulationTxParams) => { - if (!isSingleTransactionSimulation(params)) { - return false - } - const tx = params.transactions - const hasOwnerSig = tx.signatures.has(params.executionOwner) - const effectiveSigs = tx.signatures.size + (hasOwnerSig ? 0 : 1) - return params.safe.threshold > effectiveSigs -} - -const getNonceOverwrite = (params: SimulationTxParams): number | undefined => { - if (!isSingleTransactionSimulation(params)) { - return - } - const txNonce = params.transactions.data.nonce - const safeNonce = params.safe.nonce - if (txNonce > safeNonce) { - return txNonce - } -} - -/* We need to overwrite the threshold stored in smart contract storage to 1 - to do a proper simulation that takes transaction guards into account. - The threshold is stored in storage slot 4 and uses full 32 bytes slot. - Safe storage layout can be found here: - https://github.com/gnosis/safe-contracts/blob/main/contracts/libraries/GnosisSafeStorage.sol */ -export const THRESHOLD_STORAGE_POSITION = toBeHex('0x4', 32) -export const THRESHOLD_OVERWRITE = toBeHex('0x1', 32) -/* We need to overwrite the nonce if we simulate a (partially) signed transaction which is not at the top position of the tx queue. - The nonce can be found in storage slot 5 and uses a full 32 bytes slot. */ -export const NONCE_STORAGE_POSITION = toBeHex('0x5', 32) - -const getStateOverwrites = (params: SimulationTxParams) => { - const nonceOverwrite = getNonceOverwrite(params) - const isThresholdOverwrite = isOverwriteThreshold(params) - - const storageOverwrites: Record<string, string> = {} as Record<string, string> - - if (isThresholdOverwrite) { - storageOverwrites[THRESHOLD_STORAGE_POSITION] = THRESHOLD_OVERWRITE - } - if (nonceOverwrite !== undefined) { - storageOverwrites[NONCE_STORAGE_POSITION] = toBeHex('0x' + BigInt(nonceOverwrite).toString(16), 32) - } - - return storageOverwrites -} - const getLatestBlockGasLimit = async (): Promise<number> => { const web3ReadOnly = getWeb3ReadOnly() const latestBlock = await web3ReadOnly?.getBlock('latest') diff --git a/apps/web/src/components/tx/security/useDelegateCallModule.ts b/apps/web/src/components/tx/security/useDelegateCallModule.ts index 4d0f7c11bb..a9a3bab010 100644 --- a/apps/web/src/components/tx/security/useDelegateCallModule.ts +++ b/apps/web/src/components/tx/security/useDelegateCallModule.ts @@ -1,10 +1,10 @@ -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction } from '@safe-global/types-kit' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useSafeInfo from '@/hooks/useSafeInfo' -import { DelegateCallModule } from '@/services/security/modules/DelegateCallModule' -import type { DelegateCallModuleResponse } from '@/services/security/modules/DelegateCallModule' -import type { SecurityResponse } from '@/services/security/modules/types' +import { DelegateCallModule } from '@safe-global/utils/services/security/modules/DelegateCallModule' +import type { DelegateCallModuleResponse } from '@safe-global/utils/services/security/modules/DelegateCallModule' +import type { SecurityResponse } from '@safe-global/utils/services/security/modules/types' import { useCurrentChain } from '@/hooks/useChains' const DelegateCallModuleInstance = new DelegateCallModule() diff --git a/apps/web/src/components/tx/security/utils.ts b/apps/web/src/components/tx/security/utils.ts index cf4b91fa88..c46d70e82e 100644 --- a/apps/web/src/components/tx/security/utils.ts +++ b/apps/web/src/components/tx/security/utils.ts @@ -1,4 +1,4 @@ -import { SecuritySeverity } from '@/services/security/modules/types' +import { SecuritySeverity } from '@safe-global/utils/services/security/modules/types' import CloseIcon from '@/public/images/common/close.svg' import InfoIcon from '@/public/images/notifications/info.svg' import type { ComponentType } from 'react' diff --git a/apps/web/src/components/welcome/WelcomeLogin/WalletLogin.tsx b/apps/web/src/components/welcome/WelcomeLogin/WalletLogin.tsx index 9486b34edf..ff51584146 100644 --- a/apps/web/src/components/welcome/WelcomeLogin/WalletLogin.tsx +++ b/apps/web/src/components/welcome/WelcomeLogin/WalletLogin.tsx @@ -4,7 +4,15 @@ import { Box, Button, Typography } from '@mui/material' import EthHashInfo from '@/components/common/EthHashInfo' import WalletIcon from '@/components/common/WalletIcon' -const WalletLogin = ({ onLogin, onContinue }: { onLogin: () => void; onContinue: () => void }) => { +const WalletLogin = ({ + onLogin, + onContinue, + buttonText, +}: { + onLogin: () => void + onContinue: () => void + buttonText?: string +}) => { const wallet = useWallet() const connectWallet = useConnectWallet() @@ -15,46 +23,24 @@ const WalletLogin = ({ onLogin, onContinue }: { onLogin: () => void; onContinue: if (wallet !== null) { return ( - <Box sx={{ width: '100%' }}> - <Button variant="contained" sx={{ padding: '8px 16px' }} fullWidth onClick={onContinue}> - <Box - width="100%" - justifyContent="space-between" - display="flex" - flexDirection="row" - alignItems="center" - gap={1} - > - <Box display="flex" flexDirection="column" alignItems="flex-start"> - <Typography variant="subtitle2" fontWeight={700}> - Continue with {wallet.label} - </Typography> - {wallet.address && ( - <EthHashInfo - address={wallet.address} - shortAddress - avatarSize={16} - showName={false} - copyAddress={false} - /> - )} - </Box> - {wallet.icon && <WalletIcon icon={wallet.icon} provider={wallet.label} width={24} height={24} />} + <Button variant="contained" sx={{ padding: '8px 16px' }} onClick={onContinue}> + <Box justifyContent="space-between" display="flex" flexDirection="row" alignItems="center" gap={1}> + <Box display="flex" flexDirection="column" alignItems="flex-start"> + <Typography variant="subtitle2" fontWeight={700}> + {buttonText || 'Continue with'} {wallet.label} + </Typography> + {wallet.address && ( + <EthHashInfo address={wallet.address} shortAddress avatarSize={16} showName={false} copyAddress={false} /> + )} </Box> - </Button> - </Box> + {wallet.icon && <WalletIcon icon={wallet.icon} provider={wallet.label} width={24} height={24} />} + </Box> + </Button> ) } return ( - <Button - onClick={onConnectWallet} - sx={{ minHeight: '42px' }} - variant="contained" - size="small" - disableElevation - fullWidth - > + <Button onClick={onConnectWallet} sx={{ minHeight: '42px' }} variant="contained" size="small" disableElevation> Connect wallet </Button> ) diff --git a/apps/web/src/components/welcome/WelcomeLogin/__tests__/WalletLogin.test.tsx b/apps/web/src/components/welcome/WelcomeLogin/__tests__/WalletLogin.test.tsx index eb25c8c693..0d6b59177b 100644 --- a/apps/web/src/components/welcome/WelcomeLogin/__tests__/WalletLogin.test.tsx +++ b/apps/web/src/components/welcome/WelcomeLogin/__tests__/WalletLogin.test.tsx @@ -4,7 +4,7 @@ import * as useWallet from '@/hooks/wallets/useWallet' import WalletLogin from '../WalletLogin' import { toBeHex } from 'ethers' import { type EIP1193Provider } from '@web3-onboard/common' -import { shortenAddress } from '@/utils/formatters' +import { shortenAddress } from '@safe-global/utils/utils/formatters' describe('WalletLogin', () => { beforeEach(() => { diff --git a/apps/web/src/components/wrappers/FeatureWrapper/index.test.tsx b/apps/web/src/components/wrappers/FeatureWrapper/index.test.tsx index 5cb50165ad..0cdcddcd77 100644 --- a/apps/web/src/components/wrappers/FeatureWrapper/index.test.tsx +++ b/apps/web/src/components/wrappers/FeatureWrapper/index.test.tsx @@ -3,8 +3,8 @@ import type { NextRouter } from 'next/router' import { render } from '@/tests/test-utils' import { _FeatureWrapper } from '@/components/wrappers/FeatureWrapper' -import { FEATURES } from '@/utils/chains' import type * as useChains from '@/hooks/useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' const mockRouter = { replace: jest.fn(), diff --git a/apps/web/src/components/wrappers/FeatureWrapper/index.tsx b/apps/web/src/components/wrappers/FeatureWrapper/index.tsx index 806a250aea..9fb5da38e0 100644 --- a/apps/web/src/components/wrappers/FeatureWrapper/index.tsx +++ b/apps/web/src/components/wrappers/FeatureWrapper/index.tsx @@ -3,7 +3,8 @@ import type { ReactElement } from 'react' import { Navigate } from '@/components/common/Navigate' import { useHasFeature } from '@/hooks/useChains' import madProps from '@/utils/mad-props' -import type { FEATURES } from '@/utils/chains' + +import type { FEATURES } from '@safe-global/utils/utils/chains' // TODO: Use with swaps/staking export function _FeatureWrapper({ diff --git a/apps/web/src/config/constants.ts b/apps/web/src/config/constants.ts index ad03a3261c..4f751b1a36 100644 --- a/apps/web/src/config/constants.ts +++ b/apps/web/src/config/constants.ts @@ -1,8 +1,16 @@ import chains from './chains' +import { HELP_CENTER_URL as SAFE_HELP_CENTER_URL } from '@safe-global/utils/config/constants' export const IS_PRODUCTION = process.env.NEXT_PUBLIC_IS_PRODUCTION === 'true' export const IS_DEV = process.env.NODE_ENV === 'development' +// default chain ID's as provided to the environment +export const DEFAULT_TESTNET_CHAIN_ID = +(process.env.NEXT_PUBLIC_DEFAULT_TESTNET_CHAIN_ID ?? chains.boba) +export const DEFAULT_MAINNET_CHAIN_ID = +(process.env.NEXT_PUBLIC_DEFAULT_MAINNET_CHAIN_ID ?? chains.boba) + +// default chain ID used in the application +export const DEFAULT_CHAIN_ID = IS_PRODUCTION ? DEFAULT_MAINNET_CHAIN_ID : DEFAULT_TESTNET_CHAIN_ID + export const GATEWAY_URL_PRODUCTION = process.env.NEXT_PUBLIC_GATEWAY_URL_PRODUCTION || 'https://gateway.safe.boba.network' export const GATEWAY_URL_STAGING = @@ -12,21 +20,16 @@ export const GATEWAY_URL_STAGING = export const POLLING_INTERVAL = 15_000 export const BASE_TX_GAS = 21_000 export const LS_NAMESPACE = 'SAFE_v2__' -export const LATEST_SAFE_VERSION = process.env.NEXT_PUBLIC_SAFE_VERSION || '1.4.1' -// Access keys -export const INFURA_TOKEN = process.env.NEXT_PUBLIC_INFURA_TOKEN || '' export const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN || '' export const BEAMER_ID = process.env.NEXT_PUBLIC_BEAMER_ID || '' +export const DATADOG_CLIENT_TOKEN = process.env.NEXT_PUBLIC_DATADOG_CLIENT_TOKEN || '' // Wallets export const WC_PROJECT_ID = process.env.NEXT_PUBLIC_WC_PROJECT_ID || '' export const TREZOR_APP_URL = 'app.safe.global' export const TREZOR_EMAIL = 'support@safe.global' -// Cypress -export const CYPRESS_MNEMONIC = process.env.NEXT_PUBLIC_CYPRESS_MNEMONIC || '' - // Safe Token export const SAFE_TOKEN_ADDRESSES: { [chainId: string]: string } = { [chains.eth]: '0x5aFE3855358E112B5647B952709E6165e1c1eEEe', @@ -38,23 +41,19 @@ export const SAFE_LOCKING_ADDRESS: { [chainId: string]: string } = { [chains.sep]: '0xb161ccb96b9b817F9bDf0048F212725128779DE9', } -// Safe Apps -export const SAFE_APPS_INFURA_TOKEN = process.env.NEXT_PUBLIC_SAFE_APPS_INFURA_TOKEN || INFURA_TOKEN export const SAFE_APPS_THIRD_PARTY_COOKIES_CHECK_URL = 'https://third-party-cookies-check.gnosis-safe.com' export const SAFE_APPS_DEMO_SAFE_MAINNET = 'eth:0xfF501B324DC6d78dC9F983f140B9211c3EdB4dc7' export const SAFE_APPS_SDK_DOCS_URL = 'https://help.safe.global/en/articles/145503-how-to-create-a-safe-app-with-safe-apps-sdk-and-list-it' -// Google Tag Manager -export const GOOGLE_TAG_MANAGER_ID = process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID || '' -export const GOOGLE_TAG_MANAGER_AUTH_LIVE = process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LIVE_AUTH || '' -export const GOOGLE_TAG_MANAGER_AUTH_LATEST = process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LATEST_AUTH || '' -export const GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH = process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH || '' +// Google Analytics +export const PROD_GA_TRACKING_ID = process.env.NEXT_PUBLIC_PROD_GA_TRACKING_ID || '' +export const TEST_GA_TRACKING_ID = process.env.NEXT_PUBLIC_TEST_GA_TRACKING_ID || '' +export const SAFE_APPS_GA_TRACKING_ID = process.env.NEXT_PUBLIC_SAFE_APPS_GA_TRACKING_ID || '' +export const GA_TRACKING_ID = IS_PRODUCTION ? PROD_GA_TRACKING_ID : TEST_GA_TRACKING_ID -// Tenderly - API docs: https://www.notion.so/Simulate-API-Documentation-6f7009fe6d1a48c999ffeb7941efc104 -export const TENDERLY_SIMULATE_ENDPOINT_URL = process.env.NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL || '' -export const TENDERLY_PROJECT_NAME = process.env.NEXT_PUBLIC_TENDERLY_PROJECT_NAME || '' -export const TENDERLY_ORG_NAME = process.env.NEXT_PUBLIC_TENDERLY_ORG_NAME || '' +// MixPanel +export const MIXPANEL_TOKEN = process.env.NEXT_PUBLIC_MIXPANEL_TOKEN || '' // Safe Apps tags export enum SafeAppsTag { @@ -63,54 +62,30 @@ export enum SafeAppsTag { SAFE_GOVERNANCE_APP = 'safe-governance-app', ONRAMP = 'onramp', RECOVERY_SYGNUM = 'recovery-sygnum', + SWAP_FALLBACK = 'swap-fallback', } -// Help Center export const HELP_CENTER_URL = 'https://safe-support.protofire.io' -export const SAFE_HELP_CENTER_URL = 'https://help.safe.global/' - -export const HelpCenterArticle = { - ADDRESS_BOOK_DATA: `${SAFE_HELP_CENTER_URL}/en/articles/40811-address-book-export-and-import`, - ADVANCED_PARAMS: `${SAFE_HELP_CENTER_URL}/en/articles/40837-advanced-transaction-parameters`, - CANCELLING_TRANSACTIONS: `${SAFE_HELP_CENTER_URL}/en/articles/40836-why-do-i-need-to-pay-for-cancelling-a-transaction`, - COOKIES: `${SAFE_HELP_CENTER_URL}/en/articles/40797-why-do-i-need-to-enable-third-party-cookies-for-safe-apps`, - CONFLICTING_TRANSACTIONS: `${SAFE_HELP_CENTER_URL}/en/articles/40839-why-are-transactions-with-the-same-nonce-conflicting-with-each-other`, - FALLBACK_HANDLER: `${SAFE_HELP_CENTER_URL}/en/articles/40838-what-is-a-fallback-handler-and-how-does-it-relate-to-safe`, - MOBILE_SAFE: `${SAFE_HELP_CENTER_URL}/en/articles/40801-connect-to-web-with-mobile-safe`, - RECOVERY: `${SAFE_HELP_CENTER_URL}/en/articles/110656-account-recovery-in-safe-wallet`, - RELAYING: `${SAFE_HELP_CENTER_URL}/en/articles/59203-what-is-gas-fee-sponsoring`, - SAFE_SETUP: `${SAFE_HELP_CENTER_URL}/en/articles/40835-what-safe-setup-should-i-use`, - SIGNED_MESSAGES: `${SAFE_HELP_CENTER_URL}/en/articles/40783-what-are-signed-messages`, - SPAM_TOKENS: `${SAFE_HELP_CENTER_URL}/en/articles/40784-default-token-list-local-hiding-of-spam-tokens`, - SPENDING_LIMITS: `${SAFE_HELP_CENTER_URL}/en/articles/40842-set-up-and-use-spending-limits`, - TRANSACTION_GUARD: `${SAFE_HELP_CENTER_URL}/en/articles/40809-what-is-a-transaction-guard`, - UNEXPECTED_DELEGATE_CALL: `${SAFE_HELP_CENTER_URL}/en/articles/40794-why-do-i-see-an-unexpected-delegate-call-warning-in-my-transaction`, - PROPOSERS: `${SAFE_HELP_CENTER_URL}/en/articles/235770-proposers`, - PUSH_NOTIFICATIONS: `${SAFE_HELP_CENTER_URL}/en/articles/99197-how-to-start-receiving-web-push-notifications-in-the-web-wallet`, - SWAP_WIDGET_FEES: `${SAFE_HELP_CENTER_URL}/en/articles/178530-how-does-the-widget-fee-work-for-native-swaps`, -} as const -export const HelperCenterArticleTitles = { - RECOVERY: 'Learn more about the Account recovery process', + +// Safe Apps names +export enum SafeAppsName { + CSV = 'CSV Airdrop', } //Feedback -export const NEW_SUGGESTION_FORM = - 'https://docs.google.com/forms/d/e/1FAIpQLSfojsADYCiWq9AqbLqsUTzCDSpA8FMgdAQp0Pyl0BOeurlq9A/viewform' +export const NEW_SUGGESTION_FORM = 'https://safe-feature-request.protofire.io/' export const RECOVERY_FEEDBACK_FORM = 'https://noteforms.com/forms/safe-feedback-form-hk16ds?notionforms=1&utm_source=notionforms' // Social -export const DISCORD_URL = 'https://chat.safe.global' export const TWITTER_URL = 'https://x.com/bobanetwork' // Legal export const IS_OFFICIAL_HOST = 'true' export const OFFICIAL_HOSTS = /app\.safe\.global|.+\.5afe\.dev|localhost:3000/ +export const IPFS_HOSTS = /\.ipfs\.dweb\.link|\.ipfs\.w3s\.link|\.ipfs\.inbrowser\.link/ export const BRAND_NAME = process.env.NEXT_PUBLIC_BRAND_NAME || (IS_OFFICIAL_HOST ? 'Safe{Wallet}' : 'Wallet fork') export const BRAND_LOGO = process.env.NEXT_PUBLIC_BRAND_LOGO || '' -// Risk mitigation (Blockaid) -export const BLOCKAID_API = 'https://client.blockaid.io' -export const BLOCKAID_CLIENT_ID = process.env.NEXT_PUBLIC_BLOCKAID_CLIENT_ID export const REDEFINE_ARTICLE = 'https://safe.mirror.xyz/rInLWZwD_sf7enjoFerj6FIzCYmVMGrrV8Nhg4THdwI' export const CHAINALYSIS_OFAC_CONTRACT = '0x40c57923924b5c5c5455c48d93317139addac8fb' @@ -118,7 +93,7 @@ export const CHAINALYSIS_OFAC_CONTRACT = '0x40c57923924b5c5c5455c48d93317139adda export const SAFE_PASS_URL = 'community.safe.global' export const ECOSYSTEM_ID_ADDRESS = process.env.NEXT_PUBLIC_ECOSYSTEM_ID_ADDRESS || '0x0000000000000000000000000000000000000000' -export const MULTICHAIN_HELP_ARTICLE = `${HELP_CENTER_URL}/en/articles/222612-multi-chain-safe` +export const MULTICHAIN_HELP_ARTICLE = `${SAFE_HELP_CENTER_URL}/en/articles/222612-multi-chain-safe` export const TERMS_LINK = process.env.NEXT_PUBLIC_TERMS_LINK || diff --git a/apps/web/src/config/routes.ts b/apps/web/src/config/routes.ts index 9002355243..01932a786c 100644 --- a/apps/web/src/config/routes.ts +++ b/apps/web/src/config/routes.ts @@ -2,6 +2,7 @@ export const AppRoutes = { '403': '/403', '404': '/404', wc: '/wc', + userSettings: '/user-settings', terms: '/terms', swap: '/swap', stake: '/stake', @@ -10,6 +11,7 @@ export const AppRoutes = { index: '/', imprint: '/imprint', home: '/home', + earn: '/earn', cookie: '/cookie', bridge: '/bridge', addressBook: '/address-book', @@ -47,6 +49,12 @@ export const AppRoutes = { share: { safeApp: '/share/safe-app', }, + spaces: { + settings: '/spaces/settings', + safeAccounts: '/spaces/safe-accounts', + members: '/spaces/members', + index: '/spaces', + }, transactions: { tx: '/transactions/tx', queue: '/transactions/queue', @@ -56,6 +64,7 @@ export const AppRoutes = { history: '/transactions/history', }, welcome: { + spaces: '/welcome/spaces', index: '/welcome', accounts: '/welcome/accounts', }, diff --git a/apps/web/src/config/securityHeaders.ts b/apps/web/src/config/securityHeaders.ts index 5cd722ad69..fcb9cb5a44 100644 --- a/apps/web/src/config/securityHeaders.ts +++ b/apps/web/src/config/securityHeaders.ts @@ -1,4 +1,6 @@ -import { CYPRESS_MNEMONIC, IS_PRODUCTION } from '@/config/constants' +import { IS_PRODUCTION } from './constants' + +const isCypress = Boolean(typeof window !== 'undefined' && window.Cypress) /** * CSP Header notes: @@ -6,14 +8,13 @@ import { CYPRESS_MNEMONIC, IS_PRODUCTION } from '@/config/constants' * connect-src * because the RPCs are configurable (config service) * style-src unsafe-inline for our styled components * script-src unsafe-eval is needed by next.js in dev mode, otherwise only self - * unsafe-inline is needed for gtm https://developers.google.com/tag-platform/tag-manager/web/csp * frame-ancestors can not be set via meta tag */ export const ContentSecurityPolicy = ` default-src 'self'; connect-src 'self' *; - script-src 'self' https://www.google-analytics.com https://ssl.google-analytics.com 'unsafe-inline' https://*.getbeamer.com https://www.googletagmanager.com https://*.ingest.sentry.io https://sentry.io ${ - !IS_PRODUCTION || CYPRESS_MNEMONIC + script-src 'self' 'unsafe-inline' https://*.getbeamer.com https://www.googletagmanager.com https://*.ingest.sentry.io https://sentry.io ${ + !IS_PRODUCTION || isCypress ? "'unsafe-eval'" // Dev server and cypress need unsafe-eval : "'wasm-unsafe-eval'" }; diff --git a/apps/web/src/features/bridge/components/Bridge/index.tsx b/apps/web/src/features/bridge/components/Bridge/index.tsx index 3d503b3cfd..d8c0b21155 100644 --- a/apps/web/src/features/bridge/components/Bridge/index.tsx +++ b/apps/web/src/features/bridge/components/Bridge/index.tsx @@ -1,10 +1,10 @@ import dynamic from 'next/dynamic' import { AppRoutes } from '@/config/routes' -import { FEATURES } from '@/utils/chains' import { FeatureWrapper } from '@/components/wrappers/FeatureWrapper' import { SanctionWrapper } from '@/components/wrappers/SanctionWrapper' import { DisclaimerWrapper } from '@/components/wrappers/DisclaimerWrapper' +import { FEATURES } from '@safe-global/utils/utils/chains' const LOCAL_STORAGE_CONSENT_KEY = 'bridgeConsent' diff --git a/apps/web/src/features/bridge/components/BridgeWidget/index.test.tsx b/apps/web/src/features/bridge/components/BridgeWidget/index.test.tsx index 09d19c1361..eeddd8c8ac 100644 --- a/apps/web/src/features/bridge/components/BridgeWidget/index.test.tsx +++ b/apps/web/src/features/bridge/components/BridgeWidget/index.test.tsx @@ -1,81 +1,49 @@ -import { faker } from '@faker-js/faker' - import { _getAppData } from '@/features/bridge/components/BridgeWidget' import { chainBuilder } from '@/tests/builders/chains' -import { FEATURES } from '@/utils/chains' describe('BridgeWidget', () => { describe('getAppData', () => { it('should return the correct SafeAppDataWithPermissions', () => { - const result = _getAppData(false) + const chain = chainBuilder().build() + const result = _getAppData(false, chain) expect(result).toStrictEqual({ accessControl: { type: 'NO_RESTRICTIONS', }, - chainIds: [], + chainIds: [chain.chainId], description: '', developerWebsite: '', features: [], - iconUrl: '/images/common/bridge.svg', + iconUrl: '/images/common/safe-bridge.svg', id: expect.any(Number), name: 'Bridge', safeAppsPermissions: [], socialProfiles: [], tags: [], - url: 'https://iframe.jumper.exchange/?theme=light', + url: `https://iframe.jumper.exchange/bridge?fromChain=${chain.chainId}&theme=light`, }) }) it('should return the correct SafeAppDataWithPermissions with dark mode', () => { - const result = _getAppData(true) - - expect(result).toStrictEqual({ - accessControl: { - type: 'NO_RESTRICTIONS', - }, - chainIds: [], - description: '', - developerWebsite: '', - features: [], - iconUrl: '/images/common/bridge.svg', - id: expect.any(Number), - name: 'Bridge', - safeAppsPermissions: [], - socialProfiles: [], - tags: [], - url: 'https://iframe.jumper.exchange/?theme=dark', - }) - }) - - it('should return the correct SafeAppDataWithPermissions with chains', () => { - const chains = Array.from({ length: faker.number.int({ min: 1, max: 10 }) }, (_, i) => { - return ( - chainBuilder() - // @ts-expect-error - .with({ features: i % 2 ? [FEATURES.BRIDGE] : [] }) - .build() - ) - }) - - const result = _getAppData(false, chains) + const chain = chainBuilder().build() + const result = _getAppData(true, chain) expect(result).toStrictEqual({ accessControl: { type: 'NO_RESTRICTIONS', }, - // @ts-expect-error - chainIds: chains.filter((chain) => chain.features.includes(FEATURES.BRIDGE)).map((chain) => chain.chainId), + chainIds: [chain.chainId], description: '', developerWebsite: '', features: [], - iconUrl: '/images/common/bridge.svg', + iconUrl: '/images/common/safe-bridge-dark.svg', id: expect.any(Number), name: 'Bridge', safeAppsPermissions: [], socialProfiles: [], tags: [], - url: 'https://iframe.jumper.exchange/?theme=light', + url: `https://iframe.jumper.exchange/bridge?fromChain=${chain.chainId}&theme=dark`, }) }) }) diff --git a/apps/web/src/features/bridge/components/BridgeWidget/index.tsx b/apps/web/src/features/bridge/components/BridgeWidget/index.tsx index cf36700fdf..253e0ed039 100644 --- a/apps/web/src/features/bridge/components/BridgeWidget/index.tsx +++ b/apps/web/src/features/bridge/components/BridgeWidget/index.tsx @@ -3,21 +3,28 @@ import type { ReactElement } from 'react' import AppFrame from '@/components/safe-apps/AppFrame' import { getEmptySafeApp } from '@/components/safe-apps/utils' -import useChains from '@/hooks/useChains' -import { FEATURES, hasFeature } from '@/utils/chains' +import { useCurrentChain } from '@/hooks/useChains' import { useDarkMode } from '@/hooks/useDarkMode' import type { SafeAppDataWithPermissions } from '@/components/safe-apps/types' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' -export const BRIDGE_WIDGET_URL = 'https://iframe.jumper.exchange' +export const BRIDGE_WIDGET_URL = 'https://iframe.jumper.exchange/bridge' -export function BridgeWidget(): ReactElement { +export function BridgeWidget(): ReactElement | null { const isDarkMode = useDarkMode() - const { configs } = useChains() + const chain = useCurrentChain() - const appData = useMemo((): SafeAppDataWithPermissions => { - return _getAppData(isDarkMode, configs) - }, [configs, isDarkMode]) + const appData = useMemo((): SafeAppDataWithPermissions | null => { + if (!chain || !hasFeature(chain, FEATURES.BRIDGE)) { + return null + } + return _getAppData(isDarkMode, chain) + }, [chain, isDarkMode]) + + if (!appData) { + return null + } return ( <AppFrame @@ -29,25 +36,17 @@ export function BridgeWidget(): ReactElement { ) } -export function _getAppData(isDarkMode: boolean, chains?: Array<ChainInfo>): SafeAppDataWithPermissions { +export function _getAppData(isDarkMode: boolean, chain: ChainInfo): SafeAppDataWithPermissions { const theme = isDarkMode ? 'dark' : 'light' + const appUrl = new URL(BRIDGE_WIDGET_URL) + appUrl.searchParams.set('fromChain', chain.chainId) + appUrl.searchParams.set('theme', theme) + return { ...getEmptySafeApp(), name: 'Bridge', - iconUrl: '/images/common/bridge.svg', - chainIds: getChainIds(chains), - url: `${BRIDGE_WIDGET_URL}/?theme=${theme}`, + iconUrl: isDarkMode ? '/images/common/safe-bridge-dark.svg' : '/images/common/safe-bridge.svg', + chainIds: [chain.chainId], + url: appUrl.toString(), } } - -function getChainIds(chains?: Array<ChainInfo>): Array<string> { - if (!chains) { - return [] - } - return chains.reduce<Array<string>>((acc, cur) => { - if (hasFeature(cur, FEATURES.BRIDGE)) { - acc.push(cur.chainId) - } - return acc - }, []) -} diff --git a/apps/web/src/features/bridge/hooks/useIsBridgeFeatureEnabled.ts b/apps/web/src/features/bridge/hooks/useIsBridgeFeatureEnabled.ts index 2f3a718483..a85715e1d8 100644 --- a/apps/web/src/features/bridge/hooks/useIsBridgeFeatureEnabled.ts +++ b/apps/web/src/features/bridge/hooks/useIsBridgeFeatureEnabled.ts @@ -1,5 +1,6 @@ import { useIsGeoblockedFeatureEnabled } from '@/hooks/useIsGeoblockedFeatureEnabled' -import { FEATURES } from '@/utils/chains' + +import { FEATURES } from '@safe-global/utils/utils/chains' export function useIsBridgeFeatureEnabled() { return useIsGeoblockedFeatureEnabled(FEATURES.BRIDGE) diff --git a/apps/web/src/features/counterfactual/ActivateAccountButton.tsx b/apps/web/src/features/counterfactual/ActivateAccountButton.tsx index c6cd0e3b7e..b42a58ab45 100644 --- a/apps/web/src/features/counterfactual/ActivateAccountButton.tsx +++ b/apps/web/src/features/counterfactual/ActivateAccountButton.tsx @@ -3,10 +3,11 @@ import dynamic from 'next/dynamic' import React, { useContext } from 'react' import { Button, CircularProgress, Tooltip, Typography } from '@mui/material' import { TxModalContext } from '@/components/tx-flow' -import { PendingSafeStatus, selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' +import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' import useSafeInfo from '@/hooks/useSafeInfo' import { useAppSelector } from '@/store' import CheckWallet from '@/components/common/CheckWallet' +import { PendingSafeStatus } from '@safe-global/utils/features/counterfactual/store/types' const ActivateAccountFlow = dynamic(() => import('./ActivateAccountFlow')) diff --git a/apps/web/src/features/counterfactual/ActivateAccountFlow.tsx b/apps/web/src/features/counterfactual/ActivateAccountFlow.tsx index bf9cf65f67..a1ca763ee6 100644 --- a/apps/web/src/features/counterfactual/ActivateAccountFlow.tsx +++ b/apps/web/src/features/counterfactual/ActivateAccountFlow.tsx @@ -7,7 +7,7 @@ import TxLayout from '@/components/tx-flow/common/TxLayout' import ErrorMessage from '@/components/tx/ErrorMessage' import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector' import { safeCreationDispatch, SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' -import { selectUndeployedSafe, type UndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' +import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' import { CF_TX_GROUP_KEY, extractCounterfactualSafeSetup, isPredictedSafeProps } from '@/features/counterfactual/utils' import useChainId from '@/hooks/useChainId' import { useCurrentChain } from '@/hooks/useChains' @@ -18,21 +18,20 @@ import useWalletCanPay from '@/hooks/useWalletCanPay' import useWallet from '@/hooks/wallets/useWallet' import { OVERVIEW_EVENTS, trackEvent, WALLET_EVENTS } from '@/services/analytics' import { TX_EVENTS, TX_TYPES } from '@/services/analytics/events/transactions' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { useAppSelector } from '@/store' -import { hasFeature } from '@/utils/chains' import { hasRemainingRelays } from '@/utils/relaying' import { Box, Button, CircularProgress, Divider, Grid, Typography } from '@mui/material' -import type { DeploySafeProps } from '@safe-global/protocol-kit' -import { FEATURES } from '@/utils/chains' import React, { useContext, useMemo, useState } from 'react' -import { getLatestSafeVersion } from '@/utils/chains' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { useEstimateSafeCreationGas } from '@/components/new-safe/create/useEstimateSafeCreationGas' import useIsWrongChain from '@/hooks/useIsWrongChain' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' import CheckWallet from '@/components/common/CheckWallet' import { getSafeToL2SetupDeployment } from '@safe-global/safe-deployments' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' +import type { UndeployedSafe } from '@safe-global/utils/features/counterfactual/store/types' +import type { TransactionOptions } from '@safe-global/types-kit' const useActivateAccount = (undeployedSafe: UndeployedSafe | undefined) => { const chain = useCurrentChain() @@ -49,7 +48,7 @@ const useActivateAccount = (undeployedSafe: UndeployedSafe | undefined) => { const maxFeePerGas = gasPrice?.maxFeePerGas const maxPriorityFeePerGas = gasPrice?.maxPriorityFeePerGas - const options: DeploySafeProps['options'] = isEIP1559 + const options: TransactionOptions = isEIP1559 ? { maxFeePerGas: maxFeePerGas?.toString(), maxPriorityFeePerGas: maxPriorityFeePerGas?.toString(), @@ -94,7 +93,7 @@ const ActivateAccountFlow = () => { if (!undeployedSafe || !undeployedSafeSetup) return null - const { owners, threshold, safeVersion } = undeployedSafeSetup + const { owners, threshold } = undeployedSafeSetup const safeToL2SetupDeployment = getSafeToL2SetupDeployment({ version: '1.4.1', network: chain?.chainId }) const safeToL2SetupAddress = safeToL2SetupDeployment?.defaultAddress @@ -129,7 +128,6 @@ const ActivateAccountFlow = () => { await createNewSafe( wallet.provider, undeployedSafe.props, - safeVersion ?? getLatestSafeVersion(chain), chain, options, onSubmit, diff --git a/apps/web/src/features/counterfactual/CheckBalance.tsx b/apps/web/src/features/counterfactual/CheckBalance.tsx index 6424ea57ee..708db2c0c4 100644 --- a/apps/web/src/features/counterfactual/CheckBalance.tsx +++ b/apps/web/src/features/counterfactual/CheckBalance.tsx @@ -4,7 +4,7 @@ import Track from '@/components/common/Track' import { useCurrentChain } from '@/hooks/useChains' import useSafeInfo from '@/hooks/useSafeInfo' import { COUNTERFACTUAL_EVENTS } from '@/services/analytics/events/counterfactual' -import { getBlockExplorerLink } from '@/utils/chains' +import { getBlockExplorerLink } from '@safe-global/utils/utils/chains' import { Alert, Typography } from '@mui/material' const CheckBalance = () => { diff --git a/apps/web/src/features/counterfactual/CounterfactualForm.tsx b/apps/web/src/features/counterfactual/CounterfactualForm.tsx index e91a6199cf..de6b7564ea 100644 --- a/apps/web/src/features/counterfactual/CounterfactualForm.tsx +++ b/apps/web/src/features/counterfactual/CounterfactualForm.tsx @@ -18,12 +18,11 @@ import { useCurrentChain } from '@/hooks/useChains' import { getTxOptions } from '@/utils/transactions' import CheckWallet from '@/components/common/CheckWallet' import { useIsExecutionLoop } from '@/components/tx/SignOrExecuteForm/hooks' -import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm/SignOrExecuteForm' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SignOrExecuteProps } from '@/components/tx/SignOrExecuteForm/SignOrExecuteFormV2' +import type { SafeTransaction } from '@safe-global/types-kit' import AdvancedParams, { useAdvancedParams } from '@/components/tx/AdvancedParams' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' -import css from '@/components/tx/SignOrExecuteForm/styles.module.css' import commonCss from '@/components/tx-flow/common/styles.module.css' import { TxSecurityContext } from '@/components/tx/security/shared/TxSecurityContext' import useIsSafeOwner from '@/hooks/useIsSafeOwner' @@ -134,7 +133,7 @@ export const CounterfactualForm = ({ </ul> </Alert> - <div className={classNames(css.params)}> + <div className={classNames(commonCss.params)}> <AdvancedParams willExecute params={advancedParams} diff --git a/apps/web/src/features/counterfactual/PayNowPayLater.tsx b/apps/web/src/features/counterfactual/PayNowPayLater.tsx index 4232427dc9..716cd21a81 100644 --- a/apps/web/src/features/counterfactual/PayNowPayLater.tsx +++ b/apps/web/src/features/counterfactual/PayNowPayLater.tsx @@ -15,11 +15,7 @@ import { import css from './styles.module.css' import ErrorMessage from '@/components/tx/ErrorMessage' - -export const enum PayMethod { - PayNow = 'PayNow', - PayLater = 'PayLater', -} +import { PayMethod } from '@safe-global/utils/features/counterfactual/types' const PayNowPayLater = ({ totalFee, diff --git a/apps/web/src/features/counterfactual/__tests__/utils.test.ts b/apps/web/src/features/counterfactual/__tests__/utils.test.ts index 56b223028b..7d97f6910f 100644 --- a/apps/web/src/features/counterfactual/__tests__/utils.test.ts +++ b/apps/web/src/features/counterfactual/__tests__/utils.test.ts @@ -1,9 +1,4 @@ -import { - getCounterfactualBalance, - getNativeBalance, - getUndeployedSafeInfo, - setNativeBalance, -} from '@/features/counterfactual/utils' +import { getCounterfactualBalance, getUndeployedSafeInfo } from '@/features/counterfactual/utils' import * as web3 from '@/hooks/wallets/web3' import { chainBuilder } from '@/tests/builders/chains' import { faker } from '@faker-js/faker' @@ -11,8 +6,8 @@ import type { PredictedSafeProps } from '@safe-global/protocol-kit' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' import { type BrowserProvider, type JsonRpcProvider } from 'ethers' -import { PendingSafeStatus } from '../store/undeployedSafesSlice' -import { PayMethod } from '../PayNowPayLater' +import { PendingSafeStatus } from '@safe-global/utils/features/counterfactual/store/types' +import { PayMethod } from '@safe-global/utils/features/counterfactual/types' describe('Counterfactual utils', () => { describe('getUndeployedSafeInfo', () => { @@ -60,31 +55,9 @@ describe('Counterfactual utils', () => { } as unknown as JsonRpcProvider jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementationOnce(() => mockReadOnlyProvider) - const result = await getCounterfactualBalance(mockSafeAddress, undefined, mockChain) - const nativeBalanceCache = getNativeBalance() + await getCounterfactualBalance(mockSafeAddress, undefined, mockChain) expect(mockReadOnlyProvider.getBalance).toHaveBeenCalledTimes(1) - expect(nativeBalanceCache).toEqual(mockBalance) - expect(result).toEqual({ - fiatTotal: '0', - items: [ - { - tokenInfo: { - type: TokenType.NATIVE_TOKEN, - address: ZERO_ADDRESS, - ...mockChain.nativeCurrency, - }, - balance: mockBalance.toString(), - fiatBalance: '0', - fiatConversion: '0', - }, - ], - }) - - // Should use the cache now - const newResult = await getCounterfactualBalance(mockSafeAddress, undefined, mockChain) - expect(mockReadOnlyProvider.getBalance).toHaveBeenCalledTimes(1) - expect(newResult?.items[0].balance).toEqual('123') }) it('should return undefined if there is no chain info', async () => { @@ -118,40 +91,5 @@ describe('Counterfactual utils', () => { ], }) }) - - it('should not use the cache if the ignoreCache flag is passed', async () => { - const mockBalance = 123n - const mockReadOnlyProvider = { - getBalance: jest.fn(() => Promise.resolve(mockBalance)), - } as unknown as JsonRpcProvider - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementationOnce(() => mockReadOnlyProvider) - - // Set local cache - const mockCacheBalance = 10n - setNativeBalance(mockCacheBalance) - const nativeBalanceCache = getNativeBalance() - expect(nativeBalanceCache).toEqual(mockCacheBalance) - - // Call function and ignore cache - const result = await getCounterfactualBalance(mockSafeAddress, undefined, mockChain, true) - - expect(mockReadOnlyProvider.getBalance).toHaveBeenCalled() - expect(result?.items[0].balance).not.toEqual(mockCacheBalance) - expect(result).toEqual({ - fiatTotal: '0', - items: [ - { - tokenInfo: { - type: TokenType.NATIVE_TOKEN, - address: ZERO_ADDRESS, - ...mockChain.nativeCurrency, - }, - balance: mockBalance.toString(), - fiatBalance: '0', - fiatConversion: '0', - }, - ], - }) - }) }) }) diff --git a/apps/web/src/features/counterfactual/hooks/useDeployGasLimit.ts b/apps/web/src/features/counterfactual/hooks/useDeployGasLimit.ts index 6731869354..55d9304383 100644 --- a/apps/web/src/features/counterfactual/hooks/useDeployGasLimit.ts +++ b/apps/web/src/features/counterfactual/hooks/useDeployGasLimit.ts @@ -1,4 +1,4 @@ -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useChainId from '@/hooks/useChainId' import useOnboard from '@/hooks/wallets/useOnboard' import useWallet from '@/hooks/wallets/useWallet' @@ -6,7 +6,7 @@ import { getSafeSDKWithSigner } from '@/services/tx/tx-sender/sdk' import { estimateSafeDeploymentGas, estimateTxBaseGas } from '@safe-global/protocol-kit' import type Safe from '@safe-global/protocol-kit' -import { OperationType, type SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { OperationType, type SafeTransaction } from '@safe-global/types-kit' import { getCompatibilityFallbackHandlerContract, getSimulateTxAccessorContract, @@ -65,7 +65,7 @@ export const estimateBatchDeploymentTransaction = async ( chainId: string, ) => { const customContracts = sdk.getContractManager().contractNetworks?.[chainId] - const safeVersion = await sdk.getContractVersion() + const safeVersion = sdk.getContractVersion() const safeProvider = sdk.getSafeProvider() const fallbackHandlerContract = await getCompatibilityFallbackHandlerContract({ safeProvider, @@ -92,13 +92,13 @@ export const estimateBatchDeploymentTransaction = async ( const transactionDataToEstimate: string = simulateTxAccessorContract.encode('simulate', [ safeTransaction.data.to, BigInt(safeTransaction.data.value), - safeTransaction.data.data, + safeTransaction.data.data as `0x${string}`, safeTransaction.data.operation, ]) const safeFunctionToEstimate: string = fallbackHandlerContract.encode('simulate', [ - await simulateTxAccessorContract.getAddress(), - transactionDataToEstimate, + simulateTxAccessorContract.getAddress(), + transactionDataToEstimate as `0x${string}`, ]) const simulateBatchTransaction = { diff --git a/apps/web/src/features/counterfactual/hooks/usePendingSafeNotifications.ts b/apps/web/src/features/counterfactual/hooks/usePendingSafeNotifications.ts index c7ff761840..948b5b5e94 100644 --- a/apps/web/src/features/counterfactual/hooks/usePendingSafeNotifications.ts +++ b/apps/web/src/features/counterfactual/hooks/usePendingSafeNotifications.ts @@ -1,15 +1,13 @@ import { SafeCreationEvent, safeCreationSubscribe } from '@/features/counterfactual/services/safeCreationEvents' -import useWallet from '@/hooks/wallets/useWallet' -import { useGetAllOwnedSafesQuery } from '@/store/api/gateway' -import { getBlockExplorerLink } from '@/utils/chains' -import { skipToken } from '@reduxjs/toolkit/query' +import { getBlockExplorerLink } from '@safe-global/utils/utils/chains' import { useEffect } from 'react' -import { formatError } from '@/utils/formatters' +import { formatError } from '@safe-global/utils/utils/formatters' import { showNotification } from '@/store/notificationsSlice' import { useAppDispatch } from '@/store' import { useCurrentChain } from '@/hooks/useChains' import useSafeAddress from '@/hooks/useSafeAddress' import { isWalletRejection } from '@/utils/wallets' +import { cgwApi } from '@safe-global/store/gateway/AUTO_GENERATED/owners' const SafeCreationNotifications = { [SafeCreationEvent.PROCESSING]: 'Validating...', @@ -29,8 +27,6 @@ const usePendingSafeNotifications = (): void => { const dispatch = useAppDispatch() const chain = useCurrentChain() const safeAddress = useSafeAddress() - const { address = '' } = useWallet() || {} - const { refetch } = useGetAllOwnedSafesQuery(address === '' ? skipToken : { walletAddress: address }) useEffect(() => { if (!chain) return @@ -50,7 +46,7 @@ const usePendingSafeNotifications = (): void => { // Fetch all owned safes after the Safe has been deployed if (isSuccess) { - refetch() + dispatch(cgwApi.util.invalidateTags(['owners'])) } dispatch( @@ -69,7 +65,7 @@ const usePendingSafeNotifications = (): void => { return () => { unsubFns.forEach((unsub) => unsub()) } - }, [dispatch, safeAddress, chain, refetch]) + }, [dispatch, safeAddress, chain]) } export default usePendingSafeNotifications diff --git a/apps/web/src/features/counterfactual/hooks/usePendingSafeStatuses.ts b/apps/web/src/features/counterfactual/hooks/usePendingSafeStatuses.ts index 70ee6b03dd..4644f6c444 100644 --- a/apps/web/src/features/counterfactual/hooks/usePendingSafeStatuses.ts +++ b/apps/web/src/features/counterfactual/hooks/usePendingSafeStatuses.ts @@ -1,12 +1,10 @@ import { pollSafeInfo } from '@/components/new-safe/create/logic' -import { PayMethod } from '@/features/counterfactual/PayNowPayLater' import { safeCreationDispatch, SafeCreationEvent, safeCreationSubscribe, } from '@/features/counterfactual/services/safeCreationEvents' import { - PendingSafeStatus, removeUndeployedSafe, selectUndeployedSafes, updateUndeployedSafeStatus, @@ -20,6 +18,8 @@ import { useAppDispatch, useAppSelector } from '@/store' import { useEffect, useRef } from 'react' import { isSmartContract } from '@/utils/wallets' import { gtmSetSafeAddress } from '@/services/analytics/gtm' +import { PendingSafeStatus } from '@safe-global/utils/features/counterfactual/store/types' +import { PayMethod } from '@safe-global/utils/features/counterfactual/types' export const safeCreationPendingStatuses: Partial<Record<SafeCreationEvent, PendingSafeStatus | null>> = { [SafeCreationEvent.AWAITING_EXECUTION]: PendingSafeStatus.AWAITING_EXECUTION, diff --git a/apps/web/src/features/counterfactual/services/safeCreationEvents.ts b/apps/web/src/features/counterfactual/services/safeCreationEvents.ts index 1923dd0a13..338ffe3af7 100644 --- a/apps/web/src/features/counterfactual/services/safeCreationEvents.ts +++ b/apps/web/src/features/counterfactual/services/safeCreationEvents.ts @@ -1,6 +1,6 @@ -import type { PayMethod } from '@/features/counterfactual/PayNowPayLater' import EventBus from '@/services/EventBus' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { PayMethod } from '@safe-global/utils/features/counterfactual/types' export enum SafeCreationEvent { AWAITING_EXECUTION = 'AWAITING_EXECUTION', diff --git a/apps/web/src/features/counterfactual/store/undeployedSafesSlice.ts b/apps/web/src/features/counterfactual/store/undeployedSafesSlice.ts index d24f9909fe..caeb516679 100644 --- a/apps/web/src/features/counterfactual/store/undeployedSafesSlice.ts +++ b/apps/web/src/features/counterfactual/store/undeployedSafesSlice.ts @@ -1,54 +1,15 @@ -import type { PayMethod } from '@/features/counterfactual/PayNowPayLater' import { type RootState } from '@/store' import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' import type { PredictedSafeProps } from '@safe-global/protocol-kit' import { selectChainIdAndSafeAddress, selectSafeAddress } from '@/store/common' -import { type SafeVersion } from '@safe-global/safe-core-sdk-types' - -export enum PendingSafeStatus { - AWAITING_EXECUTION = 'AWAITING_EXECUTION', - PROCESSING = 'PROCESSING', - RELAYING = 'RELAYING', -} - -type UndeployedSafeStatus = { - status: PendingSafeStatus - type: PayMethod - txHash?: string - taskId?: string - startBlock?: number - submittedAt?: number - signerAddress?: string - signerNonce?: number | null -} - -export type ReplayedSafeProps = { - factoryAddress: string - masterCopy: string - safeAccountConfig: { - threshold: number - owners: string[] - fallbackHandler: string - to: string - data: string - paymentToken?: string - payment?: number - paymentReceiver: string - } - saltNonce: string - safeVersion: SafeVersion -} - -export type UndeployedSafeProps = PredictedSafeProps | ReplayedSafeProps - -export type UndeployedSafe = { - status: UndeployedSafeStatus - props: UndeployedSafeProps -} - -type UndeployedSafesSlice = { [address: string]: UndeployedSafe } - -export type UndeployedSafesState = { [chainId: string]: UndeployedSafesSlice } +import type { + ReplayedSafeProps, + UndeployedSafe, + UndeployedSafesState, + UndeployedSafeStatus, +} from '@safe-global/utils/features/counterfactual/store/types' +import { PendingSafeStatus } from '@safe-global/utils/features/counterfactual/store/types' +import type { PayMethod } from '@safe-global/utils/features/counterfactual/types' const initialState: UndeployedSafesState = {} diff --git a/apps/web/src/features/counterfactual/useCounterfactualBalances.ts b/apps/web/src/features/counterfactual/useCounterfactualBalances.ts new file mode 100644 index 0000000000..8b7a9c30ac --- /dev/null +++ b/apps/web/src/features/counterfactual/useCounterfactualBalances.ts @@ -0,0 +1,18 @@ +import { type SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' +import { getCounterfactualBalance } from '@/features/counterfactual/utils' +import { useWeb3 } from '@/hooks/wallets/web3' +import type { ExtendedSafeInfo } from '@safe-global/store/slices/SafeInfo/types' +import useAsync from '@safe-global/utils/hooks/useAsync' +import { useCurrentChain } from '@/hooks/useChains' + +export function useCounterfactualBalances(safe: ExtendedSafeInfo) { + const web3 = useWeb3() + const chain = useCurrentChain() + const safeAddress = safe.address.value + const isCounterfactual = !safe.deployed + + return useAsync<SafeBalanceResponse | undefined>(() => { + if (!chain || !isCounterfactual || !safeAddress) return + return getCounterfactualBalance(safeAddress, web3, chain) + }, [chain, safeAddress, web3, isCounterfactual]) +} diff --git a/apps/web/src/features/counterfactual/utils.ts b/apps/web/src/features/counterfactual/utils.ts index 5afc33cd05..e0f7ce60f9 100644 --- a/apps/web/src/features/counterfactual/utils.ts +++ b/apps/web/src/features/counterfactual/utils.ts @@ -1,38 +1,37 @@ -import { getLatestSafeVersion } from '@/utils/chains' import { POLLING_INTERVAL } from '@/config/constants' -import type { PayMethod } from '@/features/counterfactual/PayNowPayLater' import { safeCreationDispatch, SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' -import { - addUndeployedSafe, - type UndeployedSafeProps, - type ReplayedSafeProps, - type UndeployedSafe, - PendingSafeStatus, -} from '@/features/counterfactual/store/undeployedSafesSlice' +import { addUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' import { getWeb3ReadOnly } from '@/hooks/wallets/web3' -import { asError } from '@/services/exceptions/utils' -import ExternalStore from '@/services/ExternalStore' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { getSafeSDKWithSigner, getUncheckedSigner, tryOffChainTxSigning } from '@/services/tx/tx-sender/sdk' import { getRelayTxStatus, TaskState } from '@/services/tx/txMonitor' import type { AppDispatch } from '@/store' -import { defaultSafeInfo } from '@/store/safeInfoSlice' +import { defaultSafeInfo } from '@safe-global/store/slices/SafeInfo/utils' import { didRevert, type EthersError } from '@/utils/ethers-utils' import { assertProvider, assertTx, assertWallet } from '@/utils/helpers' -import { type DeploySafeProps, type PredictedSafeProps } from '@safe-global/protocol-kit' +import { type PredictedSafeProps } from '@safe-global/protocol-kit' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' -import type { SafeTransaction, SafeVersion, TransactionOptions } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction, SafeVersion, TransactionOptions } from '@safe-global/types-kit' import { type ChainInfo, ImplementationVersionState, type SafeBalanceResponse, TokenType, } from '@safe-global/safe-gateway-typescript-sdk' -import type { BrowserProvider, ContractTransactionResponse, Eip1193Provider, Provider } from 'ethers' +import type { BrowserProvider, Eip1193Provider, Provider, TransactionResponse } from 'ethers' import { getSafeL2SingletonDeployments, getSafeSingletonDeployments } from '@safe-global/safe-deployments' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { encodeSafeCreationTx } from '@/components/new-safe/create/logic' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' +import type { + ReplayedSafeProps, + UndeployedSafe, + UndeployedSafeProps, +} from '@safe-global/utils/features/counterfactual/store/types' +import { PendingSafeStatus } from '@safe-global/utils/features/counterfactual/store/types' +import type { PayMethod } from '@safe-global/utils/features/counterfactual/types' export const getUndeployedSafeInfo = (undeployedSafe: UndeployedSafe, address: string, chain: ChainInfo) => { const safeSetup = extractCounterfactualSafeSetup(undeployedSafe, chain.chainId) @@ -67,7 +66,7 @@ export const dispatchTxExecutionAndDeploySafe = async ( const sdk = await getSafeSDKWithSigner(provider) const eventParams = { groupKey: CF_TX_GROUP_KEY } - let result: ContractTransactionResponse | undefined + let result: TransactionResponse | undefined try { const signedTx = await tryOffChainTxSigning(safeTx, sdk) const signer = await getUncheckedSigner(provider) @@ -77,7 +76,6 @@ export const dispatchTxExecutionAndDeploySafe = async ( // We need to estimate the actual gasLimit after the user has signed since it is more accurate than what useDeployGasLimit returns const gas = await signer.estimateGas({ data: deploymentTx.data, value: deploymentTx.value, to: deploymentTx.to }) - // @ts-ignore TODO: Check why TransactionResponse type doesn't work result = await signer.sendTransaction({ ...deploymentTx, gasLimit: gas }) } catch (error) { safeCreationDispatch(SafeCreationEvent.FAILED, { ...eventParams, error: asError(error), safeAddress }) @@ -103,14 +101,7 @@ export const deploySafeAndExecuteTx = async ( return dispatchTxExecutionAndDeploySafe(safeTx, txOptions, provider, safeAddress) } -export const { getStore: getNativeBalance, setStore: setNativeBalance } = new ExternalStore<bigint>(0n) - -export const getCounterfactualBalance = async ( - safeAddress: string, - provider?: BrowserProvider, - chain?: ChainInfo, - ignoreCache?: boolean, -) => { +export const getCounterfactualBalance = async (safeAddress: string, provider?: BrowserProvider, chain?: ChainInfo) => { let balance: bigint | undefined if (!chain) return undefined @@ -120,10 +111,7 @@ export const getCounterfactualBalance = async ( if (provider) { balance = await provider.getBalance(safeAddress) } else { - const cachedBalance = getNativeBalance() - const useCache = cachedBalance !== undefined && cachedBalance > 0n && !ignoreCache - balance = useCache ? cachedBalance : ((await getWeb3ReadOnly()?.getBalance(safeAddress)) ?? 0n) - setNativeBalance(balance) + balance = (await getWeb3ReadOnly()?.getBalance(safeAddress)) ?? 0n } return <SafeBalanceResponse>{ @@ -377,7 +365,7 @@ export const activateReplayedSafe = async ( chain: ChainInfo, props: ReplayedSafeProps, provider: BrowserProvider, - options: DeploySafeProps['options'], + options: TransactionOptions, ) => { const data = encodeSafeCreationTx(props, chain) diff --git a/apps/web/src/features/earn/components/EarnButton/index.tsx b/apps/web/src/features/earn/components/EarnButton/index.tsx new file mode 100644 index 0000000000..d9622063b4 --- /dev/null +++ b/apps/web/src/features/earn/components/EarnButton/index.tsx @@ -0,0 +1,62 @@ +import CheckWallet from '@/components/common/CheckWallet' +import Track from '@/components/common/Track' +import { AppRoutes } from '@/config/routes' +import useSpendingLimit from '@/hooks/useSpendingLimit' +import { Button } from '@mui/material' +import type { TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { useRouter } from 'next/router' +import type { ReactElement } from 'react' +import EarnIcon from '@/public/images/common/earn.svg' +import { EARN_EVENTS, type EARN_LABELS } from '@/services/analytics/events/earn' +import { useCurrentChain } from '@/hooks/useChains' +import css from './styles.module.css' +import classnames from 'classnames' + +const EarnButton = ({ + tokenInfo, + trackingLabel, + compact = true, +}: { + tokenInfo: TokenInfo + trackingLabel: EARN_LABELS + compact?: boolean +}): ReactElement => { + const spendingLimit = useSpendingLimit(tokenInfo) + const chain = useCurrentChain() + const router = useRouter() + + const onEarnClick = () => { + router.push({ + pathname: AppRoutes.earn, + query: { + ...router.query, + asset_id: `${chain?.chainId}_${tokenInfo.address}`, + }, + }) + } + + return ( + <CheckWallet allowSpendingLimit={!!spendingLimit}> + {(isOk) => ( + <Track {...EARN_EVENTS.OPEN_EARN_PAGE} label={trackingLabel}> + <Button + className={classnames({ [css.button]: compact, [css.buttonDisabled]: !isOk })} + data-testid="earn-btn" + aria-label="Earn" + variant={compact ? 'text' : 'contained'} + color={compact ? 'info' : 'background.paper'} + size={compact ? 'small' : 'compact'} + disableElevation + startIcon={<EarnIcon />} + onClick={onEarnClick} + disabled={!isOk} + > + Earn + </Button> + </Track> + )} + </CheckWallet> + ) +} + +export default EarnButton diff --git a/apps/web/src/features/earn/components/EarnButton/styles.module.css b/apps/web/src/features/earn/components/EarnButton/styles.module.css new file mode 100644 index 0000000000..1f558c9960 --- /dev/null +++ b/apps/web/src/features/earn/components/EarnButton/styles.module.css @@ -0,0 +1,16 @@ +.button { + padding: 0 4px; + border: 1px solid var(--color-info-light); + color: var(--color-info-dark); + border-radius: 100px; + font-size: 13px; +} + +.button :global .MuiButton-startIcon { + margin-left: 0; + margin-right: 4px; +} + +.buttonDisabled { + border-color: var(--color-text-disabled); +} diff --git a/apps/web/src/features/earn/components/EarnInfo/index.tsx b/apps/web/src/features/earn/components/EarnInfo/index.tsx new file mode 100644 index 0000000000..32c1b650a3 --- /dev/null +++ b/apps/web/src/features/earn/components/EarnInfo/index.tsx @@ -0,0 +1,254 @@ +import { Card, Box, Grid2 as Grid, Typography, Button, SvgIcon, Stack, Tooltip } from '@mui/material' +import Image from 'next/image' +import EarnIllustrationLight from '@/public/images/common/earn-illustration-light.png' + +import CheckIcon from '@/public/images/common/check.svg' +import StarIcon from '@/public/images/common/star.svg' +import EyeIcon from '@/public/images/common/eye.svg' +import FiatIcon from '@/public/images/common/fiat.svg' +import Track from '@/components/common/Track' +import useBalances from '@/hooks/useBalances' +import { EligibleEarnTokens, VaultAPYs } from '@/features/earn/constants' +import useChainId from '@/hooks/useChainId' +import TokenIcon from '@/components/common/TokenIcon' +import TokenAmount from '@/components/common/TokenAmount' +import FiatValue from '@/components/common/FiatValue' +import { formatPercentage } from '@safe-global/utils/utils/formatters' +import css from './styles.module.css' +import Kiln from '@/public/images/common/kiln-symbol.svg' +import Morpho from '@/public/images/common/morpho-symbol.svg' +import Cross from '@/public/images/common/cross.svg' +import classNames from 'classnames' +import { useDarkMode } from '@/hooks/useDarkMode' +import { useRouter } from 'next/router' +import { AppRoutes } from '@/config/routes' +import { trackEvent } from '@/services/analytics' +import { EARN_EVENTS, EARN_LABELS } from '@/services/analytics/events/earn' +import ExternalLink from '@/components/common/ExternalLink' +import { APYDisclaimer, EARN_HELP_ARTICLE, ApproximateAPY } from '@/features/earn/constants' + +export const EarnPoweredBy = () => { + const isDarkMode = useDarkMode() + + return ( + <Stack spacing={1} direction="row"> + <Typography variant="overline" color="text.secondary" fontWeight="bold"> + Powered by + </Typography> + <SvgIcon + component={Morpho} + inheritViewBox + color="border" + className={classNames(css.morphoIcon, { [css.kilnIconDarkMode]: isDarkMode })} + /> + <SvgIcon + component={Cross} + inheritViewBox + color="border" + sx={{ width: 12, height: 12 }} + className={classNames({ [css.kilnIconDarkMode]: isDarkMode })} + /> + <SvgIcon + component={Kiln} + inheritViewBox + color="border" + className={classNames(css.kilnIcon, { [css.kilnIconDarkMode]: isDarkMode })} + /> + </Stack> + ) +} + +export const EarnBannerCopy = () => { + const isDarkMode = useDarkMode() + + return ( + <> + <Typography variant="h2" className={classNames(css.header, { [css.gradientText]: isDarkMode })}> + Earn up to{' '} + <Typography className={classNames({ [css.gradientText]: isDarkMode })} variant="h2" component="span"> + {formatPercentage(ApproximateAPY)} APY* + </Typography>{' '} + and get MORPHO rewards + </Typography> + + <Typography variant="body1" className={css.content} mt={2}> + Deposit stablecoins, wstETH, ETH, and WBTC straight from your account and let your assets compound in minutes.{' '} + <Track {...EARN_EVENTS.OPEN_EARN_LEARN_MORE} label={EARN_LABELS.safe_dashboard_banner}> + <ExternalLink href={EARN_HELP_ARTICLE}>Learn more</ExternalLink> + </Track> + </Typography> + </> + ) +} + +const EarnInfo = ({ onGetStarted }: { onGetStarted: () => void }) => { + const { balances } = useBalances() + const chainId = useChainId() + const router = useRouter() + + const eligibleAssets = balances.items.filter((token) => EligibleEarnTokens[chainId].includes(token.tokenInfo.address)) + + return ( + <Box m={3}> + <Card sx={{ p: 4 }}> + <Grid container spacing={3}> + <Grid container size={{ xs: 12, md: 7 }} rowSpacing={3}> + <Grid size={{ xs: 12 }} zIndex={2}> + <EarnPoweredBy /> + </Grid> + + <Grid size={{ xs: 12 }} zIndex={2} maxWidth={600}> + <EarnBannerCopy /> + </Grid> + + <Grid container size={{ xs: 12 }} textAlign="center" spacing={2}> + <Grid size={{ xs: 12, md: 'auto' }}> + <Track {...EARN_EVENTS.GET_STARTED_WITH_EARN}> + <Button fullWidth variant="contained" onClick={onGetStarted}> + Get started + </Button> + </Track> + </Grid> + </Grid> + </Grid> + + <Grid + size={{ xs: 12, md: 5 }} + display={{ xs: 'none', sm: 'flex' }} + position="relative" + sx={{ backgroundColor: 'background.main', alignItems: 'center', justifyContent: 'center' }} + > + <Image src={EarnIllustrationLight} alt="Earn illustration" width={239} height={239} /> + </Grid> + </Grid> + </Card> + + <Grid container spacing={3}> + <Grid size={{ xs: 12, md: 'grow' }}> + <Typography variant="h3" mt={3} mb={2} fontWeight="bold"> + Your benefits + </Typography> + <Card sx={{ p: 4 }}> + <Stack spacing={2}> + <Stack direction="row" spacing={2}> + <Box className={css.benefitIcon}> + <SvgIcon component={CheckIcon} color="success" inheritViewBox fontSize="small" /> + </Box> + <Box> + <Typography fontWeight="bold" mb={0.5}> + Never leave the app + </Typography> + <Typography>Interact with your assets right in Safe Wallet UI.</Typography> + </Box> + </Stack> + + <Stack direction="row" spacing={2} className={css.benefit}> + <Box className={css.benefitIcon}> + <SvgIcon component={StarIcon} color="success" inheritViewBox fontSize="small" /> + </Box> + <Box> + <Typography fontWeight="bold" mb={0.5}> + Collect earnings every day + </Typography> + <Typography>Your balance keeps working for you.</Typography> + </Box> + </Stack> + + <Stack direction="row" spacing={2} className={css.benefit}> + <Box className={css.benefitIcon}> + <SvgIcon component={EyeIcon} color="success" inheritViewBox fontSize="small" /> + </Box> + <Box> + <Typography fontWeight="bold" mb={0.5}> + Understand every transaction + </Typography> + <Typography>User-friendly transactions that are easy to understand for all signers.</Typography> + </Box> + </Stack> + + <Stack direction="row" spacing={2} className={css.benefit}> + <Box className={css.benefitIcon}> + <SvgIcon component={FiatIcon} color="success" inheritViewBox fontSize="small" /> + </Box> + <Box> + <Typography fontWeight="bold" mb={0.5}> + Cash out whenever you want + </Typography> + <Typography>Zero lock-ups, zero penalties.</Typography> + </Box> + </Stack> + </Stack> + </Card> + </Grid> + + {eligibleAssets.length > 0 && ( + <Grid size={{ xs: 12, md: 'grow' }}> + <Typography variant="h3" mt={3} mb={2} fontWeight="bold"> + Eligible assets + </Typography> + + <Stack spacing={2}> + {eligibleAssets.map((asset) => { + const vaultAPY = formatPercentage(VaultAPYs[chainId][asset.tokenInfo.address] / 100) + + const onEarnClick = () => { + onGetStarted() + + trackEvent({ ...EARN_EVENTS.OPEN_EARN_PAGE, label: EARN_LABELS.info_asset }) + + router.push({ + pathname: AppRoutes.earn, + query: { + ...router.query, + asset_id: `${chainId}_${asset.tokenInfo.address}`, + }, + }) + } + + return ( + <Card key={asset.tokenInfo.address} sx={{ p: 2 }}> + <Stack direction="row" justifyContent="space-between" alignItems="center" spacing={1}> + <Stack direction="row" spacing={2} alignItems="center"> + <TokenIcon logoUri={asset.tokenInfo.logoUri} tokenSymbol={asset.tokenInfo.symbol} size={32} /> + <Box> + <Typography variant="body2"> + <TokenAmount + value={asset.balance} + decimals={asset.tokenInfo.decimals} + tokenSymbol={asset.tokenInfo.symbol} + logoUri={undefined} + /> + </Typography> + <Typography variant="body2"> + <FiatValue value={asset.fiatBalance} /> + </Typography> + </Box> + </Stack> + <Stack direction="row" spacing={2} alignItems="center"> + <Tooltip title="as of 03.06.2025"> + <Typography variant="caption" className={css.apy}> + Up to {vaultAPY}* + </Typography> + </Tooltip> + + <Button variant="outlined" size="small" onClick={onEarnClick}> + Earn + </Button> + </Stack> + </Stack> + </Card> + ) + })} + </Stack> + </Grid> + )} + </Grid> + + <Typography component="div" variant="caption" zIndex={2} mt={2}> + {APYDisclaimer} + </Typography> + </Box> + ) +} + +export default EarnInfo diff --git a/apps/web/src/features/earn/components/EarnInfo/styles.module.css b/apps/web/src/features/earn/components/EarnInfo/styles.module.css new file mode 100644 index 0000000000..279cc896a9 --- /dev/null +++ b/apps/web/src/features/earn/components/EarnInfo/styles.module.css @@ -0,0 +1,91 @@ +.benefitIcon { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: var(--color-success-background); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: var(--space-2); + flex-shrink: 0; +} + +.benefit { + border-top: 1px solid var(--color-border-light); + padding-top: var(--space-2); +} + +.apy { + padding: 4px var(--space-1); + background: var(--color-text-disabled); + border-radius: 4px; +} + +.bannerWrapper { + position: relative; + border: none; + margin: 0; + padding: var(--space-4); +} + +.earnIllustration { + position: absolute; + height: inherit; + right: var(--space-10); +} + +.gradientShadow { + width: 400px; + height: 243px; + background: linear-gradient(#b0ffc9, #5fddff); + filter: blur(40px); + position: absolute; + right: 0; + top: var(--space-8); + border-radius: 50%; +} + +.gradientShadowDarkMode { + background: linear-gradient(#04491a, #087796); +} + +.kilnIcon { + height: 20px; + width: inherit; + margin-top: -5px !important; +} + +.kilnIconDarkMode path { + fill: var(--color-primary-light); +} + +.morphoIcon { + height: 20px; + width: inherit; + margin-top: -5px !important; +} + +.morphoIconDarkMode path { + fill: var(--color-primary-light); +} + +.gradientText { + background: linear-gradient(225deg, #5fddff 12.5%, #12ff80 88.07%); + background-clip: text; + color: transparent; +} + +.header { + max-width: 600px; + font-weight: bold; +} + +.content { + max-width: 550px; +} + +@media (max-width: 899.99px) { + .header { + padding: 0; + } +} diff --git a/apps/web/src/features/earn/components/EarnView/index.tsx b/apps/web/src/features/earn/components/EarnView/index.tsx new file mode 100644 index 0000000000..d7dd11275e --- /dev/null +++ b/apps/web/src/features/earn/components/EarnView/index.tsx @@ -0,0 +1,17 @@ +import EarnInfo from '@/features/earn/components/EarnInfo' +import EarnWidget from '@/features/earn/components/EarnWidget' +import { useRouter } from 'next/router' +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import { hideEarnInfoStorageKey } from '@/features/earn/constants' + +const EarnView = () => { + const [infoHidden = false, setInfoHidden] = useLocalStorage<boolean>(hideEarnInfoStorageKey) + const router = useRouter() + const { asset_id } = router.query + + if (infoHidden) return <EarnWidget asset={asset_id ? String(asset_id) : undefined} /> + + return <EarnInfo onGetStarted={() => setInfoHidden(true)} /> +} + +export default EarnView diff --git a/apps/web/src/features/earn/components/EarnWidget/index.tsx b/apps/web/src/features/earn/components/EarnWidget/index.tsx new file mode 100644 index 0000000000..3b4b954a66 --- /dev/null +++ b/apps/web/src/features/earn/components/EarnWidget/index.tsx @@ -0,0 +1,32 @@ +import { useMemo } from 'react' +import AppFrame from '@/components/safe-apps/AppFrame' +import { getEmptySafeApp } from '@/components/safe-apps/utils' +import { widgetAppData } from '@/features/earn/constants' +import useGetWidgetUrl from '@/features/earn/hooks/useGetWidgetUrl' +import { useDarkMode } from '@/hooks/useDarkMode' + +const EarnWidget = ({ asset }: { asset?: string }) => { + const url = useGetWidgetUrl(asset) + const isDarkMode = useDarkMode() + + const appData = useMemo( + () => ({ + ...getEmptySafeApp(), + ...widgetAppData, + iconUrl: isDarkMode ? '/images/common/earn-light.svg' : '/images/common/earn.svg', + url, + }), + [url, isDarkMode], + ) + + return ( + <AppFrame + appUrl={appData.url} + allowedFeaturesList="clipboard-read; clipboard-write" + safeAppFromManifest={appData} + isNativeEmbed + /> + ) +} + +export default EarnWidget diff --git a/apps/web/src/features/earn/components/VaultDepositConfirmation/index.tsx b/apps/web/src/features/earn/components/VaultDepositConfirmation/index.tsx new file mode 100644 index 0000000000..789504e723 --- /dev/null +++ b/apps/web/src/features/earn/components/VaultDepositConfirmation/index.tsx @@ -0,0 +1,191 @@ +import type { VaultDepositTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { Box, Stack, Typography } from '@mui/material' +import TokenIcon from '@/components/common/TokenIcon' +import TokenAmount from '@/components/common/TokenAmount' +import { vaultTypeToLabel } from '@/features/earn/utils' +import { formatPercentage } from '@safe-global/utils/utils/formatters' +import { DataTable } from '@/components/common/Table/DataTable' +import { DataRow } from '@/components/common/Table/DataRow' +import IframeIcon from '@/components/common/IframeIcon' +import { InfoTooltip } from '@/features/stake/components/InfoTooltip' +import { BRAND_NAME } from '@/config/constants' + +const AdditionalRewards = ({ txInfo }: { txInfo: VaultDepositTransactionInfo }) => { + if (!txInfo.additionalRewards[0]) return null + + return ( + <Stack sx={{ border: '1px solid #ddd', borderRadius: '6px', padding: '12px', mt: 1 }}> + <DataTable + header="Additional reward" + rows={[ + <DataRow key="Token" title="Token"> + {txInfo.additionalRewards[0].tokenInfo.name}{' '} + <Typography component="span" color="primary.light"> + {txInfo.additionalRewards[0].tokenInfo.symbol} + </Typography> + </DataRow>, + + <DataRow key="Earn" title="Earn"> + {formatPercentage(txInfo.additionalRewardsNrr / 100)} + </DataRow>, + + <DataRow key="Fee" title="Fee"> + 0% + </DataRow>, + + <Typography + key="Powered by" + variant="caption" + color="text.secondary" + display="flex" + alignItems="center" + gap={0.5} + mt={1} + > + Powered by <IframeIcon src={txInfo.vaultInfo.logoUri} alt="Morpho logo" width={16} height={16} /> Morpho + </Typography>, + ]} + /> + </Stack> + ) +} + +const ConfirmationHeader = ({ txInfo }: { txInfo: VaultDepositTransactionInfo }) => { + const totalNrr = (txInfo.baseNrr + txInfo.additionalRewardsNrr) / 100 + + return ( + <Stack key="amount" direction="row" gap={1} mb={1}> + <Stack + direction="row" + sx={{ + flexWrap: 'wrap', + alignItems: 'center', + width: '50%', + bgcolor: 'border.background', + position: 'relative', + borderRadius: 1, + py: 2, + px: 3, + }} + > + {txInfo.tokenInfo && ( + <Box width={40} mr={2}> + <TokenIcon size={40} logoUri={txInfo.tokenInfo.logoUri || ''} tokenSymbol={txInfo.tokenInfo.symbol} /> + </Box> + )} + + <Box flex={1}> + <Typography variant="body2" color="primary.light"> + {vaultTypeToLabel[txInfo.type]} + </Typography> + + <Typography variant="h4" fontWeight="bold" component="div"> + {txInfo.tokenInfo ? ( + <TokenAmount + tokenSymbol={txInfo.tokenInfo.symbol} + value={txInfo.value} + decimals={txInfo.tokenInfo.decimals} + /> + ) : ( + txInfo.value + )} + </Typography> + </Box> + </Stack> + + <Stack + direction="row" + sx={{ + flexWrap: 'wrap', + alignItems: 'center', + width: '50%', + bgcolor: 'border.background', + position: 'relative', + borderRadius: 1, + py: 2, + px: 3, + }} + > + <Box flex={1}> + <Typography variant="body2" color="primary.light"> + Earn (after fees) + </Typography> + + <Typography variant="h4" fontWeight="bold" component="div"> + {formatPercentage(totalNrr)} + </Typography> + </Box> + </Stack> + </Stack> + ) +} + +const VaultDepositConfirmation = ({ + txInfo, + isTxDetails = false, +}: { + txInfo: VaultDepositTransactionInfo + isTxDetails?: boolean +}) => { + if (!txInfo.vaultInfo) return null + + const annualReward = Number(txInfo.expectedAnnualReward).toFixed(0) + const monthlyReward = Number(txInfo.expectedMonthlyReward).toFixed(0) + + return ( + <> + <DataTable + rows={[ + <>{!isTxDetails && <ConfirmationHeader txInfo={txInfo} />}</>, + + <DataRow key="Deposit via" title="Deposit via"> + <Stack direction="row" alignItems="center"> + <IframeIcon src={txInfo.vaultInfo.logoUri} alt="Morpho logo" width={24} height={24} /> + <Typography component="span" ml={1} fontWeight="bold"> + {txInfo.vaultInfo.name} + </Typography> + </Stack> + </DataRow>, + + <DataRow key="Expected annual reward" title="Exp. annual reward"> + <TokenAmount + tokenSymbol={txInfo.tokenInfo.symbol} + value={annualReward} + decimals={txInfo.tokenInfo.decimals} + /> + </DataRow>, + + <DataRow key="Expected monthly reward" title="Exp. monthly reward"> + <TokenAmount + tokenSymbol={txInfo.tokenInfo.symbol} + value={monthlyReward} + decimals={txInfo.tokenInfo.decimals} + /> + </DataRow>, + + <DataRow + key="Performance fee" + title={ + <> + Performance fee + <InfoTooltip + title={`The performance fee incurred here is charged by Kiln for the operation of this widget. The fee is calculated automatically. Part of the fee will contribute to a license fee that supports the Safe Community. Neither the Safe Ecosystem Foundation nor ${BRAND_NAME} operates the Kiln Widget and/or Kiln.`} + /> + </> + } + > + {formatPercentage(txInfo.fee, true)} + </DataRow>, + + <AdditionalRewards key="Additional rewards" txInfo={txInfo} />, + + <Typography key="Vault description" variant="body2" color="text.secondary" mt={1}> + {txInfo.vaultInfo.description} + </Typography>, + ]} + /> + </> + ) +} + +export default VaultDepositConfirmation diff --git a/apps/web/src/features/earn/components/VaultDepositTxDetails/index.tsx b/apps/web/src/features/earn/components/VaultDepositTxDetails/index.tsx new file mode 100644 index 0000000000..b40d76a0a3 --- /dev/null +++ b/apps/web/src/features/earn/components/VaultDepositTxDetails/index.tsx @@ -0,0 +1,27 @@ +import type { VaultDepositTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import VaultDepositConfirmation from '@/features/earn/components/VaultDepositConfirmation' +import { Box } from '@mui/material' +import FieldsGrid from '@/components/tx/FieldsGrid' +import TokenAmount from '@/components/common/TokenAmount' +import { formatPercentage } from '@safe-global/utils/utils/formatters' + +const VaultDepositTxDetails = ({ info }: { info: VaultDepositTransactionInfo }) => { + const totalNrr = (info.baseNrr + info.additionalRewardsNrr) / 100 + + return ( + <Box pl={1} pr={5} display="flex" flexDirection="column" gap={1}> + <FieldsGrid title="Deposit"> + <TokenAmount + tokenSymbol={info.tokenInfo.symbol} + value={info.value} + logoUri={info.tokenInfo.logoUri || ''} + decimals={info.tokenInfo.decimals} + /> + </FieldsGrid> + <FieldsGrid title="Earn (after fees)">{formatPercentage(totalNrr)}</FieldsGrid> + <VaultDepositConfirmation txInfo={info} isTxDetails /> + </Box> + ) +} + +export default VaultDepositTxDetails diff --git a/apps/web/src/features/earn/components/VaultDepositTxInfo/index.tsx b/apps/web/src/features/earn/components/VaultDepositTxInfo/index.tsx new file mode 100644 index 0000000000..a0082388cd --- /dev/null +++ b/apps/web/src/features/earn/components/VaultDepositTxInfo/index.tsx @@ -0,0 +1,15 @@ +import TokenAmount from '@/components/common/TokenAmount' +import type { VaultDepositTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +const VaultDepositTxInfo = ({ txInfo }: { txInfo: VaultDepositTransactionInfo }) => { + return ( + <TokenAmount + logoUri={txInfo.tokenInfo.logoUri!} + value={txInfo.value} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + /> + ) +} + +export default VaultDepositTxInfo diff --git a/apps/web/src/features/earn/components/VaultRedeemConfirmation/index.tsx b/apps/web/src/features/earn/components/VaultRedeemConfirmation/index.tsx new file mode 100644 index 0000000000..d113688367 --- /dev/null +++ b/apps/web/src/features/earn/components/VaultRedeemConfirmation/index.tsx @@ -0,0 +1,170 @@ +import type { VaultRedeemTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { Box, Stack, Typography } from '@mui/material' +import TokenIcon from '@/components/common/TokenIcon' +import TokenAmount from '@/components/common/TokenAmount' +import { vaultTypeToLabel } from '@/features/earn/utils' +import { formatPercentage } from '@safe-global/utils/utils/formatters' +import { DataTable } from '@/components/common/Table/DataTable' +import { DataRow } from '@/components/common/Table/DataRow' +import IframeIcon from '@/components/common/IframeIcon' + +// TODO: Check if additional rewards can actually appear for a withdraw/redeem +const AdditionalRewards = ({ txInfo }: { txInfo: VaultRedeemTransactionInfo }) => { + if (!txInfo.additionalRewards[0]) return null + + const additionalRewardsClaimable = Number(txInfo.additionalRewards[0].claimable) > 0 + + if (!additionalRewardsClaimable) return null + + return ( + <Stack sx={{ border: '1px solid #ddd', borderRadius: '6px', padding: '12px', mt: 1 }}> + <DataTable + header="Additional reward" + rows={[ + <DataRow key="Token" title="Token"> + {txInfo.additionalRewards[0].tokenInfo.name}{' '} + <Typography component="span" color="primary.light"> + {txInfo.additionalRewards[0].tokenInfo.symbol} + </Typography> + </DataRow>, + + <DataRow key="Earn" title="Earn"> + {formatPercentage(txInfo.additionalRewardsNrr / 100)} + </DataRow>, + + <Typography + key="Powered by" + variant="caption" + color="text.secondary" + display="flex" + alignItems="center" + gap={0.5} + mt={1} + > + Powered by <IframeIcon src={txInfo.vaultInfo.logoUri} alt="Morpho logo" width={16} height={16} /> Morpho + </Typography>, + ]} + /> + </Stack> + ) +} + +const ConfirmationHeader = ({ txInfo }: { txInfo: VaultRedeemTransactionInfo }) => { + return ( + <Stack key="amount" direction="row" gap={1} mb={1}> + <Stack + direction="row" + sx={{ + flexWrap: 'wrap', + alignItems: 'center', + width: '50%', + bgcolor: 'border.background', + position: 'relative', + borderRadius: 1, + py: 2, + px: 3, + }} + > + {txInfo.tokenInfo && ( + <Box width={40} mr={2}> + <TokenIcon size={40} logoUri={txInfo.tokenInfo.logoUri || ''} tokenSymbol={txInfo.tokenInfo.symbol} /> + </Box> + )} + + <Box flex={1}> + <Typography variant="body2" color="primary.light"> + {vaultTypeToLabel[txInfo.type]} + </Typography> + + <Typography variant="h4" fontWeight="bold" component="div"> + {txInfo.tokenInfo ? ( + <TokenAmount + tokenSymbol={txInfo.tokenInfo.symbol} + value={txInfo.value} + decimals={txInfo.tokenInfo.decimals} + /> + ) : ( + txInfo.value + )} + </Typography> + </Box> + </Stack> + + <Stack + direction="row" + sx={{ + flexWrap: 'wrap', + alignItems: 'center', + width: '50%', + bgcolor: 'border.background', + position: 'relative', + borderRadius: 1, + py: 2, + px: 3, + }} + > + <Box flex={1}> + <Typography variant="body2" color="primary.light"> + Current reward + </Typography> + + <Typography variant="h4" fontWeight="bold" component="div"> + <TokenAmount + value={txInfo.currentReward} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + /> + </Typography> + </Box> + </Stack> + </Stack> + ) +} + +const VaultRedeemConfirmation = ({ + txInfo, + isTxDetails = false, +}: { + txInfo: VaultRedeemTransactionInfo + isTxDetails?: boolean +}) => { + return ( + <> + <DataTable + rows={[ + <>{!isTxDetails && <ConfirmationHeader txInfo={txInfo} />}</>, + + <> + {isTxDetails && ( + <DataRow key="Current reward" title="Current reward"> + <TokenAmount + value={txInfo.currentReward} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + logoUri={txInfo.tokenInfo.logoUri ?? undefined} + /> + </DataRow> + )} + </>, + + <DataRow key="Withdraw from" title="Withdraw from"> + <Stack direction="row" alignItems="center"> + <IframeIcon src={txInfo.vaultInfo.logoUri} alt="Morpho logo" width={24} height={24} /> + <Typography component="span" ml={1} fontWeight="bold"> + {txInfo.vaultInfo.name} + </Typography> + </Stack> + </DataRow>, + + <AdditionalRewards key="Additional rewards" txInfo={txInfo} />, + + <Typography key="Vault description" variant="body2" color="text.secondary" mt={1}> + {txInfo.vaultInfo.description} + </Typography>, + ]} + /> + </> + ) +} + +export default VaultRedeemConfirmation diff --git a/apps/web/src/features/earn/components/VaultRedeemTxDetails/index.tsx b/apps/web/src/features/earn/components/VaultRedeemTxDetails/index.tsx new file mode 100644 index 0000000000..aa8b2baf13 --- /dev/null +++ b/apps/web/src/features/earn/components/VaultRedeemTxDetails/index.tsx @@ -0,0 +1,23 @@ +import type { VaultRedeemTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { Box } from '@mui/material' +import FieldsGrid from '@/components/tx/FieldsGrid' +import TokenAmount from '@/components/common/TokenAmount' +import VaultRedeemConfirmation from '@/features/earn/components/VaultRedeemConfirmation' + +const VaultRedeemTxDetails = ({ info }: { info: VaultRedeemTransactionInfo }) => { + return ( + <Box pl={1} pr={5} display="flex" flexDirection="column" gap={1}> + <FieldsGrid title="Withdraw"> + <TokenAmount + tokenSymbol={info.tokenInfo.symbol} + value={info.value} + logoUri={info.tokenInfo.logoUri || ''} + decimals={info.tokenInfo.decimals} + /> + </FieldsGrid> + <VaultRedeemConfirmation txInfo={info} isTxDetails /> + </Box> + ) +} + +export default VaultRedeemTxDetails diff --git a/apps/web/src/features/earn/components/VaultRedeemTxInfo/index.tsx b/apps/web/src/features/earn/components/VaultRedeemTxInfo/index.tsx new file mode 100644 index 0000000000..b4299a6d32 --- /dev/null +++ b/apps/web/src/features/earn/components/VaultRedeemTxInfo/index.tsx @@ -0,0 +1,15 @@ +import TokenAmount from '@/components/common/TokenAmount' +import type { VaultDepositTransactionInfo } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +const VaultRedeemTxInfo = ({ txInfo }: { txInfo: VaultDepositTransactionInfo }) => { + return ( + <TokenAmount + logoUri={txInfo.tokenInfo.logoUri!} + value={txInfo.value} + tokenSymbol={txInfo.tokenInfo.symbol} + decimals={txInfo.tokenInfo.decimals} + /> + ) +} + +export default VaultRedeemTxInfo diff --git a/apps/web/src/features/earn/constants.ts b/apps/web/src/features/earn/constants.ts new file mode 100644 index 0000000000..f52d52ef05 --- /dev/null +++ b/apps/web/src/features/earn/constants.ts @@ -0,0 +1,46 @@ +export const EARN_TITLE = 'Earn' +export const WIDGET_TESTNET_URL = 'https://safe.widget.testnet.kiln.fi/earn' +export const WIDGET_PRODUCTION_URL = 'https://safe-defi.widget.kiln.fi/earn' +export const EARN_CONSENT_STORAGE_KEY = 'lendDisclaimerAcceptedV1' +export const EARN_HELP_ARTICLE = 'https://help.safe.global/en/articles/322149-defi-lending-in-safe-wallet' + +export const widgetAppData = { + url: WIDGET_TESTNET_URL, + name: EARN_TITLE, + chainIds: ['1', '8453'], +} + +export const hideEarnInfoStorageKey = 'hideEarnInfoV2' + +export const EligibleEarnTokens: Record<string, string[]> = { + '1': [ + '0xdAC17F958D2ee523a2206206994597C13D831ec7', // USDT + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC + '0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0', // wstETH + '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', // WBTC + ], + '8453': [ + '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC + '0x4200000000000000000000000000000000000006', // WETH + ], +} + +// Vault APYs as of 03.06.2025 +export const VaultAPYs: Record<string, Record<string, number>> = { + '1': { + '0xdAC17F958D2ee523a2206206994597C13D831ec7': 3.55, + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2': 3.89, + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': 3.78, + '0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0': 0.68, + '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599': 0.34, + }, + '8453': { + '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913': 5.6, + '0x4200000000000000000000000000000000000006': 2.87, + }, +} + +export const ApproximateAPY = 0.081 +export const APYDisclaimer = + '* based on historic averages of USD stablecoin and ETH Morpho vaults. Yields are variable and subject to change. Past performance is not a guarantee of future returns. The Kiln DeFi, Morpho Borrow and Vault products and features described herein are not offered or controlled by Core Contributors GmbH, Safe Ecosystem Foundation, and/or its affiliates.' diff --git a/apps/web/src/features/earn/hooks/useGetWidgetUrl.ts b/apps/web/src/features/earn/hooks/useGetWidgetUrl.ts new file mode 100644 index 0000000000..d9edd1d1be --- /dev/null +++ b/apps/web/src/features/earn/hooks/useGetWidgetUrl.ts @@ -0,0 +1,25 @@ +import { useDarkMode } from '@/hooks/useDarkMode' +import { WIDGET_TESTNET_URL, WIDGET_PRODUCTION_URL } from '@/features/earn/constants' +import useChains from '@/hooks/useChains' +import { useMemo } from 'react' +import useChainId from '@/hooks/useChainId' + +const useGetWidgetUrl = (asset?: string) => { + let url = WIDGET_PRODUCTION_URL + const currentChainId = useChainId() + const { configs } = useChains() + const testChains = useMemo(() => configs.filter((chain) => chain.isTestnet), [configs]) + if (testChains.some((chain) => chain.chainId === currentChainId)) { + url = WIDGET_TESTNET_URL + } + + const params = new URLSearchParams() + const isDarkMode = useDarkMode() + + params.append('theme', isDarkMode ? 'dark' : 'light') + if (asset) params.append('asset_id', asset) + + return url + '?' + params.toString() +} + +export default useGetWidgetUrl diff --git a/apps/web/src/features/earn/hooks/useIsEarnFeatureEnabled.ts b/apps/web/src/features/earn/hooks/useIsEarnFeatureEnabled.ts new file mode 100644 index 0000000000..61126d1b00 --- /dev/null +++ b/apps/web/src/features/earn/hooks/useIsEarnFeatureEnabled.ts @@ -0,0 +1,11 @@ +import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' +import { useContext } from 'react' +import { GeoblockingContext } from '@/components/common/GeoblockingProvider' + +const useIsEarnFeatureEnabled = () => { + const isBlockedCountry = useContext(GeoblockingContext) + return useHasFeature(FEATURES.EARN) && !isBlockedCountry +} + +export default useIsEarnFeatureEnabled diff --git a/apps/web/src/features/earn/index.tsx b/apps/web/src/features/earn/index.tsx new file mode 100644 index 0000000000..23420377cf --- /dev/null +++ b/apps/web/src/features/earn/index.tsx @@ -0,0 +1,56 @@ +import { Stack } from '@mui/material' +import Disclaimer from '@/components/common/Disclaimer' +import WidgetDisclaimer from '@/components/common/WidgetDisclaimer' +import BlockedAddress from '@/components/common/BlockedAddress' +import useBlockedAddress from '@/hooks/useBlockedAddress' +import useConsent from '@/hooks/useConsent' +import { EARN_CONSENT_STORAGE_KEY } from '@/features/earn/constants' +import EarnView from '@/features/earn/components/EarnView' + +const EarnPage = () => { + const { isConsentAccepted, onAccept } = useConsent(EARN_CONSENT_STORAGE_KEY) + const blockedAddress = useBlockedAddress() + + if (blockedAddress) { + return ( + <Stack + direction="column" + sx={{ + alignItems: 'center', + justifyContent: 'center', + flex: 1, + }} + > + <BlockedAddress address={blockedAddress} featureTitle="Earn feature with Kiln" /> + </Stack> + ) + } + + if (isConsentAccepted === undefined) return null + + return ( + <> + {isConsentAccepted ? ( + <EarnView /> + ) : ( + <Stack + direction="column" + sx={{ + alignItems: 'center', + justifyContent: 'center', + flex: 1, + }} + > + <Disclaimer + title="Note" + content={<WidgetDisclaimer widgetName="Earn Widget by Kiln" />} + onAccept={onAccept} + buttonText="Continue" + /> + </Stack> + )} + </> + ) +} + +export default EarnPage diff --git a/apps/web/src/features/earn/utils.ts b/apps/web/src/features/earn/utils.ts new file mode 100644 index 0000000000..27125771a0 --- /dev/null +++ b/apps/web/src/features/earn/utils.ts @@ -0,0 +1,10 @@ +import { EligibleEarnTokens } from '@/features/earn/constants' + +export const vaultTypeToLabel = { + VaultDeposit: 'Deposit', + VaultRedeem: 'Withdraw', +} + +export const isEligibleEarnToken = (chainId: string, tokenAddress: string) => { + return EligibleEarnTokens[chainId]?.includes(tokenAddress) +} diff --git a/apps/web/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/apps/web/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index df7185c268..81142a9f29 100644 --- a/apps/web/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/apps/web/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -16,15 +16,15 @@ import { useAppDispatch, useAppSelector } from '@/store' import { selectRpc } from '@/store/settingsSlice' import { createWeb3ReadOnly } from '@/hooks/wallets/web3' import { hasMultiChainAddNetworkFeature, predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import ExternalLink from '@/components/common/ExternalLink' import { useRouter } from 'next/router' import ChainIndicator from '@/components/common/ChainIndicator' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { useMemo, useState } from 'react' import { useCompatibleNetworks } from '../../hooks/useCompatibleNetworks' -import { PayMethod } from '@/features/counterfactual/PayNowPayLater' import { MULTICHAIN_HELP_ARTICLE } from '@/config/constants' +import { PayMethod } from '@safe-global/utils/features/counterfactual/types' type CreateSafeOnNewChainForm = { chainId: string diff --git a/apps/web/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx b/apps/web/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx index 6043d68e5d..b8739462ce 100644 --- a/apps/web/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx +++ b/apps/web/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx @@ -5,7 +5,7 @@ import useSafeAddress from '@/hooks/useSafeAddress' import { useAppSelector } from '@/store' import { selectCurrency, selectUndeployedSafes, useGetMultipleSafeOverviewsQuery } from '@/store/slices' import { useAllSafesGrouped } from '@/features/myAccounts/hooks/useAllSafesGrouped' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { useMemo } from 'react' import { getDeviatingSetups, getSafeSetups } from '@/features/multichain/utils/utils' import { Box, Typography } from '@mui/material' diff --git a/apps/web/src/features/multichain/components/UnsupportedMastercopyWarning/UnsupportedMasterCopyWarning.tsx b/apps/web/src/features/multichain/components/UnsupportedMastercopyWarning/UnsupportedMasterCopyWarning.tsx new file mode 100644 index 0000000000..e5a0df8cee --- /dev/null +++ b/apps/web/src/features/multichain/components/UnsupportedMastercopyWarning/UnsupportedMasterCopyWarning.tsx @@ -0,0 +1,34 @@ +import { TxModalContext } from '@/components/tx-flow' +import { MigrateSafeL2Flow } from '@/components/tx-flow/flows' +import ErrorMessage from '@/components/tx/ErrorMessage' +import useSafeInfo from '@/hooks/useSafeInfo' +import { Button, Stack, Typography } from '@mui/material' +import { useCallback, useContext } from 'react' +import { isMigrationToL2Possible, isValidMasterCopy } from '@safe-global/utils/services/contracts/safeContracts' + +export const UnsupportedMastercopyWarning = () => { + const { safe } = useSafeInfo() + + const showWarning = !isValidMasterCopy(safe.implementationVersionState) && isMigrationToL2Possible(safe) + + const { setTxFlow } = useContext(TxModalContext) + + const openUpgradeModal = useCallback(() => setTxFlow(<MigrateSafeL2Flow />), [setTxFlow]) + + if (!showWarning) return + + return ( + <ErrorMessage level="warning" title="Base contract is not supported"> + <Stack spacing={2}> + <Typography display="inline" mr={1}> + Your Safe Account's base contract is not supported. You should migrate it to a compatible version. + </Typography> + <div> + <Button variant="contained" onClick={openUpgradeModal}> + Migrate + </Button> + </div> + </Stack> + </ErrorMessage> + ) +} diff --git a/apps/web/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts b/apps/web/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts index 699276cb34..c8c012111d 100644 --- a/apps/web/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts +++ b/apps/web/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts @@ -1,6 +1,6 @@ import { renderHook } from '@/tests/test-utils' import { useCompatibleNetworks } from '../useCompatibleNetworks' -import { type ReplayedSafeProps } from '@/store/slices' +import { type ReplayedSafeProps } from '@safe-global/utils/features/counterfactual/store/types' import { faker } from '@faker-js/faker' import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { ECOSYSTEM_ID_ADDRESS } from '@/config/constants' @@ -69,7 +69,7 @@ describe('useCompatibleNetworks', () => { expect(result.current.every((config) => config.available)).toEqual(false) }) - it('should set everything to available except zkSync and GnosisChain Chiado for 1.4.1 Safes', () => { + it('should set everything to available except GnosisChain Chiado for 1.4.1 Safes', () => { const callData = { owners: [faker.finance.ethereumAddress()], threshold: 1, @@ -91,7 +91,7 @@ describe('useCompatibleNetworks', () => { const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(6) expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '324', '480', '10200']) - expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, false, true, false]) + expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, true, true, false]) } { @@ -105,7 +105,7 @@ describe('useCompatibleNetworks', () => { const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(6) expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '324', '480', '10200']) - expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, false, true, false]) + expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, true, true, false]) } }) @@ -133,7 +133,7 @@ describe('useCompatibleNetworks', () => { const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(6) expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '324', '480', '10200']) - expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, false, true, false]) + expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, true, true, false]) } // 1.3.0, L2 and canonical @@ -148,7 +148,7 @@ describe('useCompatibleNetworks', () => { const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(6) expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '324', '480', '10200']) - expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, false, true, true]) + expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, true, true, true]) } // 1.3.0, L1 and EIP155 is not available on Worldchain and Chiado @@ -163,7 +163,7 @@ describe('useCompatibleNetworks', () => { const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(6) expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '324', '480', '10200']) - expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, false, true, false]) + expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, true, true, false]) } // 1.3.0, L2 and EIP155 @@ -178,7 +178,7 @@ describe('useCompatibleNetworks', () => { const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(6) expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '324', '480', '10200']) - expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, false, true, false]) + expect(result.current.map((chain) => chain.available)).toEqual([true, true, true, true, true, false]) } }) diff --git a/apps/web/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts b/apps/web/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts index 3c6aba20e3..dfbf53de45 100644 --- a/apps/web/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts +++ b/apps/web/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts @@ -1,19 +1,19 @@ import { fakerChecksummedAddress, renderHook, waitFor } from '@/tests/test-utils' import { SAFE_CREATION_DATA_ERRORS, useSafeCreationData } from '../useSafeCreationData' import { faker } from '@faker-js/faker' -import { PendingSafeStatus, type UndeployedSafe } from '@/store/slices' -import { PayMethod } from '@/features/counterfactual/PayNowPayLater' +import { PendingSafeStatus, type UndeployedSafe } from '@safe-global/utils/features/counterfactual/store/types' import { chainBuilder } from '@/tests/builders/chains' import * as sdk from '@/services/tx/tx-sender/sdk' import * as cgwSdk from '@safe-global/safe-client-gateway-sdk' import * as web3 from '@/hooks/wallets/web3' import { encodeMultiSendData, type SafeProvider } from '@safe-global/protocol-kit' -import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' +import { Safe__factory, Safe_proxy_factory__factory } from '@safe-global/utils/types/contracts' import { type JsonRpcProvider } from 'ethers' -import { Multi_send__factory } from '@/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' +import { Multi_send__factory } from '@safe-global/utils/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { getSafeSingletonDeployment, getSafeToL2SetupDeployment } from '@safe-global/safe-deployments' +import { PayMethod } from '@safe-global/utils/features/counterfactual/types' const setupToL2Address = getSafeToL2SetupDeployment({ version: '1.4.1' })?.defaultAddress! diff --git a/apps/web/src/features/multichain/hooks/useCompatibleNetworks.ts b/apps/web/src/features/multichain/hooks/useCompatibleNetworks.ts index 881076dd32..32f14441c1 100644 --- a/apps/web/src/features/multichain/hooks/useCompatibleNetworks.ts +++ b/apps/web/src/features/multichain/hooks/useCompatibleNetworks.ts @@ -1,8 +1,7 @@ -import { type ReplayedSafeProps } from '@/features/counterfactual/store/undeployedSafesSlice' import useChains from '@/hooks/useChains' -import { hasCanonicalDeployment, hasMatchingDeployment } from '@/services/contracts/deployments' +import { hasCanonicalDeployment, hasMatchingDeployment } from '@safe-global/utils/services/contracts/deployments' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' -import { type SafeVersion } from '@safe-global/safe-core-sdk-types' +import { type SafeVersion } from '@safe-global/types-kit' import { getCompatibilityFallbackHandlerDeployments, getProxyFactoryDeployments, @@ -12,6 +11,7 @@ import { getSafeToL2SetupDeployments, } from '@safe-global/safe-deployments' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { ReplayedSafeProps } from '@safe-global/utils/features/counterfactual/store/types' const SUPPORTED_VERSIONS: SafeVersion[] = ['1.4.1', '1.3.0'] diff --git a/apps/web/src/features/multichain/hooks/useIsMultichainSafe.ts b/apps/web/src/features/multichain/hooks/useIsMultichainSafe.ts index 0f235feace..0dd0c1aa3c 100644 --- a/apps/web/src/features/multichain/hooks/useIsMultichainSafe.ts +++ b/apps/web/src/features/multichain/hooks/useIsMultichainSafe.ts @@ -1,6 +1,6 @@ import { useAllSafesGrouped } from '@/features/myAccounts/hooks/useAllSafesGrouped' import useSafeAddress from '@/hooks/useSafeAddress' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { useMemo } from 'react' export const useIsMultichainSafe = () => { diff --git a/apps/web/src/features/multichain/hooks/useSafeCreationData.ts b/apps/web/src/features/multichain/hooks/useSafeCreationData.ts index 504064f34c..d3a62cc31d 100644 --- a/apps/web/src/features/multichain/hooks/useSafeCreationData.ts +++ b/apps/web/src/features/multichain/hooks/useSafeCreationData.ts @@ -1,15 +1,16 @@ -import useAsync, { type AsyncResult } from '@/hooks/useAsync' +import useAsync, { type AsyncResult } from '@safe-global/utils/hooks/useAsync' import { createWeb3ReadOnly } from '@/hooks/wallets/web3' -import { type UndeployedSafe, selectRpc, type ReplayedSafeProps, selectUndeployedSafes } from '@/store/slices' -import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' -import { sameAddress } from '@/utils/addresses' +import { selectRpc, selectUndeployedSafes } from '@/store/slices' +import { type UndeployedSafe, type ReplayedSafeProps } from '@safe-global/utils/features/counterfactual/store/types' +import { Safe__factory, Safe_proxy_factory__factory } from '@safe-global/utils/types/contracts' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { getCreationTransaction } from '@safe-global/safe-client-gateway-sdk' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { useAppSelector } from '@/store' import { determineMasterCopyVersion, isPredictedSafeProps } from '@/features/counterfactual/utils' import { logError } from '@/services/exceptions' -import ErrorCodes from '@/services/exceptions/ErrorCodes' -import { asError } from '@/services/exceptions/utils' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' +import { asError } from '@safe-global/utils/services/exceptions/utils' import semverSatisfies from 'semver/functions/satisfies' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { getSafeToL2SetupDeployment } from '@safe-global/safe-deployments' diff --git a/apps/web/src/features/multichain/utils/extract-migration-data.ts b/apps/web/src/features/multichain/utils/extract-migration-data.ts deleted file mode 100644 index 3a8a0efa19..0000000000 --- a/apps/web/src/features/multichain/utils/extract-migration-data.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { TransactionData } from '@safe-global/safe-gateway-typescript-sdk' -import { Safe_to_l2_migration__factory } from '@/types/contracts' -import { sameAddress } from '@/utils/addresses' -import { isMultiSendCalldata } from '@/utils/transaction-calldata' -import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' -import { getSafeToL2MigrationDeployment } from '@safe-global/safe-deployments' - -export const extractMigrationL2MasterCopyAddress = (txData: TransactionData): string | undefined => { - if (!isMultiSendCalldata(txData.hexData || '')) { - return undefined - } - - const innerTxs = decodeMultiSendData(txData.hexData || '') - const firstInnerTx = innerTxs[0] - if (!firstInnerTx) { - return undefined - } - - const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() - const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress - const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() - - if ( - firstInnerTx.data.startsWith(safeToL2MigrationInterface.getFunction('migrateToL2').selector) && - sameAddress(firstInnerTx.to, safeToL2MigrationAddress) - ) { - const callParams = safeToL2MigrationInterface.decodeFunctionData('migrateToL2', firstInnerTx.data) - return callParams[0] - } - - return undefined -} diff --git a/apps/web/src/features/multichain/utils/utils.test.ts b/apps/web/src/features/multichain/utils/utils.test.ts index 4eff5c8ba6..54b0764b9b 100644 --- a/apps/web/src/features/multichain/utils/utils.test.ts +++ b/apps/web/src/features/multichain/utils/utils.test.ts @@ -1,7 +1,8 @@ import { faker } from '@faker-js/faker/locale/af_ZA' import { getDeviatingSetups, getSafeSetups, getSharedSetup, isMultiChainSafeItem } from './utils' -import { PendingSafeStatus } from '@/store/slices' -import { PayMethod } from '@/features/counterfactual/PayNowPayLater' +import { PendingSafeStatus } from '@safe-global/utils/features/counterfactual/store/types' + +import { PayMethod } from '@safe-global/utils/features/counterfactual/types' describe('multiChain/utils', () => { describe('isMultiChainSafeItem', () => { diff --git a/apps/web/src/features/multichain/utils/utils.ts b/apps/web/src/features/multichain/utils/utils.ts index 040fc00fff..b9e69790f0 100644 --- a/apps/web/src/features/multichain/utils/utils.ts +++ b/apps/web/src/features/multichain/utils/utils.ts @@ -3,15 +3,19 @@ import semverSatisfies from 'semver/functions/satisfies' import memoize from 'lodash/memoize' import { keccak256, ethers, solidityPacked, getCreate2Address, type Provider } from 'ethers' -import { type UndeployedSafesState, type ReplayedSafeProps } from '@/store/slices' -import { sameAddress } from '@/utils/addresses' -import { Safe_proxy_factory__factory } from '@/types/contracts' +import { + type UndeployedSafesState, + type ReplayedSafeProps, +} from '@safe-global/utils/features/counterfactual/store/types' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import { areOwnersMatching } from '@safe-global/utils/utils/safe-setup-comparison' +import { Safe_proxy_factory__factory } from '@safe-global/utils/types/contracts' import { extractCounterfactualSafeSetup } from '@/features/counterfactual/utils' import { encodeSafeSetupCall } from '@/components/new-safe/create/logic' -import { FEATURES, hasFeature } from '@/utils/chains' import { type SafeItem } from '@/features/myAccounts/hooks/useAllSafes' import { type MultiChainSafeItem } from '@/features/myAccounts/hooks/useAllSafesGrouped' -import { LATEST_SAFE_VERSION } from '@/config/constants' +import { LATEST_SAFE_VERSION } from '@safe-global/utils/config/constants' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' type SafeSetup = { owners: string[] @@ -30,8 +34,9 @@ export const isMultiChainSafeItem = (safe: SafeItem | MultiChainSafeItem): safe return false } -const areOwnersMatching = (owners1: string[], owners2: string[]) => - owners1.length === owners2.length && owners1.every((owner) => owners2.some((owner2) => sameAddress(owner, owner2))) +export const isSafeItem = (safe: SafeItem | MultiChainSafeItem): safe is SafeItem => { + return !isMultiChainSafeItem(safe) +} export const getSafeSetups = ( safes: SafeItem[], @@ -101,24 +106,34 @@ const memoizedGetProxyCreationCode = memoize( async (factoryAddress, provider) => `${factoryAddress}${(await provider.getNetwork()).chainId}`, ) -export const predictAddressBasedOnReplayData = async (safeCreationData: ReplayedSafeProps, provider: Provider) => { - const setupData = encodeSafeSetupCall(safeCreationData.safeAccountConfig) - +export const predictSafeAddress = async ( + setupData: { initializer: string; saltNonce: string; singleton: string }, + factoryAddress: string, + provider: Provider, +) => { // Step 1: Hash the initializer - const initializerHash = keccak256(setupData) + const initializerHash = keccak256(setupData.initializer) // Step 2: Encode the initializerHash and saltNonce using abi.encodePacked equivalent - const encoded = ethers.concat([initializerHash, solidityPacked(['uint256'], [safeCreationData.saltNonce])]) + const encoded = ethers.concat([initializerHash, solidityPacked(['uint256'], [setupData.saltNonce])]) // Step 3: Hash the encoded value to get the final salt const salt = keccak256(encoded) // Get Proxy creation code - const proxyCreationCode = await memoizedGetProxyCreationCode(safeCreationData.factoryAddress, provider) + const proxyCreationCode = await memoizedGetProxyCreationCode(factoryAddress, provider) - const constructorData = safeCreationData.masterCopy - const initCode = proxyCreationCode + solidityPacked(['uint256'], [constructorData]).slice(2) - return getCreate2Address(safeCreationData.factoryAddress, salt, keccak256(initCode)) + const initCode = proxyCreationCode + solidityPacked(['uint256'], [setupData.singleton]).slice(2) + return getCreate2Address(factoryAddress, salt, keccak256(initCode)) +} + +export const predictAddressBasedOnReplayData = async (safeCreationData: ReplayedSafeProps, provider: Provider) => { + const initializer = encodeSafeSetupCall(safeCreationData.safeAccountConfig) + return predictSafeAddress( + { initializer, saltNonce: safeCreationData.saltNonce, singleton: safeCreationData.masterCopy }, + safeCreationData.factoryAddress, + provider, + ) } const canMultichain = (chain: ChainInfo) => { diff --git a/apps/web/src/features/myAccounts/components/AccountInfoChips/index.tsx b/apps/web/src/features/myAccounts/components/AccountInfoChips/index.tsx index 6181fcf9e5..fe7e0ae92c 100644 --- a/apps/web/src/features/myAccounts/components/AccountInfoChips/index.tsx +++ b/apps/web/src/features/myAccounts/components/AccountInfoChips/index.tsx @@ -16,7 +16,6 @@ const AccountStatusChip = ({ isActivating }: { isActivating: boolean }) => { className={css.chip} sx={{ backgroundColor: isActivating ? 'var(--color-info-light)' : 'var(--color-warning-background)', - // backgroundColor: 'warning.background', }} size="small" label={isActivating ? 'Activating account' : 'Not activated'} @@ -34,6 +33,7 @@ const AccountStatusChip = ({ isActivating }: { isActivating: boolean }) => { const ReadOnlyChip = () => { return ( <Chip + data-testid="read-only-chip" className={css.chip} sx={{ color: 'var(--color-primary-light)', borderColor: 'var(--color-border-light)' }} variant="outlined" diff --git a/apps/web/src/features/myAccounts/components/AccountItems/MultiAccountItem.tsx b/apps/web/src/features/myAccounts/components/AccountItems/MultiAccountItem.tsx index aba2cdcdb7..207426b5bb 100644 --- a/apps/web/src/features/myAccounts/components/AccountItems/MultiAccountItem.tsx +++ b/apps/web/src/features/myAccounts/components/AccountItems/MultiAccountItem.tsx @@ -1,5 +1,7 @@ import { selectUndeployedSafes } from '@/features/counterfactual/store/undeployedSafesSlice' import NetworkLogosList from '@/features/multichain/components/NetworkLogosList' +import type { SafeListProps } from '@/features/myAccounts/components/SafesList' +import SpaceSafeContextMenu from '@/features/spaces/components/SafeAccounts/SpaceSafeContextMenu' import { showNotification } from '@/store/notificationsSlice' import SingleAccountItem from '@/features/myAccounts/components/AccountItems/SingleAccountItem' import type { SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' @@ -16,6 +18,7 @@ import { Tooltip, SvgIcon, IconButton, + useMediaQuery, } from '@mui/material' import SafeIcon from '@/components/common/SafeIcon' import { OVERVIEW_EVENTS, OVERVIEW_LABELS, PIN_SAFE_LABELS, trackEvent } from '@/services/analytics' @@ -23,12 +26,12 @@ import { AppRoutes } from '@/config/routes' import { useAppDispatch, useAppSelector } from '@/store' import css from './styles.module.css' import useSafeAddress from '@/hooks/useSafeAddress' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import classnames from 'classnames' import { useRouter } from 'next/router' import FiatValue from '@/components/common/FiatValue' import { type MultiChainSafeItem } from '@/features/myAccounts/hooks/useAllSafesGrouped' -import { shortenAddress } from '@/utils/formatters' +import { shortenAddress } from '@safe-global/utils/utils/formatters' import { type SafeItem } from '@/features/myAccounts/hooks/useAllSafes' import { getSafeSetups, getSharedSetup, hasMultiChainAddNetworkFeature } from '@/features/multichain/utils/utils' import { AddNetworkButton } from '../AddNetworkButton' @@ -42,35 +45,34 @@ import { selectChains } from '@/store/chainsSlice' import BookmarkIcon from '@/public/images/apps/bookmark.svg' import BookmarkedIcon from '@/public/images/apps/bookmarked.svg' import { addOrUpdateSafe, pinSafe, selectAllAddedSafes, unpinSafe } from '@/store/addedSafesSlice' -import { defaultSafeInfo } from '@/store/safeInfoSlice' +import { defaultSafeInfo } from '@safe-global/store/slices/SafeInfo/utils' import { selectOrderByPreference } from '@/store/orderByPreferenceSlice' import { getComparator } from '@/features/myAccounts/utils/utils' +import { useIsSpaceRoute } from '@/hooks/useIsSpaceRoute' +import EthHashInfo from '@/components/common/EthHashInfo' +import { useTheme } from '@mui/material/styles' -type MultiAccountItemProps = { - multiSafeAccountItem: MultiChainSafeItem - safeOverviews?: SafeOverview[] - onLinkClick?: () => void -} - -const MultichainIndicator = ({ safes }: { safes: SafeItem[] }) => { +export const MultichainIndicator = ({ safes }: { safes: SafeItem[] }) => { return ( - <Tooltip - title={ - <Box data-testid="multichain-tooltip"> - <Typography fontSize="14px">Multichain account on:</Typography> - {safes.map((safeItem) => ( - <Box key={safeItem.chainId} sx={{ p: '4px 0px' }}> - <ChainIndicator chainId={safeItem.chainId} /> - </Box> - ))} + <Box className={css.multiChains}> + <Tooltip + title={ + <Box data-testid="multichain-tooltip"> + <Typography fontSize="14px">Multichain account on:</Typography> + {safes.map((safeItem) => ( + <Box key={safeItem.chainId} sx={{ p: '4px 0px' }}> + <ChainIndicator chainId={safeItem.chainId} /> + </Box> + ))} + </Box> + } + arrow + > + <Box> + <NetworkLogosList networks={safes} showHasMore /> </Box> - } - arrow - > - <Box className={css.multiChains}> - <NetworkLogosList networks={safes} showHasMore /> - </Box> - </Tooltip> + </Tooltip> + </Box> ) } @@ -79,6 +81,7 @@ function useMultiAccountItemData(multiSafeAccountItem: MultiChainSafeItem) { const router = useRouter() const isWelcomePage = router.pathname === AppRoutes.welcome.accounts + const isSpaceRoute = useIsSpaceRoute() const safeAddress = useSafeAddress() const isCurrentSafe = sameAddress(safeAddress, address) @@ -136,6 +139,7 @@ function useMultiAccountItemData(multiSafeAccountItem: MultiChainSafeItem) { isReadOnly, isWelcomePage, deployedChainIds, + isSpaceRoute, } } @@ -213,7 +217,17 @@ function usePinActions( return { addToPinnedList, removeFromPinnedList } } -const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountItemProps) => { +type MultiAccountItemProps = { + multiSafeAccountItem: MultiChainSafeItem + safeOverviews?: SafeOverview[] + onLinkClick?: SafeListProps['onLinkClick'] + isSpaceSafe?: boolean +} + +const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, isSpaceSafe = false }: MultiAccountItemProps) => { + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + const { address, name, @@ -227,6 +241,7 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte isReadOnly, isWelcomePage, deployedChainIds, + isSpaceRoute, } = useMultiAccountItemData(multiSafeAccountItem) const { addToPinnedList, removeFromPinnedList } = usePinActions(address, name, sortedSafes, safeOverviews) @@ -235,7 +250,7 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte const toggleExpand = () => { setExpanded((prev) => { - if (!prev) { + if (!prev && !isSpaceRoute) { trackEvent({ ...OVERVIEW_EVENTS.EXPAND_MULTI_SAFE, label: trackingLabel }) } return !prev @@ -257,27 +272,23 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte '& .MuiAccordionSummary-content': { m: '0 !important', alignItems: 'center' }, '&.Mui-expanded': { backgroundColor: 'transparent !important' }, }} + component="div" > <Box className={classnames(css.multiSafeLink, css.safeLink)} width="100%"> <Box sx={{ pr: 2.5 }} data-testid="group-safe-icon"> <SafeIcon address={address} owners={sharedSetup?.owners.length} threshold={sharedSetup?.threshold} /> </Box> + <Typography variant="body2" component="div" className={css.safeAddress}> - {multiSafeAccountItem.name && ( - <Typography variant="subtitle2" component="p" sx={{ fontWeight: 'bold' }} className={css.safeName}> - {multiSafeAccountItem.name} - </Typography> - )} - <Typography - data-testid="group-address" - component="span" - sx={{ - color: 'var(--color-primary-light)', - fontSize: 'inherit', - }} - > - {shortenAddress(address)} - </Typography> + <EthHashInfo + address={address} + name={multiSafeAccountItem.name} + shortAddress + showPrefix={false} + showAvatar={false} + copyPrefix={false} + copyAddress={!isMobile} + /> </Typography> <MultichainIndicator safes={sortedSafes} /> <Typography @@ -296,29 +307,40 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte )} </Typography> </Box> - <IconButton - data-testid="bookmark-icon" - edge="end" - size="medium" - sx={{ mx: 1 }} - onClick={(event) => { - event.stopPropagation() - isPinned ? removeFromPinnedList() : addToPinnedList() - }} - > - <SvgIcon - component={isPinned ? BookmarkedIcon : BookmarkIcon} - inheritViewBox - color={isPinned ? 'primary' : undefined} - fontSize="small" + + {!isSpaceSafe && ( + <IconButton + data-testid="bookmark-icon" + edge="end" + size="medium" + sx={{ mx: 1 }} + onClick={(event) => { + event.stopPropagation() + isPinned ? removeFromPinnedList() : addToPinnedList() + }} + > + <SvgIcon + component={isPinned ? BookmarkedIcon : BookmarkIcon} + inheritViewBox + color={isPinned ? 'primary' : undefined} + fontSize="small" + /> + </IconButton> + )} + + {isSpaceSafe ? ( + <> + <Box width="40px" /> {/* Spacer for the send button */} + <SpaceSafeContextMenu safeItem={multiSafeAccountItem} /> + </> + ) : ( + <MultiAccountContextMenu + name={multiSafeAccountItem.name ?? ''} + address={address} + chainIds={deployedChainIds} + addNetwork={hasReplayableSafe} /> - </IconButton> - <MultiAccountContextMenu - name={multiSafeAccountItem.name ?? ''} - address={address} - chainIds={deployedChainIds} - addNetwork={hasReplayableSafe} - /> + )} </AccordionSummary> <AccordionDetails sx={{ padding: '0px 12px' }}> <Box data-testid="subacounts-container"> @@ -328,10 +350,11 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte safeItem={safeItem} key={`${safeItem.chainId}:${safeItem.address}`} isMultiChainItem + isSpaceSafe={isSpaceSafe} /> ))} </Box> - {!isReadOnly && hasReplayableSafe && ( + {!isReadOnly && hasReplayableSafe && !isSpaceSafe && ( <> <Divider sx={{ ml: '-12px', mr: '-12px' }} /> <Box diff --git a/apps/web/src/features/myAccounts/components/AccountItems/SingleAccountItem.tsx b/apps/web/src/features/myAccounts/components/AccountItems/SingleAccountItem.tsx index f3c5060bb6..f31727801c 100644 --- a/apps/web/src/features/myAccounts/components/AccountItems/SingleAccountItem.tsx +++ b/apps/web/src/features/myAccounts/components/AccountItems/SingleAccountItem.tsx @@ -1,4 +1,6 @@ import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' +import type { SafeListProps } from '@/features/myAccounts/components/SafesList' +import SpaceSafeContextMenu from '@/features/spaces/components/SafeAccounts/SpaceSafeContextMenu' import { type SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' import { useMemo, useRef } from 'react' import { ListItemButton, Box, Typography, IconButton, SvgIcon, Skeleton, useTheme, useMediaQuery } from '@mui/material' @@ -11,11 +13,11 @@ import { selectChainById } from '@/store/chainsSlice' import ChainIndicator from '@/components/common/ChainIndicator' import css from './styles.module.css' import { selectAllAddressBooks } from '@/store/addressBookSlice' -import { shortenAddress } from '@/utils/formatters' +import { shortenAddress } from '@safe-global/utils/utils/formatters' import SafeListContextMenu from '@/components/sidebar/SafeListContextMenu' import useSafeAddress from '@/hooks/useSafeAddress' import useChainId from '@/hooks/useChainId' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import classnames from 'classnames' import { useRouter } from 'next/router' import type { SafeItem } from '@/features/myAccounts/hooks/useAllSafes' @@ -29,18 +31,27 @@ import { addOrUpdateSafe, unpinSafe } from '@/store/addedSafesSlice' import SafeIcon from '@/components/common/SafeIcon' import useOnceVisible from '@/hooks/useOnceVisible' import { skipToken } from '@reduxjs/toolkit/query' -import { defaultSafeInfo, showNotification, useGetSafeOverviewQuery } from '@/store/slices' +import { showNotification, useGetSafeOverviewQuery } from '@/store/slices' +import { defaultSafeInfo } from '@safe-global/store/slices/SafeInfo/utils' import FiatValue from '@/components/common/FiatValue' import { AccountInfoChips } from '../AccountInfoChips' +import SendTransactionButton from '@/features/spaces/components/SafeAccounts/SendTransactionButton' +import EthHashInfo from '@/components/common/EthHashInfo' type AccountItemProps = { safeItem: SafeItem safeOverview?: SafeOverview - onLinkClick?: () => void + onLinkClick?: SafeListProps['onLinkClick'] isMultiChainItem?: boolean + isSpaceSafe?: boolean } -const SingleAccountItem = ({ onLinkClick, safeItem, isMultiChainItem = false }: AccountItemProps) => { +const SingleAccountItem = ({ + onLinkClick, + safeItem, + isMultiChainItem = false, + isSpaceSafe = false, +}: AccountItemProps) => { const { chainId, address, isReadOnly, isPinned } = safeItem const chain = useAppSelector((state) => selectChainById(state, chainId)) const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chainId, address)) @@ -57,7 +68,11 @@ const SingleAccountItem = ({ onLinkClick, safeItem, isMultiChainItem = false }: const dispatch = useAppDispatch() - const trackingLabel = isWelcomePage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + const trackingLabel = isWelcomePage + ? OVERVIEW_LABELS.login_page + : isSpaceSafe + ? OVERVIEW_LABELS.space_page + : OVERVIEW_LABELS.sidebar const getHref = useGetHref(router) @@ -131,109 +146,100 @@ const SingleAccountItem = ({ onLinkClick, safeItem, isMultiChainItem = false }: trackEvent({ ...OVERVIEW_EVENTS.PIN_SAFE, label: PIN_SAFE_LABELS.unpin }) } - return ( - <ListItemButton - ref={elementRef} - data-testid="safe-list-item" - selected={isCurrentSafe} - className={classnames(css.listItem, { [css.currentListItem]: isCurrentSafe })} - > - <Track {...OVERVIEW_EVENTS.OPEN_SAFE} label={trackingLabel}> - <Link onClick={onLinkClick} href={href} className={css.safeLink}> - <Box + const content = ( + <> + <Box + sx={{ + pr: 2.5, + }} + > + <SafeIcon + address={address} + owners={safeOwners.length > 0 ? safeOwners.length : undefined} + threshold={safeThreshold > 0 ? safeThreshold : undefined} + isMultiChainItem={isMultiChainItem} + chainId={chainId} + /> + </Box> + + <Typography variant="body2" component="div" className={css.safeAddress}> + {isMultiChainItem ? ( + <Typography + component="span" sx={{ - pr: 2.5, + color: 'var(--color-primary-light)', + fontSize: 'inherit', }} > - <SafeIcon - address={address} - owners={safeOwners.length > 0 ? safeOwners.length : undefined} - threshold={safeThreshold > 0 ? safeThreshold : undefined} - isMultiChainItem={isMultiChainItem} - chainId={chainId} - /> - </Box> - - <Typography variant="body2" component="div" className={css.safeAddress}> - {name && ( - <Typography - variant="subtitle2" - component="p" - className={css.safeName} - sx={{ - fontWeight: 'bold', - }} - > - {name} - </Typography> - )} - {isMultiChainItem ? ( - <Typography - component="span" - sx={{ - color: 'var(--color-primary-light)', - fontSize: 'inherit', - }} - > - {chain?.chainName} - </Typography> - ) : ( - <> - {chain?.shortName}: - <Typography - component="span" - sx={{ - color: 'var(--color-primary-light)', - fontSize: 'inherit', - }} - > - {shortenAddress(address)} - </Typography> - </> - )} - {!isMobile && ( - <AccountInfoChips - isActivating={isActivating} - isReadOnly={isReadOnly} - undeployedSafe={!!undeployedSafe} - isVisible={isVisible} - safeOverview={safeOverview ?? null} - chain={chain} - href={href} - onLinkClick={onLinkClick} - trackingLabel={trackingLabel} - /> - )} + {chain?.chainName} </Typography> + ) : ( + <EthHashInfo + address={address} + name={name} + shortAddress + chainId={chain?.chainId} + showAvatar={false} + copyAddress={!isMobile} + /> + )} + {!isMobile && ( + <AccountInfoChips + isActivating={isActivating} + isReadOnly={isReadOnly} + undeployedSafe={!!undeployedSafe} + isVisible={isVisible} + safeOverview={safeOverview ?? null} + chain={chain} + href={href} + onLinkClick={onLinkClick} + trackingLabel={trackingLabel} + /> + )} + </Typography> - {!isMultiChainItem && <ChainIndicator chainId={chainId} responsive onlyLogo className={css.chainIndicator} />} + {!isMultiChainItem ? ( + <ChainIndicator chainId={chainId} responsive onlyLogo className={css.chainIndicator} /> + ) : ( + <div /> + )} - <Typography variant="body2" sx={{ fontWeight: 'bold', textAlign: 'right', pl: 2 }}> - {undeployedSafe ? null : safeOverview ? ( - <FiatValue value={safeOverview.fiatTotal} /> - ) : ( - <Skeleton variant="text" sx={{ ml: 'auto' }} /> - )} - </Typography> - </Link> - </Track> - {!isMultiChainItem && ( - <> - <IconButton - data-testid="bookmark-icon" - edge="end" - size="medium" - sx={{ mx: 1 }} - onClick={isPinned ? removeFromPinnedList : addToPinnedList} - > - <SvgIcon - component={isPinned ? BookmarkedIcon : BookmarkIcon} - inheritViewBox - color={isPinned ? 'primary' : undefined} - fontSize="small" - /> - </IconButton> + <Typography variant="body2" sx={{ fontWeight: 'bold', textAlign: 'right', pl: 2 }}> + {undeployedSafe ? null : safeOverview ? ( + <FiatValue value={safeOverview.fiatTotal} /> + ) : ( + <Skeleton variant="text" sx={{ ml: 'auto' }} /> + )} + </Typography> + </> + ) + + const actions = ( + <> + {!isMultiChainItem && !isSpaceSafe && ( + <IconButton + data-testid="bookmark-icon" + edge="end" + size="medium" + sx={{ mx: 1 }} + onClick={isPinned ? removeFromPinnedList : addToPinnedList} + > + <SvgIcon + component={isPinned ? BookmarkedIcon : BookmarkIcon} + inheritViewBox + color={isPinned ? 'primary' : undefined} + fontSize="small" + /> + </IconButton> + )} + {isSpaceSafe ? ( + <> + {safeOverview && <SendTransactionButton safe={safeOverview} />} + <SpaceSafeContextMenu safeItem={safeItem} /> + </> + ) : ( + !isMultiChainItem && ( <SafeListContextMenu name={name} address={address} @@ -241,8 +247,9 @@ const SingleAccountItem = ({ onLinkClick, safeItem, isMultiChainItem = false }: addNetwork={isReplayable} rename undeployedSafe={!!undeployedSafe} + onClose={onLinkClick} /> - </> + ) )} {isMobile && ( @@ -258,6 +265,23 @@ const SingleAccountItem = ({ onLinkClick, safeItem, isMultiChainItem = false }: trackingLabel={trackingLabel} /> )} + </> + ) + + return ( + <ListItemButton + ref={elementRef} + data-testid="safe-list-item" + selected={isCurrentSafe} + className={classnames(css.listItem, { [css.currentListItem]: isCurrentSafe })} + > + <Track {...OVERVIEW_EVENTS.OPEN_SAFE} label={trackingLabel}> + <Link onClick={onLinkClick} href={href} className={css.safeLink}> + {content} + </Link> + </Track> + + {actions} </ListItemButton> ) } diff --git a/apps/web/src/features/myAccounts/components/AccountItems/styles.module.css b/apps/web/src/features/myAccounts/components/AccountItems/styles.module.css index 4adeb324f6..1790f5b8f4 100644 --- a/apps/web/src/features/myAccounts/components/AccountItems/styles.module.css +++ b/apps/web/src/features/myAccounts/components/AccountItems/styles.module.css @@ -28,6 +28,10 @@ background-color: var(--color-background-paper); } +.listItem { + background-color: var(--color-background-paper); +} + .listItem.subItem { margin-bottom: 8px; } @@ -71,6 +75,10 @@ text-overflow: ellipsis; } +.safeAddress :global .ethHashInfo-name { + font-weight: bold; +} + .listHeader { display: flex; } diff --git a/apps/web/src/features/myAccounts/components/AccountsHeader/index.tsx b/apps/web/src/features/myAccounts/components/AccountsHeader/index.tsx index 2b52c39113..f4034a171d 100644 --- a/apps/web/src/features/myAccounts/components/AccountsHeader/index.tsx +++ b/apps/web/src/features/myAccounts/components/AccountsHeader/index.tsx @@ -1,11 +1,14 @@ import ConnectWalletButton from '@/components/common/ConnectWallet/ConnectWalletButton' import Track from '@/components/common/Track' import { AppRoutes } from '@/config/routes' +import AccountsNavigation from '@/features/myAccounts/components/AccountsNavigation' import CreateButton from '@/features/myAccounts/components/CreateButton' import css from '@/features/myAccounts/styles.module.css' +import { useHasFeature } from '@/hooks/useChains' import useWallet from '@/hooks/wallets/useWallet' import AddIcon from '@/public/images/common/add.svg' import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' +import { FEATURES } from '@safe-global/utils/utils/chains' import { Box, Button, Link, SvgIcon, Typography } from '@mui/material' import classNames from 'classnames' import { useRouter } from 'next/router' @@ -15,6 +18,7 @@ const AddSafeButton = ({ trackingLabel, onLinkClick }: { trackingLabel: string; <Track {...OVERVIEW_EVENTS.ADD_TO_WATCHLIST} label={trackingLabel}> <Link href={AppRoutes.newSafe.load}> <Button + data-testid="add-safe-button" disableElevation variant="outlined" size="small" @@ -32,14 +36,19 @@ const AddSafeButton = ({ trackingLabel, onLinkClick }: { trackingLabel: string; const AccountsHeader = ({ isSidebar, onLinkClick }: { isSidebar: boolean; onLinkClick?: () => void }) => { const wallet = useWallet() const router = useRouter() + const isSpacesFeatureEnabled = useHasFeature(FEATURES.SPACES) const isLoginPage = router.pathname === AppRoutes.welcome.accounts const trackingLabel = isLoginPage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar return ( <Box className={classNames(css.header, { [css.sidebarHeader]: isSidebar })}> - <Typography variant="h1" fontWeight={700} className={css.title}> - My accounts - </Typography> + {isSidebar || !isSpacesFeatureEnabled ? ( + <Typography variant="h1" fontWeight={700} className={css.title}> + Accounts + </Typography> + ) : ( + <AccountsNavigation /> + )} <Box className={css.headerButtons}> <AddSafeButton trackingLabel={trackingLabel} onLinkClick={onLinkClick} /> diff --git a/apps/web/src/features/myAccounts/components/AccountsList/index.tsx b/apps/web/src/features/myAccounts/components/AccountsList/index.tsx index a50b09d9a4..3c38424ef7 100644 --- a/apps/web/src/features/myAccounts/components/AccountsList/index.tsx +++ b/apps/web/src/features/myAccounts/components/AccountsList/index.tsx @@ -1,5 +1,6 @@ import FilteredSafes from '@/features/myAccounts/components/FilteredSafes' import PinnedSafes from '@/features/myAccounts/components/PinnedSafes' +import CurrentSafe from '@/features/myAccounts/components/CurrentSafe' import type { AllSafeItems, AllSafeItemsGrouped } from '@/features/myAccounts/hooks/useAllSafesGrouped' import AllSafes from '@/features/myAccounts/components/AllSafes' import { getComparator } from '@/features/myAccounts/utils/utils' @@ -32,6 +33,7 @@ const AccountsList = ({ return ( <> + <CurrentSafe allSafes={allSafes} onLinkClick={onLinkClick} /> <PinnedSafes allSafes={allSafes} onLinkClick={onLinkClick} /> <AllSafes allSafes={allSafes} onLinkClick={onLinkClick} isSidebar={isSidebar} /> </> diff --git a/apps/web/src/features/myAccounts/components/AccountsNavigation/index.tsx b/apps/web/src/features/myAccounts/components/AccountsNavigation/index.tsx new file mode 100644 index 0000000000..010119306e --- /dev/null +++ b/apps/web/src/features/myAccounts/components/AccountsNavigation/index.tsx @@ -0,0 +1,48 @@ +import { AppRoutes } from '@/config/routes' +import css from '@/features/myAccounts/styles.module.css' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import { Chip, Stack, Typography } from '@mui/material' +import classNames from 'classnames' +import { useRouter } from 'next/router' +import Link from 'next/link' +import { trackEvent } from '@/services/analytics' + +const AccountsNavigation = () => { + const router = useRouter() + + const isActiveNavigation = (pathname: string) => { + return router.pathname === pathname + } + + const trackSpacesClick = () => { + if (!isActiveNavigation(AppRoutes.welcome.spaces)) { + trackEvent({ ...SPACE_EVENTS.OPEN_SPACE_LIST_PAGE, label: SPACE_LABELS.accounts_page }) + } + } + + return ( + <Stack direction="row" gap={2} flexWrap="wrap"> + <Typography variant="h1" fontWeight={700} className={css.title}> + <Link + href={AppRoutes.welcome.accounts} + className={classNames(css.link, { [css.active]: isActiveNavigation(AppRoutes.welcome.accounts) })} + > + Accounts + </Link> + </Typography> + + <Typography variant="h1" fontWeight={700} className={css.title}> + <Link + onClick={trackSpacesClick} + href={AppRoutes.welcome.spaces} + className={classNames(css.link, { [css.active]: isActiveNavigation(AppRoutes.welcome.spaces) })} + > + Spaces + <Chip label="Beta" size="small" sx={{ ml: 1, fontWeight: 'normal', borderRadius: '4px' }} /> + </Link> + </Typography> + </Stack> + ) +} + +export default AccountsNavigation diff --git a/apps/web/src/features/myAccounts/components/AllSafes/index.tsx b/apps/web/src/features/myAccounts/components/AllSafes/index.tsx index d3a6862b3b..152c5b2e3d 100644 --- a/apps/web/src/features/myAccounts/components/AllSafes/index.tsx +++ b/apps/web/src/features/myAccounts/components/AllSafes/index.tsx @@ -34,6 +34,7 @@ const AllSafes = ({ padding: 0, '& .MuiAccordionSummary-content': { margin: '0 !important', mb: 1, flexGrow: 0 }, }} + component="div" > <div className={css.listHeader}> <Typography variant="h5" fontWeight={700}> diff --git a/apps/web/src/features/myAccounts/components/CurrentSafe/index.tsx b/apps/web/src/features/myAccounts/components/CurrentSafe/index.tsx new file mode 100644 index 0000000000..9222beb94c --- /dev/null +++ b/apps/web/src/features/myAccounts/components/CurrentSafe/index.tsx @@ -0,0 +1,65 @@ +import { Box, Typography } from '@mui/material' +import useSafeInfo from '@/hooks/useSafeInfo' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import type { AllSafeItems } from '../../hooks/useAllSafesGrouped' +import { useMemo } from 'react' +import useAddressBook from '@/hooks/useAddressBook' +import SingleAccountItem from '../AccountItems/SingleAccountItem' + +function CurrentSafeList({ + safeAddress, + chainId, + isReadOnly, + onLinkClick, +}: { + safeAddress: string + chainId: string + isReadOnly: boolean + onLinkClick?: () => void +}) { + const addressBook = useAddressBook() + const safeName = addressBook[safeAddress] + + const safeItem = useMemo( + () => ({ + chainId, + address: safeAddress, + isReadOnly, + isPinned: false, + lastVisited: -1, + name: safeName, + }), + [chainId, safeAddress, isReadOnly, safeName], + ) + + return ( + <Box data-testid="current-safe-section" mb={3}> + <Typography variant="h5" fontWeight={700} mb={2}> + Current Safe Account + </Typography> + + <SingleAccountItem onLinkClick={onLinkClick} safeItem={safeItem} /> + </Box> + ) +} + +function CurrentSafe({ allSafes, onLinkClick }: { allSafes: AllSafeItems; onLinkClick?: () => void }) { + const { safe, safeAddress } = useSafeInfo() + + const safeInList = useMemo( + () => (safeAddress ? allSafes?.find((s) => sameAddress(s.address, safeAddress)) : undefined), + [allSafes, safeAddress], + ) + if (!safeAddress || safeInList?.isPinned) return null + + return ( + <CurrentSafeList + onLinkClick={onLinkClick} + safeAddress={safeAddress} + chainId={safe.chainId} + isReadOnly={!safeInList} + /> + ) +} + +export default CurrentSafe diff --git a/apps/web/src/features/myAccounts/components/FilteredSafes/index.test.tsx b/apps/web/src/features/myAccounts/components/FilteredSafes/index.test.tsx index 36bab0ca9e..f06d6ecca1 100644 --- a/apps/web/src/features/myAccounts/components/FilteredSafes/index.test.tsx +++ b/apps/web/src/features/myAccounts/components/FilteredSafes/index.test.tsx @@ -73,13 +73,4 @@ describe('FilteredSafes', () => { const safesListProps = (SafesList as jest.Mock).mock.calls[0][0] expect(safesListProps.onLinkClick).toBe(onLinkClickMock) }) - - it('sets useTransitions to false in SafesList', () => { - // Just verify that we are passing useTransitions={false} - jest.spyOn(safesSearch, 'useSafesSearch').mockReturnValue([]) - render(<FilteredSafes searchQuery="test" allSafes={[]} />) - - const safesListProps = (SafesList as jest.Mock).mock.calls[0][0] - expect(safesListProps.useTransitions).toBe(false) - }) }) diff --git a/apps/web/src/features/myAccounts/components/FilteredSafes/index.tsx b/apps/web/src/features/myAccounts/components/FilteredSafes/index.tsx index 13579cbca6..d9b2756f0c 100644 --- a/apps/web/src/features/myAccounts/components/FilteredSafes/index.tsx +++ b/apps/web/src/features/myAccounts/components/FilteredSafes/index.tsx @@ -1,8 +1,11 @@ import SafesList from '@/features/myAccounts/components/SafesList' import type { AllSafeItems } from '@/features/myAccounts/hooks/useAllSafesGrouped' import { useSafesSearch } from '@/features/myAccounts/hooks/useSafesSearch' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' +import { OVERVIEW_EVENTS } from '@/services/analytics' +import { trackEvent } from '@/services/analytics' import { Box, Typography } from '@mui/material' +import { useEffect } from 'react' const FilteredSafes = ({ searchQuery, @@ -15,13 +18,19 @@ const FilteredSafes = ({ }) => { const filteredSafes = useSafesSearch(allSafes ?? [], searchQuery) + useEffect(() => { + if (searchQuery) { + trackEvent({ category: OVERVIEW_EVENTS.SEARCH.category, action: OVERVIEW_EVENTS.SEARCH.action }) + } + }, [searchQuery]) + return ( <> <Typography variant="h5" fontWeight="normal" mb={2} color="primary.light"> Found {filteredSafes.length} result{maybePlural(filteredSafes)} </Typography> <Box mt={1}> - <SafesList safes={filteredSafes} onLinkClick={onLinkClick} useTransitions={false} /> + <SafesList safes={filteredSafes} onLinkClick={onLinkClick} /> </Box> </> ) diff --git a/apps/web/src/features/myAccounts/components/QueueActions/index.tsx b/apps/web/src/features/myAccounts/components/QueueActions/index.tsx index f651de62c9..96c178a7d3 100644 --- a/apps/web/src/features/myAccounts/components/QueueActions/index.tsx +++ b/apps/web/src/features/myAccounts/components/QueueActions/index.tsx @@ -48,7 +48,7 @@ const QueueActions = ({ e.preventDefault() router.push({ pathname: AppRoutes.transactions.queue, - query: { safe: `${chainShortName}:${safeAddress}` }, + query: { ...router.query, safe: `${chainShortName}:${safeAddress}` }, }) }, [chainShortName, router, safeAddress], diff --git a/apps/web/src/features/myAccounts/components/SafesList/index.tsx b/apps/web/src/features/myAccounts/components/SafesList/index.tsx index 29b38fdad3..95cf579a58 100644 --- a/apps/web/src/features/myAccounts/components/SafesList/index.tsx +++ b/apps/web/src/features/myAccounts/components/SafesList/index.tsx @@ -1,45 +1,33 @@ import SingleAccountItem from '@/features/myAccounts/components/AccountItems/SingleAccountItem' import type { SafeItem } from '@/features/myAccounts/hooks/useAllSafes' -import type { MultiChainSafeItem } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import type { AllSafeItems, MultiChainSafeItem } from '@/features/myAccounts/hooks/useAllSafesGrouped' import MultiAccountItem from '@/features/myAccounts/components/AccountItems/MultiAccountItem' import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' -import { TransitionGroup } from 'react-transition-group' -import { Collapse } from '@mui/material' -type SafeListProps = { - safes?: (SafeItem | MultiChainSafeItem)[] +export type SafeListProps = { + safes?: AllSafeItems onLinkClick?: () => void - useTransitions?: boolean + isSpaceSafe?: boolean } -const renderSafeItem = (item: SafeItem | MultiChainSafeItem, onLinkClick?: () => void) => { +const renderSafeItem = ( + item: SafeItem | MultiChainSafeItem, + onLinkClick?: SafeListProps['onLinkClick'], + isSpaceSafe = false, +) => { return isMultiChainSafeItem(item) ? ( - <MultiAccountItem onLinkClick={onLinkClick} multiSafeAccountItem={item} /> + <MultiAccountItem onLinkClick={onLinkClick} multiSafeAccountItem={item} isSpaceSafe={isSpaceSafe} /> ) : ( - <SingleAccountItem onLinkClick={onLinkClick} safeItem={item} /> + <SingleAccountItem onLinkClick={onLinkClick} safeItem={item} isSpaceSafe={isSpaceSafe} /> ) } -const SafesList = ({ safes, onLinkClick, useTransitions = true }: SafeListProps) => { +const SafesList = ({ safes, onLinkClick, isSpaceSafe = false }: SafeListProps) => { if (!safes || safes.length === 0) { return null } - return useTransitions ? ( - <TransitionGroup> - {safes.map((item) => ( - <Collapse key={item.address} timeout="auto"> - {renderSafeItem(item, onLinkClick)} - </Collapse> - ))} - </TransitionGroup> - ) : ( - <> - {safes.map((item) => ( - <div key={item.address}>{renderSafeItem(item, onLinkClick)}</div> - ))} - </> - ) + return safes.map((item) => <div key={item.address}>{renderSafeItem(item, onLinkClick, isSpaceSafe)}</div>) } export default SafesList diff --git a/apps/web/src/features/myAccounts/hooks/__tests__/useAllSafes.test.ts b/apps/web/src/features/myAccounts/hooks/__tests__/useAllSafes.test.ts index e39cb6caab..a8a7cff058 100644 --- a/apps/web/src/features/myAccounts/hooks/__tests__/useAllSafes.test.ts +++ b/apps/web/src/features/myAccounts/hooks/__tests__/useAllSafes.test.ts @@ -1,11 +1,10 @@ -import type { UndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' - import * as allOwnedSafes from '@/features/myAccounts/hooks/useAllOwnedSafes' import useAllSafes, { _buildSafeItem, _prepareAddresses } from '@/features/myAccounts/hooks/useAllSafes' import * as useChains from '@/hooks/useChains' import * as useWallet from '@/hooks/wallets/useWallet' import { renderHook } from '@/tests/test-utils' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { UndeployedSafe } from '@safe-global/utils/features/counterfactual/store/types' describe('useAllSafes hook', () => { beforeEach(() => { diff --git a/apps/web/src/features/myAccounts/hooks/__tests__/useAllSafesGrouped.ts b/apps/web/src/features/myAccounts/hooks/__tests__/useAllSafesGrouped.test.ts similarity index 100% rename from apps/web/src/features/myAccounts/hooks/__tests__/useAllSafesGrouped.ts rename to apps/web/src/features/myAccounts/hooks/__tests__/useAllSafesGrouped.test.ts diff --git a/apps/web/src/features/myAccounts/hooks/useAllOwnedSafes.ts b/apps/web/src/features/myAccounts/hooks/useAllOwnedSafes.ts index f7cf18661b..39ccd187d9 100644 --- a/apps/web/src/features/myAccounts/hooks/useAllOwnedSafes.ts +++ b/apps/web/src/features/myAccounts/hooks/useAllOwnedSafes.ts @@ -1,13 +1,15 @@ -import type { AllOwnedSafes } from '@safe-global/safe-gateway-typescript-sdk' -import type { AsyncResult } from '@/hooks/useAsync' -import { useGetAllOwnedSafesQuery } from '@/store/api/gateway' -import { asError } from '@/services/exceptions/utils' -import { skipToken } from '@reduxjs/toolkit/query' +import type { AsyncResult } from '@safe-global/utils/hooks/useAsync' +import type { OwnersGetAllSafesByOwnerV2ApiResponse } from '@safe-global/store/gateway/AUTO_GENERATED/owners' +import { useOwnersGetAllSafesByOwnerV2Query } from '@safe-global/store/gateway/AUTO_GENERATED/owners' +import { asError } from '@safe-global/utils/services/exceptions/utils' -const useAllOwnedSafes = (address: string): AsyncResult<AllOwnedSafes> => { - const { data, error, isLoading } = useGetAllOwnedSafesQuery(address === '' ? skipToken : { walletAddress: address }) +const useAllOwnedSafes = (address: string): AsyncResult<OwnersGetAllSafesByOwnerV2ApiResponse> => { + const { currentData, error, isLoading } = useOwnersGetAllSafesByOwnerV2Query( + { ownerAddress: address }, + { skip: address === '' }, + ) - return [address ? data : undefined, asError(error), isLoading] + return [address ? currentData : undefined, asError(error), isLoading] } export default useAllOwnedSafes diff --git a/apps/web/src/features/myAccounts/hooks/useAllSafes.ts b/apps/web/src/features/myAccounts/hooks/useAllSafes.ts index 82d3381c68..1dd91c6c61 100644 --- a/apps/web/src/features/myAccounts/hooks/useAllSafes.ts +++ b/apps/web/src/features/myAccounts/hooks/useAllSafes.ts @@ -5,9 +5,10 @@ import type { AddedSafesState } from '@/store/addedSafesSlice' import { selectAllAddedSafes } from '@/store/addedSafesSlice' import useChains from '@/hooks/useChains' import useWallet from '@/hooks/wallets/useWallet' -import type { AddressBookState, UndeployedSafesState, VisitedSafesState } from '@/store/slices' +import type { AddressBookState, VisitedSafesState } from '@/store/slices' +import type { UndeployedSafesState } from '@safe-global/utils/features/counterfactual/store/types' import { selectAllAddressBooks, selectAllVisitedSafes, selectUndeployedSafes } from '@/store/slices' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import useAllOwnedSafes from './useAllOwnedSafes' export type SafeItem = { diff --git a/apps/web/src/features/myAccounts/hooks/useAllSafesGrouped.ts b/apps/web/src/features/myAccounts/hooks/useAllSafesGrouped.ts index 021593a3dd..d2a1a9a9ea 100644 --- a/apps/web/src/features/myAccounts/hooks/useAllSafesGrouped.ts +++ b/apps/web/src/features/myAccounts/hooks/useAllSafesGrouped.ts @@ -1,7 +1,13 @@ import groupBy from 'lodash/groupBy' import useAllSafes, { type SafeItem, type SafeItems } from './useAllSafes' import { useMemo } from 'react' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import { type AddressBookState, selectAllAddressBooks } from '@/store/addressBookSlice' +import useWallet from '@/hooks/wallets/useWallet' +import useAllOwnedSafes from '@/features/myAccounts/hooks/useAllOwnedSafes' +import { useAppSelector } from '@/store' +import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' +import type { AllOwnedSafes } from '@safe-global/safe-gateway-typescript-sdk' export type MultiChainSafeItem = { address: string @@ -26,6 +32,38 @@ export const _buildMultiChainSafeItem = (address: string, safes: SafeItems): Mul return { address, safes, isPinned, lastVisited, name } } +export function _buildSafeItems( + safes: Record<string, string[]>, + allSafeNames: AddressBookState, + allOwned?: AllOwnedSafes, +): SafeItem[] { + const result: SafeItem[] = [] + + for (const chainId in safes) { + const addresses = safes[chainId] + + addresses.forEach((address) => { + const isReadOnly = !!allOwned && !(allOwned[chainId] || []).includes(address) + const name = allSafeNames[chainId]?.[address] + + result.push({ + chainId, + address, + isReadOnly, + isPinned: false, + lastVisited: 0, + name, + }) + }) + } + + return result +} + +export function flattenSafeItems(items: Array<SafeItem | MultiChainSafeItem>): SafeItem[] { + return items.flatMap((item) => (isMultiChainSafeItem(item) ? item.safes : [item])) +} + export const _getMultiChainAccounts = (safes: SafeItems): MultiChainSafeItem[] => { const groupedByAddress = groupBy(safes, (safe) => safe.address) @@ -42,8 +80,9 @@ export const _getSingleChainAccounts = (safes: SafeItems, allMultiChainSafes: Mu return safes.filter((safe) => !allMultiChainSafes.some((multiSafe) => sameAddress(multiSafe.address, safe.address))) } -export const useAllSafesGrouped = () => { - const allSafes = useAllSafes() +export const useAllSafesGrouped = (customSafes?: SafeItems) => { + const safes = useAllSafes() + const allSafes = customSafes ?? safes return useMemo<AllSafeItemsGrouped>(() => { if (!allSafes) { @@ -59,3 +98,12 @@ export const useAllSafesGrouped = () => { } }, [allSafes]) } + +export const useOwnedSafesGrouped = () => { + const { address: walletAddress = '' } = useWallet() || {} + const [allOwned = {}] = useAllOwnedSafes(walletAddress) + const allSafeNames = useAppSelector(selectAllAddressBooks) + const safeItems = _buildSafeItems(allOwned, allSafeNames) + + return useAllSafesGrouped(safeItems) +} diff --git a/apps/web/src/features/myAccounts/hooks/useGetHref.ts b/apps/web/src/features/myAccounts/hooks/useGetHref.ts index 939cbb22d2..249b139d88 100644 --- a/apps/web/src/features/myAccounts/hooks/useGetHref.ts +++ b/apps/web/src/features/myAccounts/hooks/useGetHref.ts @@ -2,6 +2,7 @@ import { AppRoutes } from '@/config/routes' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { type NextRouter } from 'next/router' import { useCallback } from 'react' +import { useIsSpaceRoute } from '@/hooks/useIsSpaceRoute' /** * Navigate to the dashboard when selecting a safe on the welcome page, @@ -9,16 +10,22 @@ import { useCallback } from 'react' * otherwise keep the current route */ export const useGetHref = (router: NextRouter) => { + const isSpacePage = useIsSpaceRoute() const isWelcomePage = router.pathname === AppRoutes.welcome.accounts const isSingleTxPage = router.pathname === AppRoutes.transactions.tx return useCallback( (chain: ChainInfo, address: string) => { return { - pathname: isWelcomePage ? AppRoutes.home : isSingleTxPage ? AppRoutes.transactions.history : router.pathname, - query: { ...router.query, safe: `${chain.shortName}:${address}` }, + pathname: + isWelcomePage || isSpacePage + ? AppRoutes.home + : isSingleTxPage + ? AppRoutes.transactions.history + : router.pathname, + query: { ...(!isSpacePage && router.query), safe: `${chain.shortName}:${address}` }, } }, - [isSingleTxPage, isWelcomePage, router.pathname, router.query], + [isSingleTxPage, isWelcomePage, isSpacePage, router.pathname, router.query], ) } diff --git a/apps/web/src/features/myAccounts/hooks/useNetworksOfSafe.ts b/apps/web/src/features/myAccounts/hooks/useNetworksOfSafe.ts new file mode 100644 index 0000000000..8b90c224dd --- /dev/null +++ b/apps/web/src/features/myAccounts/hooks/useNetworksOfSafe.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react' +import { useAllSafesGrouped } from './useAllSafesGrouped' +import useChains from '@/hooks/useChains' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +/** + * Hook to get all networks where a specific Safe is active + * + * @param safeAddress - The address of the Safe to check + * @returns Array of network names where the Safe is deployed + */ +export const useNetworksOfSafe = (safeAddress: string): string[] => { + const { allMultiChainSafes } = useAllSafesGrouped() + const { configs: allChains } = useChains() + + const chainMap = useMemo(() => { + return allChains.reduce( + (acc, chain) => { + acc[chain.chainId] = chain + return acc + }, + {} as Record<string, ChainInfo>, + ) + }, [allChains]) + + return useMemo(() => { + if (!safeAddress || !allMultiChainSafes) { + return [] + } + + const multiChainSafe = allMultiChainSafes.find((multiSafe) => sameAddress(multiSafe.address, safeAddress)) + + if (!multiChainSafe) { + return [] + } + + const chainIds = multiChainSafe.safes.map((safeItem) => safeItem.chainId) + + const networkNames = chainIds.map((chainId) => { + const chainInfo = chainMap[chainId] + return chainInfo?.chainName || 'unknown' + }) + + return networkNames + }, [safeAddress, allMultiChainSafes, chainMap]) +} diff --git a/apps/web/src/features/myAccounts/hooks/useSafesSearch.ts b/apps/web/src/features/myAccounts/hooks/useSafesSearch.ts index 9ce2228def..af1fdd1bff 100644 --- a/apps/web/src/features/myAccounts/hooks/useSafesSearch.ts +++ b/apps/web/src/features/myAccounts/hooks/useSafesSearch.ts @@ -1,33 +1,23 @@ -import { useEffect, useMemo } from 'react' +import { useMemo } from 'react' import Fuse from 'fuse.js' import type { AllSafeItems } from './useAllSafesGrouped' import { selectChains } from '@/store/chainsSlice' import { useAppSelector } from '@/store' import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' -import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' const useSafesSearch = (safes: AllSafeItems, query: string): AllSafeItems => { const chains = useAppSelector(selectChains) - useEffect(() => { - if (query) { - trackEvent({ - category: OVERVIEW_EVENTS.SEARCH.category, - action: OVERVIEW_EVENTS.SEARCH.action, - }) - } - }, [query]) - // Include chain names in the search const safesWithChainNames = useMemo( () => safes.map((safe) => { if (isMultiChainSafeItem(safe)) { - const subSafeChains = safe.safes.map( - (subSafe) => chains.data.find((chain) => chain.chainId === subSafe.chainId)?.chainName, + const nestedSafeChains = safe.safes.map( + (nestedSafe) => chains.data.find((chain) => chain.chainId === nestedSafe.chainId)?.chainName, ) - const subSafeNames = safe.safes.map((subSafe) => subSafe.name) - return { ...safe, chainNames: subSafeChains, names: subSafeNames } + const nestedSafeNames = safe.safes.map((nestedSafe) => nestedSafe.name) + return { ...safe, chainNames: nestedSafeChains, names: nestedSafeNames } } const chain = chains.data.find((chain) => chain.chainId === safe.chainId) return { ...safe, chainNames: [chain?.chainName], names: [safe.name] } diff --git a/apps/web/src/features/myAccounts/styles.module.css b/apps/web/src/features/myAccounts/styles.module.css index c9387baf89..0ea86e46f8 100644 --- a/apps/web/src/features/myAccounts/styles.module.css +++ b/apps/web/src/features/myAccounts/styles.module.css @@ -59,6 +59,20 @@ stroke: var(--color-text-primary); } +.link { + text-decoration: none; + color: var(--color-text-secondary); +} + +.link:hover { + color: var(--color-text-primary); +} + +.active { + pointer-events: none; + color: var(--color-text-primary); +} + @media (max-width: 899.95px) { .container { width: auto; diff --git a/apps/web/src/features/proposers/components/DeleteProposerDialog.tsx b/apps/web/src/features/proposers/components/DeleteProposerDialog.tsx index 4ad41ef97c..4b461b09bf 100644 --- a/apps/web/src/features/proposers/components/DeleteProposerDialog.tsx +++ b/apps/web/src/features/proposers/components/DeleteProposerDialog.tsx @@ -8,8 +8,8 @@ import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics' import { useAppDispatch } from '@/store' import { useDeleteProposerMutation } from '@/store/api/gateway' import { showNotification } from '@/store/notificationsSlice' -import { shortenAddress } from '@/utils/formatters' -import { isHardwareWallet } from '@/utils/wallets' +import { shortenAddress } from '@safe-global/utils/utils/formatters' +import { isEthSignWallet } from '@/utils/wallets' import type { Delegate } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates' import React, { useState } from 'react' import { @@ -58,9 +58,9 @@ const InternalDeleteProposer = ({ wallet, safeAddress, chainId, proposer }: Dele } try { - const hardwareWallet = isHardwareWallet(wallet) + const shouldEthSign = isEthSignWallet(wallet) const signer = await getAssertedChainSigner(wallet.provider) - const signature = hardwareWallet + const signature = shouldEthSign ? await signProposerData(proposer.delegate, signer) : await signProposerTypedData(chainId, proposer.delegate, signer) @@ -70,7 +70,7 @@ const InternalDeleteProposer = ({ wallet, safeAddress, chainId, proposer }: Dele delegator: proposer.delegator, safeAddress, signature, - isHardwareWallet: hardwareWallet, + shouldEthSign, }) trackEvent(SETTINGS_EVENTS.PROPOSERS.SUBMIT_REMOVE_PROPOSER) diff --git a/apps/web/src/features/proposers/components/UpsertProposer.tsx b/apps/web/src/features/proposers/components/UpsertProposer.tsx index 2cea318c8d..578da67465 100644 --- a/apps/web/src/features/proposers/components/UpsertProposer.tsx +++ b/apps/web/src/features/proposers/components/UpsertProposer.tsx @@ -13,9 +13,9 @@ import { getAssertedChainSigner } from '@/services/tx/tx-sender/sdk' import { useAppDispatch } from '@/store' import { useAddProposerMutation } from '@/store/api/gateway' import { showNotification } from '@/store/notificationsSlice' -import { shortenAddress } from '@/utils/formatters' -import { addressIsNotCurrentSafe } from '@/utils/validation' -import { isHardwareWallet } from '@/utils/wallets' +import { shortenAddress } from '@safe-global/utils/utils/formatters' +import { addressIsNotCurrentSafe, addressIsNotOwner } from '@safe-global/utils/utils/validation' +import { isEthSignWallet } from '@/utils/wallets' import { Close } from '@mui/icons-material' import { Alert, @@ -31,8 +31,9 @@ import { Typography, } from '@mui/material' import type { Delegate } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates' -import { type BaseSyntheticEvent, useState } from 'react' -import { FormProvider, useForm } from 'react-hook-form' +import { type BaseSyntheticEvent, useCallback, useMemo, useState } from 'react' +import { FormProvider, useForm, type Validate } from 'react-hook-form' +import useSafeInfo from '@/hooks/useSafeInfo' type UpsertProposerProps = { onClose: () => void @@ -59,6 +60,7 @@ const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) = const chainId = useChainId() const wallet = useWallet() const safeAddress = useSafeAddress() + const { safe } = useSafeInfo() const methods = useForm<ProposerEntry>({ defaultValues: { @@ -68,7 +70,14 @@ const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) = mode: 'onChange', }) - const notCurrentSafe = addressIsNotCurrentSafe(safeAddress, 'Cannot add Safe Account itself as proposer') + const safeOwnerAddresses = useMemo(() => safe.owners.map((owner) => owner.value), [safe.owners]) + + const validateAddress = useCallback<Validate<string>>( + (value) => + addressIsNotCurrentSafe(safeAddress, 'Cannot add Safe Account itself as proposer')(value) ?? + addressIsNotOwner(safeOwnerAddresses, 'Cannot add Safe Owner as proposer')(value), + [safeAddress, safeOwnerAddresses], + ) const { handleSubmit, formState } = methods @@ -79,9 +88,9 @@ const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) = setIsLoading(true) try { - const hardwareWallet = isHardwareWallet(wallet) + const shouldEthSign = isEthSignWallet(wallet) const signer = await getAssertedChainSigner(wallet.provider) - const signature = hardwareWallet + const signature = shouldEthSign ? await signProposerData(data.address, signer) : await signProposerTypedData(chainId, data.address, signer) @@ -92,7 +101,7 @@ const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) = label: data.name, delegate: data.address, safeAddress, - isHardwareWallet: hardwareWallet, + shouldEthSign, }) trackEvent( @@ -171,7 +180,7 @@ const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) = <AddressBookInput name="address" label="Address" - validate={notCurrentSafe} + validate={validateAddress} variant="outlined" fullWidth required diff --git a/apps/web/src/features/proposers/utils/utils.ts b/apps/web/src/features/proposers/utils/utils.ts index 13afbb762e..84265c5c89 100644 --- a/apps/web/src/features/proposers/utils/utils.ts +++ b/apps/web/src/features/proposers/utils/utils.ts @@ -1,38 +1,11 @@ -import { signTypedData } from '@/utils/web3' +import { signTypedData } from '@safe-global/utils/utils/web3' import { SigningMethod } from '@safe-global/protocol-kit' import { adjustVInSignature } from '@safe-global/protocol-kit/dist/src/utils/signatures' import type { JsonRpcSigner } from 'ethers' - -const getProposerDataV2 = (chainId: string, proposerAddress: string) => { - const totp = Math.floor(Date.now() / 1000 / 3600) - - const domain = { - name: 'Safe Transaction Service', - version: '1.0', - chainId, - } - - const types = { - Delegate: [ - { name: 'delegateAddress', type: 'address' }, - { name: 'totp', type: 'uint256' }, - ], - } - - const message = { - delegateAddress: proposerAddress, - totp, - } - - return { - domain, - types, - message, - } -} +import { getDelegateTypedData } from '@safe-global/utils/services/delegates' export const signProposerTypedData = async (chainId: string, proposerAddress: string, signer: JsonRpcSigner) => { - const typedData = getProposerDataV2(chainId, proposerAddress) + const typedData = getDelegateTypedData(chainId, proposerAddress) return signTypedData(signer, typedData) } diff --git a/apps/web/src/features/recovery/components/CancelRecoveryButton/index.tsx b/apps/web/src/features/recovery/components/CancelRecoveryButton/index.tsx index 7247d79ceb..cd53faf6e8 100644 --- a/apps/web/src/features/recovery/components/CancelRecoveryButton/index.tsx +++ b/apps/web/src/features/recovery/components/CancelRecoveryButton/index.tsx @@ -12,7 +12,7 @@ import useIsSafeOwner from '@/hooks/useIsSafeOwner' import { dispatchRecoverySkipExpired } from '@/features/recovery/services/recovery-sender' import useSafeInfo from '@/hooks/useSafeInfo' import { trackError, Errors } from '@/services/exceptions' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { useRecoveryTxState } from '@/features/recovery/hooks/useRecoveryTxState' import { RecoveryListItemContext } from '../RecoveryListItem/RecoveryListItemContext' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' diff --git a/apps/web/src/features/recovery/components/GroupedRecoveryListItems/index.tsx b/apps/web/src/features/recovery/components/GroupedRecoveryListItems/index.tsx index 7cccaad24f..8fb61b3aeb 100644 --- a/apps/web/src/features/recovery/components/GroupedRecoveryListItems/index.tsx +++ b/apps/web/src/features/recovery/components/GroupedRecoveryListItems/index.tsx @@ -10,10 +10,10 @@ import { isRecoveryQueueItem } from '@/utils/transaction-guards' import ExpandableTransactionItem from '@/components/transactions/TxListItem/ExpandableTransactionItem' import { RecoveryListItem } from '../RecoveryListItem' import ExternalLink from '@/components/common/ExternalLink' -import { HelpCenterArticle, HelperCenterArticleTitles } from '@/config/constants' import css from '@/components/transactions/GroupedTxListItems/styles.module.css' import customCss from './styles.module.css' +import { HelpCenterArticle, HelperCenterArticleTitles } from '@safe-global/utils/config/constants' function Disclaimer({ isMalicious }: { isMalicious: boolean }): ReactElement { return ( diff --git a/apps/web/src/features/recovery/components/RecoveryCards/RecoveryInProgressCard.tsx b/apps/web/src/features/recovery/components/RecoveryCards/RecoveryInProgressCard.tsx index 53d55615e6..3e07c12370 100644 --- a/apps/web/src/features/recovery/components/RecoveryCards/RecoveryInProgressCard.tsx +++ b/apps/web/src/features/recovery/components/RecoveryCards/RecoveryInProgressCard.tsx @@ -9,10 +9,10 @@ import { Countdown } from '@/components/common/Countdown' import RecoveryPending from '@/public/images/common/recovery-pending.svg' import ExternalLink from '@/components/common/ExternalLink' import { AppRoutes } from '@/config/routes' -import { HelpCenterArticle, HelperCenterArticleTitles } from '@/config/constants' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' import css from './styles.module.css' +import { HelpCenterArticle, HelperCenterArticleTitles } from '@safe-global/utils/config/constants' type Props = | { diff --git a/apps/web/src/features/recovery/components/RecoveryCards/RecoveryProposalCard.tsx b/apps/web/src/features/recovery/components/RecoveryCards/RecoveryProposalCard.tsx index 2f09d78b3a..915522c42f 100644 --- a/apps/web/src/features/recovery/components/RecoveryCards/RecoveryProposalCard.tsx +++ b/apps/web/src/features/recovery/components/RecoveryCards/RecoveryProposalCard.tsx @@ -11,24 +11,24 @@ import { RecoverAccountFlow } from '@/components/tx-flow/flows' import useSafeInfo from '@/hooks/useSafeInfo' import madProps from '@/utils/mad-props' import { TxModalContext } from '@/components/tx-flow' -import { HelpCenterArticle, HelperCenterArticleTitles } from '@/config/constants' import type { TxModalContextType } from '@/components/tx-flow' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import css from './styles.module.css' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' +import { HelpCenterArticle, HelperCenterArticleTitles } from '@safe-global/utils/config/constants' type Props = | { orientation?: 'vertical' onClose: () => void - safe: SafeInfo + safe: SafeState setTxFlow: TxModalContextType['setTxFlow'] } | { orientation: 'horizontal' onClose?: never - safe: SafeInfo + safe: SafeState setTxFlow: TxModalContextType['setTxFlow'] } diff --git a/apps/web/src/features/recovery/components/RecoveryContext/index.tsx b/apps/web/src/features/recovery/components/RecoveryContext/index.tsx index ecc2ee8ac5..771f6e191f 100644 --- a/apps/web/src/features/recovery/components/RecoveryContext/index.tsx +++ b/apps/web/src/features/recovery/components/RecoveryContext/index.tsx @@ -1,6 +1,6 @@ -import ExternalStore from '@/services/ExternalStore' +import ExternalStore from '@safe-global/utils/services/ExternalStore' import type { PendingRecoveryTransactions } from './useRecoveryPendingTxs' -import type { AsyncResult } from '@/hooks/useAsync' +import type { AsyncResult } from '@safe-global/utils/hooks/useAsync' import type { RecoveryState } from '@/features/recovery/services/recovery-state' export type RecoveryContextType = { diff --git a/apps/web/src/features/recovery/components/RecoveryContext/useRecoveryDelayModifiers.ts b/apps/web/src/features/recovery/components/RecoveryContext/useRecoveryDelayModifiers.ts index 76b70a734f..b31f425613 100644 --- a/apps/web/src/features/recovery/components/RecoveryContext/useRecoveryDelayModifiers.ts +++ b/apps/web/src/features/recovery/components/RecoveryContext/useRecoveryDelayModifiers.ts @@ -1,15 +1,15 @@ import type { Delay } from '@gnosis.pm/zodiac' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { getRecoveryDelayModifiers } from '@/features/recovery/services/delay-modifier' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useSafeInfo from '@/hooks/useSafeInfo' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { getDeployedSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts' -import type { AsyncResult } from '@/hooks/useAsync' +import type { AsyncResult } from '@safe-global/utils/hooks/useAsync' import { useIsRecoverySupported } from '../../hooks/useIsRecoverySupported' -function isOnlySpendingLimitEnabled(chainId: string, modules: SafeInfo['modules']) { +function isOnlySpendingLimitEnabled(chainId: string, modules: SafeState['modules']) { if (modules && modules.length > 1) return false const spendingLimit = getDeployedSpendingLimitModuleAddress(chainId, modules) return !!spendingLimit diff --git a/apps/web/src/features/recovery/components/RecoveryContext/useRecoveryState.ts b/apps/web/src/features/recovery/components/RecoveryContext/useRecoveryState.ts index 87b1958bbc..89a86e51a9 100644 --- a/apps/web/src/features/recovery/components/RecoveryContext/useRecoveryState.ts +++ b/apps/web/src/features/recovery/components/RecoveryContext/useRecoveryState.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import type { Delay } from '@gnosis.pm/zodiac' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { useCurrentChain } from '@/hooks/useChains' import useSafeInfo from '@/hooks/useSafeInfo' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' @@ -9,11 +9,11 @@ import useIntervalCounter from '@/hooks/useIntervalCounter' import { getRecoveryState } from '@/features/recovery/services/recovery-state' import { useAppDispatch } from '@/store' import { isCustomTxInfo, isMultiSendTxInfo, isTransactionListItem } from '@/utils/transaction-guards' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { addListener } from '@reduxjs/toolkit' import { txHistorySlice } from '@/store/txHistorySlice' import { RecoveryEvent, recoverySubscribe } from '@/features/recovery/services/recoveryEvents' -import type { AsyncResult } from '@/hooks/useAsync' +import type { AsyncResult } from '@safe-global/utils/hooks/useAsync' import type { RecoveryState } from '@/features/recovery/services/recovery-state' const REFRESH_DELAY = 5 * 60 * 1_000 // 5 minutes diff --git a/apps/web/src/features/recovery/components/RecoveryDetails/index.tsx b/apps/web/src/features/recovery/components/RecoveryDetails/index.tsx index 94d24176d3..4d29f314a2 100644 --- a/apps/web/src/features/recovery/components/RecoveryDetails/index.tsx +++ b/apps/web/src/features/recovery/components/RecoveryDetails/index.tsx @@ -3,14 +3,13 @@ import { useState } from 'react' import { Operation } from '@safe-global/safe-gateway-typescript-sdk' import type { ReactElement } from 'react' -import { dateString } from '@/utils/formatters' +import { dateString } from '@safe-global/utils/utils/formatters' import { generateDataRowValue, TxDataRow } from '@/components/transactions/TxDetails/Summary/TxDataRow' import { RecoverySigners } from '../RecoverySigners' import { RecoveryDescription } from '../RecoveryDescription' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' import txDetailsCss from '@/components/transactions/TxDetails/styles.module.css' -import summaryCss from '@/components/transactions/TxDetails/Summary/styles.module.css' export function RecoveryDetails({ item }: { item: RecoveryQueueItem }): ReactElement { const { transactionHash, timestamp, validFrom, expiresAt, args, address } = item @@ -35,7 +34,7 @@ export function RecoveryDetails({ item }: { item: RecoveryQueueItem }): ReactEle {expiresAt !== null && <TxDataRow title="Expires:">{dateString(Number(expiresAt))}</TxDataRow>} - <Link className={summaryCss.buttonExpand} onClick={toggleExpanded} component="button" variant="body1"> + <Link onClick={toggleExpanded} component="button" variant="body1"> Advanced details </Link> diff --git a/apps/web/src/features/recovery/components/RecoveryModal/index.tsx b/apps/web/src/features/recovery/components/RecoveryModal/index.tsx index 67cfe15ea3..549b8a92f3 100644 --- a/apps/web/src/features/recovery/components/RecoveryModal/index.tsx +++ b/apps/web/src/features/recovery/components/RecoveryModal/index.tsx @@ -12,7 +12,7 @@ import madProps from '@/utils/mad-props' import useLocalStorage from '@/services/local-storage/useLocalStorage' import useWallet from '@/hooks/wallets/useWallet' import useSafeInfo from '@/hooks/useSafeInfo' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { useIsSidebarRoute } from '@/hooks/useIsSidebarRoute' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' diff --git a/apps/web/src/features/recovery/components/RecoverySettings/ChooseRecoveryMethodModal.tsx b/apps/web/src/features/recovery/components/RecoverySettings/ChooseRecoveryMethodModal.tsx index 7f6b1ec2ca..f756694e8c 100644 --- a/apps/web/src/features/recovery/components/RecoverySettings/ChooseRecoveryMethodModal.tsx +++ b/apps/web/src/features/recovery/components/RecoverySettings/ChooseRecoveryMethodModal.tsx @@ -1,5 +1,5 @@ import Track from '@/components/common/Track' -import { RECOVERY_FEEDBACK_FORM, HelpCenterArticle, SafeAppsTag } from '@/config/constants' +import { RECOVERY_FEEDBACK_FORM, SafeAppsTag } from '@/config/constants' import { trackEvent } from '@/services/analytics' import { RECOVERY_EVENTS } from '@/services/analytics/events/recovery' import { type ChangeEvent, type ReactElement, useContext, useState, useCallback } from 'react' @@ -35,6 +35,7 @@ import { useSearchParams } from 'next/navigation' import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' import TxStatusChip from '@/components/transactions/TxStatusChip' import { ZkEmailFakeDoorModal } from './ZkEmailFakeDoorModal' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' enum RecoveryMethod { SelfCustody = 'SelfCustody', @@ -54,7 +55,7 @@ export function ChooseRecoveryMethodModal({ open, onClose }: { open: boolean; on const { setTxFlow } = useContext(TxModalContext) const [openZkEmailModal, setOpenZkEmailModal] = useState(false) const querySafe = useSearchParams().get('safe') - const [matchingApps] = useRemoteSafeApps(SafeAppsTag.RECOVERY_SYGNUM) + const [matchingApps] = useRemoteSafeApps({ tag: SafeAppsTag.RECOVERY_SYGNUM }) const hasSygnumApp = Boolean(matchingApps?.length) const methods = useForm<Fields>({ diff --git a/apps/web/src/features/recovery/components/RecoverySettings/index.tsx b/apps/web/src/features/recovery/components/RecoverySettings/index.tsx index 0aa340ddec..be1b9fdbcd 100644 --- a/apps/web/src/features/recovery/components/RecoverySettings/index.tsx +++ b/apps/web/src/features/recovery/components/RecoverySettings/index.tsx @@ -11,11 +11,11 @@ import EthHashInfo from '@/components/common/EthHashInfo' import EnhancedTable from '@/components/common/EnhancedTable' import InfoIcon from '@/public/images/notifications/info.svg' import CheckWallet from '@/components/common/CheckWallet' -import { getPeriod } from '@/utils/date' -import { HelpCenterArticle, HelperCenterArticleTitles } from '@/config/constants' +import { getPeriod } from '@safe-global/utils/utils/date' import { TOOLTIP_TITLES } from '@/components/tx-flow/common/constants' import tableCss from '@/components/common/EnhancedTable/styles.module.css' +import { HelpCenterArticle, HelperCenterArticleTitles } from '@safe-global/utils/config/constants' enum HeadCells { Recoverer = 'recoverer', diff --git a/apps/web/src/features/recovery/components/RecoverySigners/index.tsx b/apps/web/src/features/recovery/components/RecoverySigners/index.tsx index f33c9260e2..907a5b0774 100644 --- a/apps/web/src/features/recovery/components/RecoverySigners/index.tsx +++ b/apps/web/src/features/recovery/components/RecoverySigners/index.tsx @@ -8,7 +8,7 @@ import { Countdown } from '@/components/common/Countdown' import { ExecuteRecoveryButton } from '../ExecuteRecoveryButton' import { CancelRecoveryButton } from '../CancelRecoveryButton' import { useRecoveryTxState } from '@/features/recovery/hooks/useRecoveryTxState' -import { formatDateTime } from '@/utils/date' +import { formatDateTime } from '@safe-global/utils/utils/date' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' import txSignersCss from '@/components/transactions/TxSigners/styles.module.css' diff --git a/apps/web/src/features/recovery/components/RecoveryType/index.tsx b/apps/web/src/features/recovery/components/RecoveryType/index.tsx index a1f5fcbf14..638d478855 100644 --- a/apps/web/src/features/recovery/components/RecoveryType/index.tsx +++ b/apps/web/src/features/recovery/components/RecoveryType/index.tsx @@ -1,22 +1,40 @@ import { Box, SvgIcon, Typography } from '@mui/material' import type { ReactElement } from 'react' - import RecoveryPlusIcon from '@/public/images/common/recovery-plus.svg' - import txTypeCss from '@/components/transactions/TxType/styles.module.css' +import pendingTxCss from '@/components/dashboard/PendingTxs/styles.module.css' +import { DateTime } from '@/components/common/DateTime/DateTime' -export function RecoveryType({ isMalicious }: { isMalicious: boolean }): ReactElement { +export function RecoveryType({ + isMalicious, + date, + isDashboard = false, +}: { + isMalicious: boolean + date?: bigint + isDashboard?: boolean +}): ReactElement { return ( - <Box className={txTypeCss.txType}> - <SvgIcon - component={RecoveryPlusIcon} - inheritViewBox - fontSize="inherit" - sx={{ '& path': { fill: ({ palette }) => palette.warning.main } }} - /> - <Typography color={isMalicious ? 'error.main' : undefined}> - {isMalicious ? 'Malicious transaction' : 'Account recovery'} - </Typography> + <Box className={txTypeCss.txType} gap={isDashboard ? '12px !important' : 1}> + <Box className={isDashboard ? pendingTxCss.iconWrapper : undefined}> + <SvgIcon + component={RecoveryPlusIcon} + inheritViewBox + fontSize="inherit" + sx={{ '& path': { fill: ({ palette }) => palette.warning.main } }} + /> + </Box> + <Box> + <Typography color={isMalicious ? 'error.main' : undefined}> + {isMalicious ? 'Malicious transaction' : 'Account recovery'} + </Typography> + + {date && ( + <Typography variant="body2" color="primary.light"> + <DateTime value={Number(date)} showDateTime={false} showTime={false} /> + </Typography> + )} + </Box> </Box> ) } diff --git a/apps/web/src/features/recovery/hooks/__tests__/useIsValidExecution.test.ts b/apps/web/src/features/recovery/hooks/__tests__/useIsValidExecution.test.ts index 6ca079a063..bc833370aa 100644 --- a/apps/web/src/features/recovery/hooks/__tests__/useIsValidExecution.test.ts +++ b/apps/web/src/features/recovery/hooks/__tests__/useIsValidExecution.test.ts @@ -1,5 +1,5 @@ import type { SafeContractImplementationType } from '@safe-global/protocol-kit' -import type { SafeTransaction, SafeSignature } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction, SafeSignature } from '@safe-global/types-kit' import * as useWallet from '@/hooks/wallets/useWallet' import { act, renderHook } from '@/tests/test-utils' import useIsValidExecution from '../../../../hooks/useIsValidExecution' @@ -62,12 +62,8 @@ describe('useIsValidExecution', () => { jest.spyOn(contracts, 'getCurrentGnosisSafeContract').mockImplementation(() => Promise.resolve({ - contract: { - execTransaction: { - staticCall: () => { - throw error - }, - }, + isValidTransaction: () => { + throw error }, } as unknown as SafeContractImplementationType), ) diff --git a/apps/web/src/features/recovery/hooks/useIsRecoverySupported.ts b/apps/web/src/features/recovery/hooks/useIsRecoverySupported.ts index 549070dea7..047d9e13ec 100644 --- a/apps/web/src/features/recovery/hooks/useIsRecoverySupported.ts +++ b/apps/web/src/features/recovery/hooks/useIsRecoverySupported.ts @@ -1,5 +1,6 @@ import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' + +import { FEATURES } from '@safe-global/utils/utils/chains' export function useIsRecoverySupported(): boolean { return useHasFeature(FEATURES.RECOVERY) ?? false diff --git a/apps/web/src/features/recovery/hooks/useIsValidRecoveryExecution.ts b/apps/web/src/features/recovery/hooks/useIsValidRecoveryExecution.ts index 039c7618e1..7aa5e333ca 100644 --- a/apps/web/src/features/recovery/hooks/useIsValidRecoveryExecution.ts +++ b/apps/web/src/features/recovery/hooks/useIsValidRecoveryExecution.ts @@ -1,14 +1,14 @@ import { getModuleInstance, KnownContracts } from '@gnosis.pm/zodiac' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction } from '@safe-global/types-kit' import useWallet from '../../../hooks/wallets/useWallet' import { useWeb3ReadOnly } from '../../../hooks/wallets/web3' -import useAsync from '../../../hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useSafeInfo from '../../../hooks/useSafeInfo' import { getPatchedSignerProvider } from '../../../hooks/useIsValidExecution' import { useRecoveryTxState } from './useRecoveryTxState' import { useIsRecoverer } from './useIsRecoverer' -import type { AsyncResult } from '../../../hooks/useAsync' +import type { AsyncResult } from '@safe-global/utils/hooks/useAsync' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' export function useIsValidRecoveryExecTransactionFromModule( diff --git a/apps/web/src/features/recovery/hooks/useRecoveryTxNotification.ts b/apps/web/src/features/recovery/hooks/useRecoveryTxNotification.ts index fc24e7cbb7..a4c029d6f2 100644 --- a/apps/web/src/features/recovery/hooks/useRecoveryTxNotification.ts +++ b/apps/web/src/features/recovery/hooks/useRecoveryTxNotification.ts @@ -1,13 +1,13 @@ import { useEffect } from 'react' -import { formatError } from '@/utils/formatters' +import { formatError } from '@safe-global/utils/utils/formatters' import { showNotification } from '@/store/notificationsSlice' import { useAppDispatch } from '@/store' import useSafeAddress from '../../../hooks/useSafeAddress' import { RecoveryEvent, RecoveryTxType, recoverySubscribe } from '@/features/recovery/services/recoveryEvents' -import { getExplorerLink } from '@/utils/gateway' import { useCurrentChain } from '../../../hooks/useChains' import { isWalletRejection } from '@/utils/wallets' +import { getExplorerLink } from '@safe-global/utils/utils/gateway' const SUCCESS_EVENTS = [ RecoveryEvent.PROCESSING_BY_SMART_CONTRACT_WALLET, diff --git a/apps/web/src/features/recovery/hooks/useRecoveryTxState.ts b/apps/web/src/features/recovery/hooks/useRecoveryTxState.ts index 4ba67a7df1..eb6d2ea380 100644 --- a/apps/web/src/features/recovery/hooks/useRecoveryTxState.ts +++ b/apps/web/src/features/recovery/hooks/useRecoveryTxState.ts @@ -1,7 +1,7 @@ import { useClock } from '../../../hooks/useClock' import { selectDelayModifierByTxHash } from '@/features/recovery/services/selectors' import recoveryStore from '@/features/recovery/components/RecoveryContext' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args, address }: RecoveryQueueItem): { diff --git a/apps/web/src/features/recovery/services/__tests__/recovery-state.test.ts b/apps/web/src/features/recovery/services/__tests__/recovery-state.test.ts index 447a866565..1deda198ed 100644 --- a/apps/web/src/features/recovery/services/__tests__/recovery-state.test.ts +++ b/apps/web/src/features/recovery/services/__tests__/recovery-state.test.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker' import { id, zeroPadValue } from 'ethers' import { JsonRpcProvider } from 'ethers' import cloneDeep from 'lodash/cloneDeep' -import type { Delay, TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Delay' +import type { TransactionAddedEvent, Delay } from '@gnosis.pm/zodiac/dist/cjs/types/Delay' import type { TransactionReceipt } from 'ethers' import { @@ -15,8 +15,11 @@ import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { encodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils/transactions/utils' import { getMultiSendCallOnlyDeployment, getSafeSingletonDeployment } from '@safe-global/safe-deployments' import { Interface } from 'ethers' -import { getLatestSafeVersion } from '@/utils/chains' import { chainBuilder } from '@/tests/builders/chains' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' +import { getModuleInstance, KnownContracts, ContractAbis } from '@gnosis.pm/zodiac' +import { createMockWeb3Provider } from '@safe-global/utils/tests/web3Provider' +import { SENTINEL_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' jest.mock('@/hooks/wallets/web3') @@ -32,6 +35,8 @@ const SUPPORTED_MULTI_SEND_CALL_ONLY_VERSIONS = [ latestSafeVersion, ] +const DELAY_INTERFACE = new Interface(ContractAbis[KnownContracts.DELAY]) + describe('recovery-state', () => { beforeEach(() => { // Clear memoization cache @@ -366,12 +371,6 @@ describe('recovery-state', () => { const transactionAddedReceipt = { from: faker.finance.ethereumAddress(), } as TransactionReceipt - const provider = { - getTransactionReceipt: jest - .fn() - .mockResolvedValueOnce(safeCreationReceipt) - .mockResolvedValue(transactionAddedReceipt), - } as unknown as JsonRpcProvider global.fetch = jest.fn().mockImplementation(() => { return Promise.resolve({ @@ -419,34 +418,69 @@ describe('recovery-state', () => { const defaultTransactionAddedFilter = { getTopicFilter: jest.fn().mockResolvedValue([...topics]), } - const delayModifier = { + + const mockProvider = createMockWeb3Provider([ + { + signature: DELAY_INTERFACE.getFunction('getModulesPaginated')?.selector!, + returnType: 'raw', + returnValue: DELAY_INTERFACE.encodeFunctionResult('getModulesPaginated', [recoverers, SENTINEL_ADDRESS]), + }, + { + signature: DELAY_INTERFACE.getFunction('txExpiration')?.selector!, + returnType: 'uint256', + returnValue: expiry, + }, + { + signature: DELAY_INTERFACE.getFunction('txCooldown')?.selector!, + returnType: 'uint256', + returnValue: delay, + }, + { + signature: DELAY_INTERFACE.getFunction('txNonce')?.selector!, + returnType: 'uint256', + returnValue: txNonce, + }, + { + signature: DELAY_INTERFACE.getFunction('queueNonce')?.selector!, + returnType: 'uint256', + returnValue: queueNonce, + }, + ]) + ;(mockProvider.getTransactionReceipt as jest.MockedFunction<JsonRpcProvider['getTransactionReceipt']>) + .mockResolvedValueOnce(safeCreationReceipt) + .mockResolvedValue(transactionAddedReceipt) + + const mockDelayModifierAddress = faker.finance.ethereumAddress() + + const delayModifier = getModuleInstance(KnownContracts.DELAY, mockDelayModifierAddress, mockProvider) + + const patchedDelayModifier = { + ...delayModifier, filters: { TransactionAdded: () => cloneDeep(defaultTransactionAddedFilter), }, - getAddress: jest.fn().mockResolvedValue(faker.finance.ethereumAddress()), - getModulesPaginated: () => Promise.resolve([recoverers]), - txExpiration: () => Promise.resolve(expiry), - txCooldown: () => Promise.resolve(delay), - txNonce: () => Promise.resolve(txNonce), + getAddress: jest.fn().mockResolvedValue(mockDelayModifierAddress), txCreatedAt: jest .fn() .mockResolvedValueOnce(420n) .mockResolvedValueOnce(69420n) .mockResolvedValueOnce(6942069n), - queueNonce: () => Promise.resolve(queueNonce), queryFilter: queryFilterMock.mockImplementation(() => Promise.resolve(transactionsAdded)), } const recoveryState = await _getRecoveryStateItem({ - delayModifier: delayModifier as unknown as Delay, + delayModifier: patchedDelayModifier as unknown as Delay, safeAddress, transactionService, - provider, + provider: mockProvider, chainId, version, }) - expect(recoveryState).toStrictEqual({ + expect({ + ...recoveryState, + recoverers: recoveryState.recoverers.map((recoverer) => recoverer.toLowerCase()), + }).toEqual({ address: await delayModifier.getAddress(), recoverers, expiry, @@ -484,7 +518,6 @@ describe('recovery-state', () => { const chainId = '1' const version = '1.3.0' const transactionService = faker.internet.url({ appendSlash: true }) - const provider = {} as unknown as JsonRpcProvider const recoverers = [faker.finance.ethereumAddress()] const expiry = 0n @@ -492,34 +525,71 @@ describe('recovery-state', () => { const txNonce = 2n const queueNonce = 2n + const mockProvider = createMockWeb3Provider([ + { + signature: DELAY_INTERFACE.getFunction('getModulesPaginated')?.selector!, + returnType: 'raw', + returnValue: DELAY_INTERFACE.encodeFunctionResult('getModulesPaginated', [recoverers, SENTINEL_ADDRESS]), + }, + { + signature: DELAY_INTERFACE.getFunction('txExpiration')?.selector!, + returnType: 'uint256', + returnValue: expiry, + }, + { + signature: DELAY_INTERFACE.getFunction('txCooldown')?.selector!, + returnType: 'uint256', + returnValue: delay, + }, + { + signature: DELAY_INTERFACE.getFunction('txNonce')?.selector!, + returnType: 'uint256', + returnValue: txNonce, + }, + { + signature: DELAY_INTERFACE.getFunction('queueNonce')?.selector!, + returnType: 'uint256', + returnValue: queueNonce, + }, + ]) + const queryFilterMock = jest.fn() const defaultTransactionAddedFilter = { address: faker.finance.ethereumAddress(), topics: [id('TransactionAdded(uint256,bytes32,address,uint256,bytes,uint8)')], } - const delayModifier = { + + const mockDelayModifierAddress = faker.finance.ethereumAddress() + + const delayModifier = getModuleInstance(KnownContracts.DELAY, mockDelayModifierAddress, mockProvider) + + const patchedDelayModifier = { + ...delayModifier, filters: { TransactionAdded: () => cloneDeep(defaultTransactionAddedFilter), }, - getAddress: jest.fn().mockResolvedValue(faker.finance.ethereumAddress()), - getModulesPaginated: () => Promise.resolve([recoverers]), - txExpiration: () => Promise.resolve(expiry), - txCooldown: () => Promise.resolve(delay), - txNonce: () => Promise.resolve(txNonce), - queueNonce: () => Promise.resolve(queueNonce), - queryFilter: queryFilterMock.mockRejectedValue('Not required'), + getAddress: jest.fn().mockResolvedValue(mockDelayModifierAddress), + txCreatedAt: jest + .fn() + .mockResolvedValueOnce(420n) + .mockResolvedValueOnce(69420n) + .mockResolvedValueOnce(6942069n), + queryFilter: queryFilterMock, } const recoveryState = await _getRecoveryStateItem({ - delayModifier: delayModifier as unknown as Delay, + delayModifier: patchedDelayModifier as unknown as Delay, safeAddress, transactionService, - provider, + provider: mockProvider, chainId, version, }) - expect(recoveryState).toStrictEqual({ + expect({ + ...recoveryState, + recoverers: recoveryState.recoverers.map((recoverer) => recoverer.toLowerCase()), + }).toEqual({ address: await delayModifier.getAddress(), recoverers, expiry, diff --git a/apps/web/src/features/recovery/services/__tests__/setup.test.ts b/apps/web/src/features/recovery/services/__tests__/setup.test.ts index 8e81350fca..ed1b1a981b 100644 --- a/apps/web/src/features/recovery/services/__tests__/setup.test.ts +++ b/apps/web/src/features/recovery/services/__tests__/setup.test.ts @@ -1,6 +1,6 @@ import { getModuleInstance, KnownContracts, deployAndSetUpModule } from '@gnosis.pm/zodiac' import { faker } from '@faker-js/faker' -import { OperationType } from '@safe-global/safe-core-sdk-types' +import { OperationType } from '@safe-global/types-kit' import { SENTINEL_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { _getEditRecoveryTransactions, _getRecoverySetupTransactions } from '@/features/recovery/services/setup' diff --git a/apps/web/src/features/recovery/services/__tests__/transaction-list.test.ts b/apps/web/src/features/recovery/services/__tests__/transaction-list.test.ts index 431a86fe47..b12d5c09d7 100644 --- a/apps/web/src/features/recovery/services/__tests__/transaction-list.test.ts +++ b/apps/web/src/features/recovery/services/__tests__/transaction-list.test.ts @@ -6,7 +6,7 @@ import { encodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils/tr import { safeInfoBuilder } from '@/tests/builders/safe' import { getRecoveredSafeInfo } from '../transaction-list' -import { checksumAddress, sameAddress } from '@/utils/addresses' +import { checksumAddress, sameAddress } from '@safe-global/utils/utils/addresses' describe('getRecoveredSafeInfo', () => { describe('non-MultiSend', () => { diff --git a/apps/web/src/features/recovery/services/delay-modifier.ts b/apps/web/src/features/recovery/services/delay-modifier.ts index 28e8e1c3cb..633388165d 100644 --- a/apps/web/src/features/recovery/services/delay-modifier.ts +++ b/apps/web/src/features/recovery/services/delay-modifier.ts @@ -2,9 +2,9 @@ import { ContractVersions, getModuleInstance, KnownContracts } from '@gnosis.pm/ import { SENTINEL_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import type { Delay, SupportedNetworks } from '@gnosis.pm/zodiac' import { type JsonRpcProvider, isAddress } from 'ethers' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { getGenericProxyMasterCopy, getGnosisProxyMasterCopy, isGenericProxy, isGnosisProxy } from './proxies' import { MAX_RECOVERER_PAGE_SIZE } from './recovery-state' @@ -63,7 +63,7 @@ export async function _isOfficialRecoveryDelayModifier( export async function getRecoveryDelayModifiers( chainId: string, - modules: SafeInfo['modules'], + modules: SafeState['modules'], provider: JsonRpcProvider, ): Promise<Array<Delay>> { if (!modules) { diff --git a/apps/web/src/features/recovery/services/recovery-sender.ts b/apps/web/src/features/recovery/services/recovery-sender.ts index 8ca388f479..3764bc2cfc 100644 --- a/apps/web/src/features/recovery/services/recovery-sender.ts +++ b/apps/web/src/features/recovery/services/recovery-sender.ts @@ -1,12 +1,12 @@ import { getModuleInstance, KnownContracts } from '@gnosis.pm/zodiac' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import type { SafeTransaction } from '@safe-global/types-kit' import type { TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Delay' -import type { Eip1193Provider, TransactionResponse } from 'ethers' +import type { Eip1193Provider, Overrides, TransactionResponse } from 'ethers' import { didReprice, didRevert } from '@/utils/ethers-utils' import { recoveryDispatch, RecoveryEvent, RecoveryTxType } from './recoveryEvents' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { getUncheckedSigner } from '../../../services/tx/tx-sender/sdk' import { isSmartContractWallet } from '@/utils/wallets' @@ -76,12 +76,14 @@ export async function dispatchRecoveryProposal({ safeTx, delayModifierAddress, signerAddress, + overrides, }: { provider: Eip1193Provider - safe: SafeInfo + safe: SafeState safeTx: SafeTransaction delayModifierAddress: string signerAddress: string + overrides: Overrides }) { const { delayModifier, isUnchecked } = await getDelayModifierContract({ provider, @@ -107,6 +109,7 @@ export async function dispatchRecoveryProposal({ safeTx.data.value, safeTx.data.data, safeTx.data.operation, + overrides, ) if (isUnchecked) { @@ -142,12 +145,14 @@ export async function dispatchRecoveryExecution({ args, delayModifierAddress, signerAddress, + overrides, }: { provider: Eip1193Provider chainId: string args: TransactionAddedEvent.Log['args'] delayModifierAddress: string signerAddress: string + overrides: Overrides }) { const { delayModifier, isUnchecked } = await getDelayModifierContract({ provider, @@ -159,7 +164,7 @@ export async function dispatchRecoveryExecution({ const txType = RecoveryTxType.EXECUTION try { - const tx = await delayModifier.executeNextTx(args.to, args.value, args.data, args.operation) + const tx = await delayModifier.executeNextTx(args.to, args.value, args.data, args.operation, overrides) if (isUnchecked) { recoveryDispatch(RecoveryEvent.PROCESSING_BY_SMART_CONTRACT_WALLET, { diff --git a/apps/web/src/features/recovery/services/recovery-state.ts b/apps/web/src/features/recovery/services/recovery-state.ts index 70941f90dc..dded6651a5 100644 --- a/apps/web/src/features/recovery/services/recovery-state.ts +++ b/apps/web/src/features/recovery/services/recovery-state.ts @@ -1,14 +1,15 @@ import { SENTINEL_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import memoize from 'lodash/memoize' import { getMultiSendCallOnlyDeployment } from '@safe-global/safe-deployments' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import type { Delay } from '@gnosis.pm/zodiac' import type { TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Delay' import { toBeHex, type JsonRpcProvider, type TransactionReceipt } from 'ethers' import { trimTrailingSlash } from '@/utils/url' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { isMultiSendCalldata } from '@/utils/transaction-calldata' import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' +import { multicall } from '@safe-global/utils/utils/multicall' export const MAX_RECOVERER_PAGE_SIZE = 100 @@ -40,7 +41,7 @@ export function _isMaliciousRecovery({ transaction, }: { chainId: string - version: SafeInfo['version'] + version: SafeState['version'] safeAddress: string transaction: Pick<AddedEvent['args'], 'to' | 'data'> }) { @@ -168,7 +169,7 @@ const getRecoveryQueueItem = async ({ expiry: bigint provider: JsonRpcProvider chainId: string - version: SafeInfo['version'] + version: SafeState['version'] safeAddress: string }): Promise<RecoveryQueueItem> => { const [timestamps, receipt] = await Promise.all([ @@ -209,20 +210,51 @@ export const _getRecoveryStateItem = async ({ safeAddress: string provider: JsonRpcProvider chainId: string - version: SafeInfo['version'] + version: SafeState['version'] }): Promise<RecoveryStateItem> => { - const [[recoverers], expiry, delay, txNonce, queueNonce] = await Promise.all([ - delayModifier.getModulesPaginated(SENTINEL_ADDRESS, MAX_RECOVERER_PAGE_SIZE), - delayModifier.txExpiration(), - delayModifier.txCooldown(), - delayModifier.txNonce(), - delayModifier.queueNonce(), - ]) + const delayModifierAddress = await delayModifier.getAddress() + const calls = [ + { + to: delayModifierAddress, + data: delayModifier.interface.encodeFunctionData('getModulesPaginated', [ + SENTINEL_ADDRESS, + MAX_RECOVERER_PAGE_SIZE, + ]), + }, + { + to: delayModifierAddress, + data: delayModifier.interface.encodeFunctionData('txExpiration'), + }, + { + to: delayModifierAddress, + data: delayModifier.interface.encodeFunctionData('txCooldown'), + }, + { + to: delayModifierAddress, + data: delayModifier.interface.encodeFunctionData('txNonce'), + }, + { + to: delayModifierAddress, + data: delayModifier.interface.encodeFunctionData('queueNonce'), + }, + ] + const callResults = await multicall(provider, calls) + + const [[recoverers], expiry, delay, txNonce, queueNonce] = [ + delayModifier.interface.decodeFunctionResult('getModulesPaginated', callResults[0].returnData) as unknown as [ + string[], + string, + ], + BigInt(callResults[1].returnData), + BigInt(callResults[2].returnData), + BigInt(callResults[3].returnData), + BigInt(callResults[4].returnData), + ] const queuedTransactionsAdded = await queryAddedTransactions( delayModifier, - BigInt(queueNonce), - BigInt(txNonce), + queueNonce, + txNonce, transactionService, provider, safeAddress, @@ -263,7 +295,7 @@ export function getRecoveryState({ safeAddress: string provider: JsonRpcProvider chainId: string - version: SafeInfo['version'] + version: SafeState['version'] }): Promise<RecoveryState> { return Promise.all(delayModifiers.map((delayModifier) => _getRecoveryStateItem({ delayModifier, ...rest }))) } diff --git a/apps/web/src/features/recovery/services/selectors.ts b/apps/web/src/features/recovery/services/selectors.ts index ceda752f17..aa6ac50aa0 100644 --- a/apps/web/src/features/recovery/services/selectors.ts +++ b/apps/web/src/features/recovery/services/selectors.ts @@ -1,6 +1,6 @@ import { createSelector } from '@reduxjs/toolkit' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import type { RecoveryState } from '@/features/recovery/services/recovery-state' // Identity function to help with type inference diff --git a/apps/web/src/features/recovery/services/setup.ts b/apps/web/src/features/recovery/services/setup.ts index 865bc2b2ca..4833bc0a32 100644 --- a/apps/web/src/features/recovery/services/setup.ts +++ b/apps/web/src/features/recovery/services/setup.ts @@ -1,11 +1,11 @@ -import { OperationType } from '@safe-global/safe-core-sdk-types' +import { OperationType } from '@safe-global/types-kit' import { SENTINEL_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { getModuleInstance, KnownContracts, deployAndSetUpModule } from '@gnosis.pm/zodiac' import { Interface } from 'ethers' import type { JsonRpcProvider } from 'ethers' -import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' +import type { MetaTransactionData } from '@safe-global/types-kit' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { MAX_RECOVERER_PAGE_SIZE } from './recovery-state' import type { UpsertRecoveryFlowProps } from '@/components/tx-flow/flows/UpsertRecovery' diff --git a/apps/web/src/features/recovery/services/transaction-list.ts b/apps/web/src/features/recovery/services/transaction-list.ts index 8f37c43efd..3b9da8b753 100644 --- a/apps/web/src/features/recovery/services/transaction-list.ts +++ b/apps/web/src/features/recovery/services/transaction-list.ts @@ -1,4 +1,4 @@ -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { isSwapOwnerCalldata, isAddOwnerWithThresholdCalldata, @@ -9,10 +9,10 @@ import { import { getSafeSingletonDeployment } from '@safe-global/safe-deployments' import { Interface } from 'ethers' import type { BaseTransaction } from '@safe-global/safe-apps-sdk' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' -function decodeOwnerManagementTransaction(safe: SafeInfo, transaction: BaseTransaction): SafeInfo { +function decodeOwnerManagementTransaction(safe: SafeState, transaction: BaseTransaction): SafeState { const safeDeployment = getSafeSingletonDeployment({ network: safe.chainId, version: safe.version ?? undefined }) if (!safeDeployment) { @@ -53,7 +53,7 @@ function decodeOwnerManagementTransaction(safe: SafeInfo, transaction: BaseTrans } } -export function getRecoveredSafeInfo(safe: SafeInfo, transaction: BaseTransaction): SafeInfo { +export function getRecoveredSafeInfo(safe: SafeState, transaction: BaseTransaction): SafeState { const transactions = isMultiSendCalldata(transaction.data) ? decodeMultiSendData(transaction.data) : [transaction] return transactions.reduce((acc, cur) => { diff --git a/apps/web/src/features/recovery/services/transaction.ts b/apps/web/src/features/recovery/services/transaction.ts index ac3e962c98..3aedc6630a 100644 --- a/apps/web/src/features/recovery/services/transaction.ts +++ b/apps/web/src/features/recovery/services/transaction.ts @@ -1,11 +1,11 @@ import { Interface } from 'ethers' import { getSafeSingletonDeployment } from '@safe-global/safe-deployments' import { SENTINEL_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' -import { OperationType } from '@safe-global/safe-core-sdk-types' -import { sameAddress } from '@/utils/addresses' +import { OperationType } from '@safe-global/types-kit' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { getModuleInstance, KnownContracts } from '@gnosis.pm/zodiac' -import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' -import type { AddressEx, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { MetaTransactionData } from '@safe-global/types-kit' +import { type SafeState, type AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' import type { Provider } from 'ethers' @@ -14,9 +14,9 @@ export function getRecoveryProposalTransactions({ newThreshold, newOwners, }: { - safe: SafeInfo + safe: SafeState newThreshold: number - newOwners: Array<AddressEx> + newOwners: Array<AddressInfo> }): Array<MetaTransactionData> { const safeDeployment = getSafeSingletonDeployment({ network: safe.chainId, version: safe.version ?? undefined }) diff --git a/apps/web/src/features/siweAccounts/components/EnableAccountBanner/index.stories.tsx b/apps/web/src/features/siweAccounts/components/EnableAccountBanner/index.stories.tsx deleted file mode 100644 index 223aae84c9..0000000000 --- a/apps/web/src/features/siweAccounts/components/EnableAccountBanner/index.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import EnableAccountBanner from './index' -import { Paper } from '@mui/material' -import type { Eip1193Provider } from 'ethers' -import { BrowserProvider } from 'ethers' - -const mockBrowserProvider = new BrowserProvider({ - request: () => {}, -} as unknown as Eip1193Provider) - -const meta = { - component: EnableAccountBanner, - parameters: { - layout: 'centered', - }, - decorators: [ - (Story) => { - return ( - <Paper sx={{ padding: 2 }}> - <Story /> - </Paper> - ) - }, - ], - - tags: ['autodocs'], -} satisfies Meta<typeof EnableAccountBanner> - -export default meta -type Story = StoryObj<typeof meta> - -// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args -export const Default: Story = { - args: { - provider: mockBrowserProvider, - }, -} diff --git a/apps/web/src/features/siweAccounts/components/EnableAccountBanner/index.tsx b/apps/web/src/features/siweAccounts/components/EnableAccountBanner/index.tsx deleted file mode 100644 index 126c3d750b..0000000000 --- a/apps/web/src/features/siweAccounts/components/EnableAccountBanner/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { signInWithEthereum } from '@/services/siwe' -import { Alert, Box, Button, IconButton, Typography } from '@mui/material' -import useWallet from '@/hooks/wallets/useWallet' -import CloseIcon from '@mui/icons-material/Close' -import DownloadCloud from '@/public/images/common/download-cloud.svg' -import ChevronRightIcon from '@mui/icons-material/ChevronRight' - -import css from './style.module.css' -import { useState } from 'react' -import { createAccount, getAccount } from '@safe-global/safe-gateway-typescript-sdk' -import type { BrowserProvider } from 'ethers' -import { logError } from '@/services/exceptions' -import ErrorCodes from '@/services/exceptions/ErrorCodes' - -const SignInBanner = ({ provider }: { provider: BrowserProvider | undefined }) => { - const { address = '' } = useWallet() || {} - const [isDismissed, setIsDismissed] = useState(false) - - if (!provider || isDismissed) return null - - const signIn = async () => { - let account - try { - await signInWithEthereum(provider) - account = await getAccount(address) - } catch (error) { - logError(ErrorCodes._640, error) - } - if (!account) { - try { - account = await createAccount({ address: address as `0x${string}` }) - } catch (error) { - logError(ErrorCodes._641, error) - } - } - } - - return ( - <div className={css.container}> - <Alert - icon={<DownloadCloud />} - action={ - <> - <Button onClick={signIn} endIcon={<ChevronRightIcon />} sx={{ padding: '2px', minWidth: '130px' }}> - <Typography noWrap fontWeight={700}> - Enable now - </Typography> - </Button> - <IconButton - aria-label="close" - color="inherit" - size="small" - onClick={() => { - setIsDismissed(true) - }} - > - <CloseIcon fontSize="inherit" sx={{ color: 'border.main' }} /> - </IconButton> - </> - } - > - <Box display="flex" alignContent="center" alignItems="center" width="100%"> - <Typography flex="1"> - <b>Access your accounts on any device!</b> Enable cloud storage to switch devices effortlessly and keep your - data secure. - </Typography> - </Box> - </Alert> - </div> - ) -} - -export default SignInBanner diff --git a/apps/web/src/features/siweAccounts/components/EnableAccountBanner/style.module.css b/apps/web/src/features/siweAccounts/components/EnableAccountBanner/style.module.css deleted file mode 100644 index 075c499000..0000000000 --- a/apps/web/src/features/siweAccounts/components/EnableAccountBanner/style.module.css +++ /dev/null @@ -1,11 +0,0 @@ -.container :global .MuiAlert-message { - padding: 4px; -} - -.container :global .MuiAlert-action { - padding-top: 2px; -} - -.container :global .MuiAlert-root { - border: 1px solid var(--color-secondary-light); -} diff --git a/apps/web/src/features/spaces/components/AddAccounts/AddManually.tsx b/apps/web/src/features/spaces/components/AddAccounts/AddManually.tsx new file mode 100644 index 0000000000..19d68fa4d3 --- /dev/null +++ b/apps/web/src/features/spaces/components/AddAccounts/AddManually.tsx @@ -0,0 +1,146 @@ +import AddressInput from '@/components/common/AddressInput' +import ChainIndicator from '@/components/common/ChainIndicator' +import ModalDialog from '@/components/common/ModalDialog' +import networkSelectorCss from '@/components/common/NetworkSelector/styles.module.css' +import chains from '@/config/chains' +import css from './styles.module.css' +import useChains from '@/hooks/useChains' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import { Button, DialogActions, DialogContent, MenuItem, Select, Stack, Box } from '@mui/material' +import { getSafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import React, { useCallback, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import { trackEvent } from '@/services/analytics' + +export type AddManuallyFormValues = { + address: string + chainId: string +} + +const AddManually = ({ handleAddSafe }: { handleAddSafe: (data: AddManuallyFormValues) => void }) => { + const [addManuallyOpen, setAddManuallyOpen] = useState(false) + const { configs } = useChains() + + const formMethods = useForm<AddManuallyFormValues>({ + mode: 'onChange', + defaultValues: { + address: '', + chainId: chains.eth, + }, + }) + + const { handleSubmit, watch, register, reset, formState } = formMethods + + const chainId = watch('chainId') + const selectedChain = configs.find((chain) => chain.chainId === chainId) + + const onSubmit = handleSubmit((data) => { + trackEvent({ ...SPACE_EVENTS.ADD_ACCOUNT_MANUALLY }) + handleAddSafe(data) + onClose() + }) + + const onClose = () => { + reset() + setAddManuallyOpen(false) + } + + const validateSafeAddress = async (address: string) => { + try { + await getSafeInfo(chainId, address) + } catch (error) { + return 'Address given is not a valid Safe Account address' + } + } + + const renderMenuItem = useCallback( + (chainId: string, isSelected: boolean) => { + const chain = configs.find((chain) => chain.chainId === chainId) + if (!chain) return null + + return ( + <MenuItem + data-testid="network-item" + key={chainId} + value={chainId} + sx={{ '&:hover': { backgroundColor: isSelected ? 'transparent' : 'inherit' } }} + disableRipple={isSelected} + > + <ChainIndicator chainId={chainId} /> + </MenuItem> + ) + }, + [configs], + ) + + const chainIdField = register('chainId') + + return ( + <> + <Button data-testid="add-manually-button" size="compact" onClick={() => setAddManuallyOpen(true)}> + + Add manually + </Button> + <ModalDialog + open={addManuallyOpen} + dialogTitle="Add safe account" + onClose={onClose} + hideChainIndicator + PaperProps={{ sx: { maxWidth: '760px' } }} + > + <FormProvider {...formMethods}> + <form + onSubmit={(e) => { + e.stopPropagation() + return onSubmit(e) + }} + > + <DialogContent> + <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}> + <AddressInput + data-testid="add-address-input" + label="Safe Account" + chain={selectedChain} + validate={validateSafeAddress} + name="address" + deps={chainId} + /> + <Box data-testid="network-selector" className={css.selectWrapper}> + <Select + {...chainIdField} + value={chainId} + size="small" + className={networkSelectorCss.select} + variant="standard" + sx={{ width: '100%' }} + IconComponent={ExpandMoreIcon} + renderValue={(value) => renderMenuItem(value, true)} + MenuProps={{ + transitionDuration: 0, + slotProps: { paper: { sx: { overflow: 'auto' } } }, + }} + > + {configs.map((chain) => renderMenuItem(chain.chainId, false))} + </Select> + </Box> + </Stack> + </DialogContent> + <DialogActions> + <Button onClick={onClose}>Cancel</Button> + <Button + data-testid="add-space-account-manually-button" + variant="contained" + disabled={!formState.isValid} + type="submit" + > + Add + </Button> + </DialogActions> + </form> + </FormProvider> + </ModalDialog> + </> + ) +} + +export default AddManually diff --git a/apps/web/src/features/spaces/components/AddAccounts/SafesList.tsx b/apps/web/src/features/spaces/components/AddAccounts/SafesList.tsx new file mode 100644 index 0000000000..a03b87da49 --- /dev/null +++ b/apps/web/src/features/spaces/components/AddAccounts/SafesList.tsx @@ -0,0 +1,217 @@ +import ChainIndicator from '@/components/common/ChainIndicator' +import EthHashInfo from '@/components/common/EthHashInfo' +import { ChainIcon } from '@/components/common/SafeIcon' +import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' +import { MultichainIndicator } from '@/features/myAccounts/components/AccountItems/MultiAccountItem' +import type { SafeItem } from '@/features/myAccounts/hooks/useAllSafes' +import { + type AllSafeItems, + flattenSafeItems, + type MultiChainSafeItem, +} from '@/features/myAccounts/hooks/useAllSafesGrouped' +import type { AddAccountsFormValues } from '@/features/spaces/components/AddAccounts/index' +import css from '@/features/spaces/components/AddAccounts/styles.module.css' +import { useChain } from '@/hooks/useChains' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Checkbox, + List, + ListItem, + Stack, + Typography, +} from '@mui/material' +import ListItemButton from '@mui/material/ListItemButton' +import ListItemIcon from '@mui/material/ListItemIcon' +import ListItemText from '@mui/material/ListItemText' +import { Controller, useFormContext } from 'react-hook-form' +import { useSpaceSafes } from '@/features/spaces/hooks/useSpaceSafes' +import isEqual from 'lodash/isEqual' + +const ChainItem = ({ chainId }: { chainId: string }) => { + const chainConfig = useChain(chainId) + + if (!chainConfig) return null + + return ( + <Stack alignItems="center" direction="row" gap={1}> + <ChainIcon chainId={chainId} /> + <Typography + component="span" + sx={{ + color: 'var(--color-primary-light)', + fontSize: 'inherit', + }} + > + {chainConfig.chainName} + </Typography> + </Stack> + ) +} + +export const getSafeId = (safeItem: SafeItem) => { + return `${safeItem.chainId}:${safeItem.address}` +} + +function getMultiChainSafeId(mcSafe: MultiChainSafeItem) { + return `multichain_${mcSafe.address}` +} + +const SafesList = ({ safes }: { safes: AllSafeItems }) => { + const { watch, setValue, control } = useFormContext<AddAccountsFormValues>() + const { allSafes: spaceSafes } = useSpaceSafes() + const flatSafeItems = flattenSafeItems(spaceSafes) + const multiChainSpaceSafes = spaceSafes.filter(isMultiChainSafeItem) + + return ( + <List + sx={{ + px: 2, + pb: 2, + pt: 0, + display: 'flex', + flexDirection: 'column', + gap: 1, + maxHeight: 400, + minHeight: 400, + overflow: 'auto', + }} + > + {safes.map((safe, index) => { + if (isMultiChainSafeItem(safe)) { + const parentSafeId = getMultiChainSafeId(safe) + const subSafeIds = safe.safes.map(getSafeId) + const watchedSubSafeIds = subSafeIds.map((id) => `selectedSafes.${id}`) + + // @ts-ignore TODO: Check why this overload is not supported https://react-hook-form.com/docs/useform/watch + const subSafeValues = watch(watchedSubSafeIds) + + const totalSubSafes = safe.safes.length + const checkedCount = subSafeValues.filter(Boolean).length + const allChecked = checkedCount === totalSubSafes && totalSubSafes > 0 + const someChecked = checkedCount > 0 && checkedCount < totalSubSafes + const alreadyAdded = multiChainSpaceSafes.some((spaceSafe) => isEqual(spaceSafe.safes, safe.safes)) + + const handleHeaderCheckboxChange = (checked: boolean) => { + setValue(`selectedSafes.${parentSafeId}`, checked, { shouldValidate: true }) + + subSafeIds.forEach((id) => { + setValue(`selectedSafes.${id}`, checked, { shouldValidate: true }) + }) + } + + return ( + <Accordion key={index} className={css.accordion} disableGutters sx={{ flexShrink: '0' }}> + <AccordionSummary + expandIcon={<ExpandMoreIcon />} + sx={{ + '& .MuiAccordionSummary-expandIconWrapper': { position: 'absolute', right: '16px' }, + }} + > + <Checkbox + checked={Boolean(allChecked) || alreadyAdded} + indeterminate={someChecked} + onChange={(e) => handleHeaderCheckboxChange(e.target.checked)} + onClick={(e) => e.stopPropagation()} + onFocus={(e) => e.stopPropagation()} + sx={{ mr: 2 }} + disabled={alreadyAdded} + /> + <Box className={css.safeRow}> + <EthHashInfo address={safe.address} copyAddress={false} showPrefix={false} /> + <Box sx={{ justifySelf: 'flex-start' }}> + <MultichainIndicator safes={safe.safes} /> + </Box> + </Box> + </AccordionSummary> + + <AccordionDetails sx={{ p: 0 }}> + <List disablePadding> + {safe.safes.map((subSafe) => { + const subSafeId = getSafeId(subSafe) + const alreadyAdded = flatSafeItems.some((spaceSafe) => { + return spaceSafe.chainId === subSafe.chainId && spaceSafe.address === subSafe.address + }) + + return ( + <Controller + key={`${subSafeId}`} + name={`selectedSafes.${subSafeId}`} + control={control} + render={({ field }) => { + const handleItemClick = () => { + field.onChange(!field.value) + } + + return ( + <ListItem disablePadding> + <ListItemButton onClick={handleItemClick} disabled={alreadyAdded}> + <ListItemIcon onClick={(e) => e.stopPropagation()}> + <Checkbox + checked={Boolean(field.value) || alreadyAdded} + onClick={(e) => e.stopPropagation()} + onFocus={(e) => e.stopPropagation()} + onChange={(e) => field.onChange(e.target.checked)} + /> + </ListItemIcon> + <ListItemText primary={<ChainItem chainId={subSafe.chainId} />} /> + </ListItemButton> + </ListItem> + ) + }} + /> + ) + })} + </List> + </AccordionDetails> + </Accordion> + ) + } + + const safeId = getSafeId(safe) + const alreadyAdded = flatSafeItems.some( + (spaceSafe) => spaceSafe.address === safe.address && spaceSafe.chainId === safe.chainId, + ) + + return ( + <Controller + key={`${safeId}`} + name={`selectedSafes.${safeId}`} + control={control} + render={({ field }) => { + const handleItemClick = () => { + field.onChange(!field.value) + } + + return ( + <ListItem className={css.safeItem} disablePadding> + <ListItemButton onClick={handleItemClick} disabled={alreadyAdded}> + <ListItemIcon onClick={(e) => e.stopPropagation()}> + <Checkbox + checked={Boolean(field.value) || alreadyAdded} + onChange={(event) => field.onChange(event.target.checked)} + /> + </ListItemIcon> + <ListItemText + primary={ + <Box className={css.safeRow}> + <EthHashInfo address={safe.address} chainId={safe.chainId} copyAddress={false} /> + <ChainIndicator chainId={safe.chainId} responsive onlyLogo /> + </Box> + } + /> + </ListItemButton> + </ListItem> + ) + }} + /> + ) + })} + </List> + ) +} + +export default SafesList diff --git a/apps/web/src/features/spaces/components/AddAccounts/index.tsx b/apps/web/src/features/spaces/components/AddAccounts/index.tsx new file mode 100644 index 0000000000..83b97a541d --- /dev/null +++ b/apps/web/src/features/spaces/components/AddAccounts/index.tsx @@ -0,0 +1,266 @@ +import ModalDialog from '@/components/common/ModalDialog' +import type { SafeItem, SafeItems } from '@/features/myAccounts/hooks/useAllSafes' +import { useSafesSearch } from '@/features/myAccounts/hooks/useSafesSearch' +import AddManually, { type AddManuallyFormValues } from '@/features/spaces/components/AddAccounts/AddManually' +import SafesList, { getSafeId } from '@/features/spaces/components/AddAccounts/SafesList' +import { useCurrentSpaceId } from '@/features/spaces/hooks/useCurrentSpaceId' +import SearchIcon from '@/public/images/common/search.svg' +import { useSpaceSafesCreateV1Mutation } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' + +import debounce from 'lodash/debounce' +import css from './styles.module.css' +import { + type AllSafeItems, + flattenSafeItems, + useOwnedSafesGrouped, +} from '@/features/myAccounts/hooks/useAllSafesGrouped' +import { getComparator } from '@/features/myAccounts/utils/utils' +import { useAppDispatch, useAppSelector } from '@/store' +import { selectOrderByPreference } from '@/store/orderByPreferenceSlice' +import { + Alert, + Box, + Button, + Card, + Container, + DialogActions, + DialogContent, + InputAdornment, + SvgIcon, + TextField, + Tooltip, + Typography, +} from '@mui/material' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { trackEvent } from '@/services/analytics' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import Track from '@/components/common/Track' +import { useIsAdmin } from '@/features/spaces/hooks/useSpaceMembers' +import { useSpaceSafes } from '@/features/spaces/hooks/useSpaceSafes' +import { showNotification } from '@/store/notificationsSlice' + +export type AddAccountsFormValues = { + selectedSafes: Record<string, boolean> +} + +function getSelectedSafes(safes: AddAccountsFormValues['selectedSafes'], spaceSafes: AllSafeItems) { + const flatSafeItems = flattenSafeItems(spaceSafes) + + return Object.entries(safes).filter( + ([key, isSelected]) => + isSelected && + !key.startsWith('multichain_') && + !flatSafeItems.some((spaceSafe) => { + const [chainId, address] = key.split(':') + return spaceSafe.address === address && spaceSafe.chainId === chainId + }), + ) +} + +const SAFE_ACCOUNTS_LIMIT = 10 + +const AddAccounts = () => { + const isAdmin = useIsAdmin() + const [open, setOpen] = useState<boolean>(false) + const [searchQuery, setSearchQuery] = useState('') + const [error, setError] = useState<string>() + const [manualSafes, setManualSafes] = useState<SafeItems>([]) + + const { orderBy } = useAppSelector(selectOrderByPreference) + const dispatch = useAppDispatch() + const { allSafes: spaceSafes } = useSpaceSafes() + const safes = useOwnedSafesGrouped() + const sortComparator = getComparator(orderBy) + const [addSafesToSpace] = useSpaceSafesCreateV1Mutation() + const spaceId = useCurrentSpaceId() + + const allSafes = useMemo<AllSafeItems>( + () => [...manualSafes, ...(safes.allMultiChainSafes ?? []), ...(safes.allSingleSafes ?? [])].sort(sortComparator), + [manualSafes, safes.allMultiChainSafes, safes.allSingleSafes, sortComparator], + ) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleSearch = useCallback(debounce(setSearchQuery, 300), []) + const filteredSafes = useSafesSearch(allSafes ?? [], searchQuery) + + const formMethods = useForm<AddAccountsFormValues>({ + mode: 'onChange', + defaultValues: { + selectedSafes: {}, + }, + }) + + const { handleSubmit, watch, setValue } = formMethods + + const selectedSafes = watch(`selectedSafes`) + const selectedSafesLength = getSelectedSafes(selectedSafes, spaceSafes).length + + const onSubmit = handleSubmit(async (data) => { + trackEvent({ ...SPACE_EVENTS.ADD_ACCOUNTS }) + const safesToAdd = getSelectedSafes(data.selectedSafes, spaceSafes).map(([key]) => { + const [chainId, address] = key.split(':') + return { chainId, address } + }) + + try { + const result = await addSafesToSpace({ + spaceId: Number(spaceId), + createSpaceSafesDto: { safes: safesToAdd }, + }) + + if (result.error) { + // @ts-ignore + setError(result.error?.data?.message || 'Something went wrong adding one or more Safe Accounts.') + return + } + + dispatch( + showNotification({ + message: `Added safe account(s) to space`, + variant: 'success', + groupKey: 'add-safe-account-success', + }), + ) + + handleClose() + } catch (e) { + console.log(e) + } + }) + + const handleAddSafe = (data: AddManuallyFormValues) => { + const alreadyExists = allSafes.some((safe) => safe.address === data.address) + + const newSafeItem: SafeItem = { + ...data, + isReadOnly: false, + isPinned: false, + lastVisited: 0, + name: '', + } + + if (!alreadyExists) { + setManualSafes((prev) => [newSafeItem, ...prev]) + } + + const safeId = getSafeId(newSafeItem) + setValue(`selectedSafes.${safeId}`, true, { shouldValidate: true }) + } + + const handleClose = () => { + setError(undefined) + setSearchQuery('') + setValue('selectedSafes', {}) // Reset doesn't seem to work consistently with an object + setOpen(false) + } + + useEffect(() => { + if (searchQuery) { + trackEvent({ ...SPACE_EVENTS.SEARCH_ACCOUNTS, label: SPACE_LABELS.add_accounts_modal }) + } + }, [searchQuery]) + + return ( + <> + <Tooltip title={!isAdmin ? 'You need to be an Admin to add accounts' : ''} placement="top"> + <Box component="span"> + <Button + data-testid="add-space-account-button" + variant="contained" + onClick={() => setOpen(true)} + disabled={!isAdmin} + sx={{ whiteSpace: 'nowrap' }} + > + Add accounts + </Button> + </Box> + </Tooltip> + + <ModalDialog + open={open} + fullScreen + hideChainIndicator + PaperProps={{ sx: { backgroundColor: 'border.background' } }} + > + <DialogContent sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap' }}> + <Container fixed maxWidth="sm" disableGutters> + <Typography component="div" variant="h1" mb={1}> + Add Safe Accounts + </Typography> + <Typography mb={2}> + You can add any Safe Account to your Space. This is currently limited to {SAFE_ACCOUNTS_LIMIT} Safe + Accounts. + </Typography> + <Card> + <FormProvider {...formMethods}> + <form onSubmit={onSubmit}> + <Box m={2}> + <TextField + id="search-by-name" + placeholder="Search" + aria-label="Search Safe list by name" + variant="filled" + hiddenLabel + onChange={(e) => { + handleSearch(e.target.value) + }} + className={css.search} + InputProps={{ + startAdornment: ( + <InputAdornment position="start"> + <SvgIcon + component={SearchIcon} + inheritViewBox + fontWeight="bold" + fontSize="small" + sx={{ + color: 'var(--color-border-main)', + '.MuiInputBase-root.Mui-focused &': { color: 'var(--color-text-primary)' }, + }} + /> + </InputAdornment> + ), + disableUnderline: true, + }} + fullWidth + size="small" + /> + </Box> + + {searchQuery ? <SafesList safes={filteredSafes} /> : <SafesList safes={allSafes} />} + + <Box p={2}> + <Track {...SPACE_EVENTS.ADD_ACCOUNT_MANUALLY_MODAL}> + <AddManually handleAddSafe={handleAddSafe} /> + </Track> + </Box> + + {error && ( + <Alert severity="error" sx={{ m: 2, mt: 0 }}> + {error} + </Alert> + )} + + <DialogActions> + <Button onClick={handleClose}>Cancel</Button> + <Button + data-testid="add-accounts-button" + variant="contained" + disabled={selectedSafesLength === 0} + type="submit" + > + Add Accounts ({selectedSafesLength}) + </Button> + </DialogActions> + </form> + </FormProvider> + </Card> + </Container> + </DialogContent> + </ModalDialog> + </> + ) +} + +export default AddAccounts diff --git a/apps/web/src/features/spaces/components/AddAccounts/styles.module.css b/apps/web/src/features/spaces/components/AddAccounts/styles.module.css new file mode 100644 index 0000000000..63d12a357c --- /dev/null +++ b/apps/web/src/features/spaces/components/AddAccounts/styles.module.css @@ -0,0 +1,31 @@ +.safeRow { + display: grid; + grid-template-columns: 8fr 3fr; + align-items: center; + width: 100%; +} + +.safeItem { + border: 1px solid var(--color-border-light); + border-radius: 6px; +} + +.search :global .MuiInputBase-root { + border: 1px solid transparent !important; +} + +.selectWrapper { + display: flex; + align-items: center; + border-radius: 8px; + border: 1px solid var(--color-border-light); + height: 66px; +} + +.accordion { + border: 1px solid var(--color-border-light) !important; +} + +.accordion:hover > h3 > button { + background-color: rgba(0, 0, 0, 0.04); +} diff --git a/apps/web/src/features/spaces/components/AddMemberModal/MemberInfoForm.tsx b/apps/web/src/features/spaces/components/AddMemberModal/MemberInfoForm.tsx new file mode 100644 index 0000000000..3449089ce0 --- /dev/null +++ b/apps/web/src/features/spaces/components/AddMemberModal/MemberInfoForm.tsx @@ -0,0 +1,41 @@ +import NameInput from '@/components/common/NameInput' +import { Controller, useFormContext } from 'react-hook-form' +import { MenuItem, Select, Stack } from '@mui/material' +import { RoleMenuItem } from '@/features/spaces/components/AddMemberModal/index' +import { MemberRole } from '@/features/spaces/hooks/useSpaceMembers' +import css from './styles.module.css' + +const MemberInfoForm = ({ isEdit = false }: { isEdit?: boolean }) => { + const { control } = useFormContext() + + return ( + <Stack direction="row" spacing={2} alignItems="center"> + <NameInput data-testid="member-name-input" name="name" label="Name" required disabled={isEdit} /> + + <Controller + control={control} + name="role" + defaultValue={MemberRole.MEMBER} + render={({ field: { value, onChange, ...field } }) => ( + <Select + {...field} + value={value} + onChange={onChange} + required + sx={{ minWidth: '150px', py: 0.5 }} + renderValue={(role) => <RoleMenuItem role={role as MemberRole} />} + > + <MenuItem value={MemberRole.ADMIN} className={css.menuItem}> + <RoleMenuItem role={MemberRole.ADMIN} hasDescription selected={value === MemberRole.ADMIN} /> + </MenuItem> + <MenuItem value={MemberRole.MEMBER} className={css.menuItem}> + <RoleMenuItem role={MemberRole.MEMBER} hasDescription selected={value === MemberRole.MEMBER} /> + </MenuItem> + </Select> + )} + /> + </Stack> + ) +} + +export default MemberInfoForm diff --git a/apps/web/src/features/spaces/components/AddMemberModal/index.tsx b/apps/web/src/features/spaces/components/AddMemberModal/index.tsx new file mode 100644 index 0000000000..5a28bec266 --- /dev/null +++ b/apps/web/src/features/spaces/components/AddMemberModal/index.tsx @@ -0,0 +1,183 @@ +import { type ReactElement, useState } from 'react' +import { + Alert, + Box, + Button, + CircularProgress, + DialogActions, + DialogContent, + Stack, + SvgIcon, + Typography, +} from '@mui/material' +import { FormProvider, useForm } from 'react-hook-form' +import ModalDialog from '@/components/common/ModalDialog' +import memberIcon from '@/public/images/spaces/member.svg' +import adminIcon from '@/public/images/spaces/admin.svg' +import AddressInput from '@/components/common/AddressInput' +import CheckIcon from '@mui/icons-material/Check' +import css from './styles.module.css' +import { useMembersInviteUserV1Mutation } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useCurrentSpaceId } from 'src/features/spaces/hooks/useCurrentSpaceId' +import { useRouter } from 'next/router' +import { AppRoutes } from '@/config/routes' +import { MemberRole } from '@/features/spaces/hooks/useSpaceMembers' +import { trackEvent } from '@/services/analytics' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import { useAppDispatch } from '@/store' +import { showNotification } from '@/store/notificationsSlice' +import MemberInfoForm from '@/features/spaces/components/AddMemberModal/MemberInfoForm' + +type MemberField = { + name: string + address: string + role: MemberRole +} + +export const RoleMenuItem = ({ + role, + hasDescription = false, + selected = false, +}: { + role: MemberRole + hasDescription?: boolean + selected?: boolean +}): ReactElement => { + const isAdmin = role === MemberRole.ADMIN + + return ( + <Box width="100%" alignItems="center" className={css.roleMenuItem}> + <Box sx={{ gridArea: 'icon', display: 'flex', alignItems: 'center' }}> + <SvgIcon mr={1} component={isAdmin ? adminIcon : memberIcon} inheritViewBox fontSize="small" /> + </Box> + <Typography gridArea="title" fontWeight={hasDescription ? 'bold' : undefined}> + {isAdmin ? 'Admin' : 'Member'} + </Typography> + {hasDescription && ( + <> + <Box gridArea="description"> + <Typography variant="body2" sx={{ maxWidth: '300px', whiteSpace: 'normal', wordWrap: 'break-word' }}> + {isAdmin ? 'Admins can create and delete spaces, invite members, and more.' : 'Can view the space data.'} + </Typography> + </Box> + <Box gridArea="checkIcon" sx={{ visibility: selected ? 'visible' : 'hidden', mx: 1 }}> + <CheckIcon fontSize="small" sx={{ color: 'text.primary' }} /> + </Box> + </> + )} + </Box> + ) +} + +const AddMemberModal = ({ onClose }: { onClose: () => void }): ReactElement => { + const spaceId = useCurrentSpaceId() + const router = useRouter() + const dispatch = useAppDispatch() + const [error, setError] = useState<string>() + const [isSubmitting, setIsSubmitting] = useState(false) + const [inviteMembers] = useMembersInviteUserV1Mutation() + + const methods = useForm<MemberField>({ + mode: 'onChange', + defaultValues: { + name: '', + address: '', + role: MemberRole.MEMBER, + }, + }) + + const { handleSubmit, formState } = methods + + const onSubmit = handleSubmit(async (data) => { + setError(undefined) + + if (!spaceId) { + setError('Something went wrong. Please try again.') + return + } + + try { + setIsSubmitting(true) + trackEvent({ ...SPACE_EVENTS.ADD_MEMBER }) + const response = await inviteMembers({ + spaceId: Number(spaceId), + inviteUsersDto: { users: [{ address: data.address, role: data.role, name: data.name }] }, + }) + + if (response.data) { + if (router.pathname !== AppRoutes.spaces.members) { + router.push({ pathname: AppRoutes.spaces.members, query: { spaceId } }) + } + + dispatch( + showNotification({ + message: `Invited ${data.name} to space`, + variant: 'success', + groupKey: 'invite-member-success', + }), + ) + + onClose() + } + if (response.error) { + // @ts-ignore + const errorMessage = response.error?.data?.message || 'Invite failed. Please try again.' + setError(errorMessage) + } + } catch (e) { + console.error(e) + setError('Something went wrong. Please try again.') + } finally { + setIsSubmitting(false) + } + }) + + return ( + <ModalDialog open onClose={onClose} dialogTitle="Add member" hideChainIndicator> + <FormProvider {...methods}> + <form onSubmit={onSubmit}> + <DialogContent sx={{ py: 2 }}> + <Typography mb={2}> + Invite a signer of the Safe Accounts, or any other wallet address. Anyone in the space can see their name. + </Typography> + + <Stack spacing={3}> + <MemberInfoForm /> + + <AddressInput + data-testid="member-address-input" + name="address" + label="Address" + required + showPrefix={false} + /> + </Stack> + + {error && ( + <Alert severity="error" sx={{ mt: 2 }}> + {error} + </Alert> + )} + </DialogContent> + + <DialogActions> + <Button data-testid="cancel-btn" onClick={onClose}> + Cancel + </Button> + <Button + data-testid="add-member-modal-button" + type="submit" + variant="contained" + disabled={!formState.isValid || isSubmitting} + disableElevation + > + {isSubmitting ? <CircularProgress size={20} /> : 'Add member'} + </Button> + </DialogActions> + </form> + </FormProvider> + </ModalDialog> + ) +} + +export default AddMemberModal diff --git a/apps/web/src/features/spaces/components/AddMemberModal/styles.module.css b/apps/web/src/features/spaces/components/AddMemberModal/styles.module.css new file mode 100644 index 0000000000..41379f5b28 --- /dev/null +++ b/apps/web/src/features/spaces/components/AddMemberModal/styles.module.css @@ -0,0 +1,16 @@ +.roleMenuItem { + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-areas: + 'icon title checkIcon' + '. description checkIcon'; + column-gap: var(--space-1); +} + +.menuItem:hover { + background-color: var(--color-background-main) !important; +} + +.menuItem:global(.Mui-selected) { + background-color: var(--color-background-main); +} diff --git a/apps/web/src/features/spaces/components/AuthState/index.tsx b/apps/web/src/features/spaces/components/AuthState/index.tsx new file mode 100644 index 0000000000..066d533bf3 --- /dev/null +++ b/apps/web/src/features/spaces/components/AuthState/index.tsx @@ -0,0 +1,42 @@ +import { type ReactNode, useEffect } from 'react' +import SignedOutState from '@/features/spaces/components/SignedOutState' +import { isUnauthorized } from '@/features/spaces/utils' +import UnauthorizedState from '@/features/spaces/components/UnauthorizedState' +import LoadingState from '@/features/spaces/components/LoadingState' +import { useAppDispatch, useAppSelector } from '@/store' +import { isAuthenticated, setLastUsedSpace } from '@/store/authSlice' +import { useSpacesGetOneV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useUsersGetWithWalletsV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/users' +import { MemberStatus } from '@/features/spaces/hooks/useSpaceMembers' +import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' +import useFeatureFlagRedirect from '@/features/spaces/hooks/useFeatureFlagRedirect' + +const AuthState = ({ spaceId, children }: { spaceId: string; children: ReactNode }) => { + const dispatch = useAppDispatch() + const isUserSignedIn = useAppSelector(isAuthenticated) + const { currentData: currentUser } = useUsersGetWithWalletsV1Query(undefined, { skip: !isUserSignedIn }) + const { currentData, error, isLoading } = useSpacesGetOneV1Query({ id: Number(spaceId) }, { skip: !isUserSignedIn }) + const isSpacesFeatureEnabled = useHasFeature(FEATURES.SPACES) + useFeatureFlagRedirect() + + const isCurrentUserDeclined = currentData?.members.some( + (member) => member.user.id === currentUser?.id && member.status === MemberStatus.DECLINED, + ) + + useEffect(() => { + dispatch(setLastUsedSpace(spaceId)) + }, [dispatch, spaceId]) + + if (!isSpacesFeatureEnabled) return null + + if (isLoading) return <LoadingState /> + + if (!isUserSignedIn) return <SignedOutState /> + + if (isUnauthorized(error) || isCurrentUserDeclined) return <UnauthorizedState /> + + return children +} + +export default AuthState diff --git a/apps/web/src/features/spaces/components/Dashboard/AddAccountsCard.tsx b/apps/web/src/features/spaces/components/Dashboard/AddAccountsCard.tsx new file mode 100644 index 0000000000..d34050c820 --- /dev/null +++ b/apps/web/src/features/spaces/components/Dashboard/AddAccountsCard.tsx @@ -0,0 +1,47 @@ +import AddAccounts from '@/features/spaces/components/AddAccounts' +import Image from 'next/image' +import { Typography, Paper, Box, Stack } from '@mui/material' +import EmptyDashboard from '@/public/images/spaces/empty_dashboard.png' +import EmptyDashboardDark from '@/public/images/spaces/empty_dashboard_dark.png' + +import css from './styles.module.css' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import Track from '@/components/common/Track' +import { useDarkMode } from '@/hooks/useDarkMode' + +const AddAccountsCard = () => { + const isDarkMode = useDarkMode() + + return ( + <Paper sx={{ p: 3, display: 'flex', gap: 3 }}> + <Stack direction={{ xs: 'column-reverse', md: 'row' }} alignItems="center" spacing={3}> + <Box sx={{ flex: 2 }}> + <Typography variant="h4" fontWeight={700} mb={2}> + Add your Safe Accounts + </Typography> + + <Typography variant="body1" color="text.secondary" mb={2}> + Start by adding Safe Accounts to your space. Any accounts that are linked to your connected wallet can be + added to the space. + </Typography> + + <Track {...SPACE_EVENTS.ADD_ACCOUNTS_MODAL} label={SPACE_LABELS.space_dashboard_card}> + <AddAccounts /> + </Track> + </Box> + + <Box> + <Image + className={css.image} + src={isDarkMode ? EmptyDashboardDark : EmptyDashboard} + alt="Illustration of two safes with their thresholds" + width={375} + height={200} + /> + </Box> + </Stack> + </Paper> + ) +} + +export default AddAccountsCard diff --git a/apps/web/src/features/spaces/components/Dashboard/AggregatedBalances.tsx b/apps/web/src/features/spaces/components/Dashboard/AggregatedBalances.tsx new file mode 100644 index 0000000000..e9947dd038 --- /dev/null +++ b/apps/web/src/features/spaces/components/Dashboard/AggregatedBalances.tsx @@ -0,0 +1,140 @@ +import { useChain } from '@/hooks/useChains' +import { Card, Grid2, Skeleton, Stack, Typography } from '@mui/material' +import css from '@/features/spaces/components/Dashboard/styles.module.css' +import FiatValue from '@/components/common/FiatValue' +import { useAppSelector } from '@/store' +import { selectCurrency } from '@/store/settingsSlice' +import { useGetMultipleSafeOverviewsQuery } from '@/store/api/gateway' +import type { SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeItem } from '@/features/myAccounts/hooks/useAllSafes' +import ChainIndicator from '@/components/common/ChainIndicator' + +type FiatTotalByChain = { + chainId: string + total: number + percentage: number +} + +function aggregateFiatTotalsByChainId(items: SafeOverview[]): FiatTotalByChain[] { + const totals: Record<string, number> = {} + + for (const item of items) { + const fiatValue = Number(item.fiatTotal) || 0 + totals[item.chainId] = (totals[item.chainId] || 0) + fiatValue + } + + const grandTotal = Object.values(totals).reduce((sum, val) => sum + val, 0) + + const result = Object.entries(totals).map(([chainId, total]) => { + const percentage = grandTotal ? (total / grandTotal) * 100 : 0 + + return { + chainId, + total, + percentage: parseFloat(percentage.toFixed(2)), + } + }) + + result.sort((a, b) => b.total - a.total) + + return result +} + +function getTopFiatTotals(chainTotals: FiatTotalByChain[]): FiatTotalByChain[] { + const MAX_NETWORKS = 5 + if (chainTotals.length <= MAX_NETWORKS) { + return chainTotals + } + + const topTotals = chainTotals.slice(0, MAX_NETWORKS - 1) + const rest = chainTotals.slice(MAX_NETWORKS - 1) + + const otherTotal = rest.reduce((sum, item) => sum + item.total, 0) + const percentage = rest.reduce((sum, item) => sum + item.percentage, 0) + + const otherItem: FiatTotalByChain = { + chainId: 'Other', + total: otherTotal, + percentage, + } + + return [...topTotals, otherItem] +} + +const AggregatedBalanceByChain = ({ fiatTotalByChain }: { fiatTotalByChain: FiatTotalByChain }) => { + const chain = useChain(fiatTotalByChain.chainId) + + return ( + <Stack> + <Typography component="div" variant="body2" color="primary.light" mb={0.5} height="24px"> + {fiatTotalByChain.chainId === 'Other' ? 'Other' : <ChainIndicator chainId={fiatTotalByChain.chainId} />} + </Typography> + + <Typography variant="h3" fontWeight="700" mb={0.5}> + <FiatValue value={fiatTotalByChain.total.toString()} maxLength={20} precise /> + </Typography> + + <div className={css.chainIndicator}> + <Typography + component="span" + className={css.chainIndicatorColor} + bgcolor={chain?.theme.backgroundColor || '#dddee0'} + width={`${fiatTotalByChain.percentage}%`} + /> + </div> + </Stack> + ) +} + +const AggregatedBalance = ({ safeItems }: { safeItems: SafeItem[] }) => { + const currency = useAppSelector(selectCurrency) + + const { data: safeOverviews, isLoading } = useGetMultipleSafeOverviewsQuery({ safes: safeItems, currency }) + const aggregatedBalance = safeOverviews ? safeOverviews.reduce((prev, next) => prev + Number(next.fiatTotal), 0) : 0 + const fiatTotalByChainId = safeOverviews ? aggregateFiatTotalsByChainId(safeOverviews) : [] + const topTotals = getTopFiatTotals(fiatTotalByChainId) + + if (isLoading) return <AggregatedBalanceSkeleton /> + + return ( + <Card sx={{ p: 2, mb: 2 }}> + <Typography variant="body2" fontWeight="bold" mb={1} color="primary.light"> + Aggregated balance + </Typography> + <Typography + component="div" + variant="h1" + sx={{ + fontSize: 44, + lineHeight: '40px', + }} + > + <FiatValue value={aggregatedBalance.toString()} maxLength={20} precise /> + </Typography> + + {topTotals && ( + <Grid2 container mt={3} spacing={2}> + {topTotals.map((fiatTotal) => { + return ( + <Grid2 key={fiatTotal.chainId} size={{ xs: 12, md: 'grow' }} maxWidth={{ xs: 1, md: '20%' }}> + <AggregatedBalanceByChain fiatTotalByChain={fiatTotal} /> + </Grid2> + ) + })} + </Grid2> + )} + </Card> + ) +} + +const AggregatedBalanceSkeleton = () => { + return ( + <Card sx={{ p: 2, mb: 2 }}> + <Skeleton variant="rounded" width={100} height={20} sx={{ mb: 1 }} /> + <Skeleton variant="rounded" width={160} height={40} sx={{ mb: 3 }} /> + <Skeleton variant="rounded" width={200} height={58} /> + </Card> + ) +} + +export default AggregatedBalance diff --git a/apps/web/src/features/spaces/components/Dashboard/DashboardMembersList.tsx b/apps/web/src/features/spaces/components/Dashboard/DashboardMembersList.tsx new file mode 100644 index 0000000000..3549e4dbc7 --- /dev/null +++ b/apps/web/src/features/spaces/components/Dashboard/DashboardMembersList.tsx @@ -0,0 +1,38 @@ +import { Button, Box, Stack } from '@mui/material' +import type { Member } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import PlusIcon from '@/public/images/common/plus.svg' +import { useState } from 'react' +import AddMemberModal from '../AddMemberModal' +import MemberName from '../MembersList/MemberName' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import Track from '@/components/common/Track' +import { useIsAdmin } from '@/features/spaces/hooks/useSpaceMembers' + +const DashboardMembersList = ({ members }: { members: Member[] }) => { + const [openAddMembersModal, setOpenAddMembersModal] = useState(false) + const isAdmin = useIsAdmin() + + return ( + <> + <Stack spacing={2}> + {members.map((member) => ( + <Box key={member.id}> + <MemberName key={member.id} member={member} /> + </Box> + ))} + </Stack> + {isAdmin && ( + <Box display="flex" justifyContent="center" mt={2}> + <Track {...SPACE_EVENTS.ADD_MEMBER_MODAL} label={SPACE_LABELS.space_dashboard}> + <Button size="small" variant="text" startIcon={<PlusIcon />} onClick={() => setOpenAddMembersModal(true)}> + Add member + </Button> + </Track> + </Box> + )} + {openAddMembersModal && <AddMemberModal onClose={() => setOpenAddMembersModal(false)} />} + </> + ) +} + +export default DashboardMembersList diff --git a/apps/web/src/features/spaces/components/Dashboard/MembersCard.tsx b/apps/web/src/features/spaces/components/Dashboard/MembersCard.tsx new file mode 100644 index 0000000000..a9ac87af39 --- /dev/null +++ b/apps/web/src/features/spaces/components/Dashboard/MembersCard.tsx @@ -0,0 +1,59 @@ +import css from '@/features/spaces/components/Dashboard/styles.module.css' +import MemberIcon from '@/public/images/spaces/member.svg' +import { Typography, Paper, Box, Button, SvgIcon, Tooltip } from '@mui/material' +import { useState } from 'react' +import { useIsAdmin } from '@/features/spaces/hooks/useSpaceMembers' +import AddMemberModal from '../AddMemberModal' +import { SPACE_LABELS } from '@/services/analytics/events/spaces' +import Track from '@/components/common/Track' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' + +const MembersCard = () => { + const [openAddMembersModal, setOpenAddMembersModal] = useState(false) + const isAdmin = useIsAdmin() + const isButtonDisabled = !isAdmin + + const handleInviteClick = () => { + setOpenAddMembersModal(true) + } + + return ( + <> + <Paper sx={{ p: 3, borderRadius: '12px' }}> + <Box position="relative" width={1}> + <Box className={css.iconBG}> + <SvgIcon component={MemberIcon} inheritViewBox /> + </Box> + <Tooltip title={isButtonDisabled ? 'You need to be an Admin to add members' : ''} placement="top"> + <Box component="span" sx={{ position: 'absolute', top: 0, right: 0 }}> + <Track {...SPACE_EVENTS.ADD_MEMBER_MODAL} label={SPACE_LABELS.space_dashboard_card}> + <Button + data-testid="add-member-button" + onClick={handleInviteClick} + variant={isButtonDisabled ? 'contained' : 'outlined'} + size="compact" + aria-label="Invite team members" + disabled={isButtonDisabled} + > + Add members + </Button> + </Track> + </Box> + </Tooltip> + </Box> + <Box> + <Typography variant="body1" color="text.primary" fontWeight={700} mb={1}> + Add members + </Typography> + <Typography variant="body2" color="text.secondary"> + Invite team members to help manage your Safe Accounts. You can add both Safe Account signers and external + collaborators. + </Typography> + </Box> + </Paper> + {openAddMembersModal && <AddMemberModal onClose={() => setOpenAddMembersModal(false)} />} + </> + ) +} + +export default MembersCard diff --git a/apps/web/src/features/spaces/components/Dashboard/NewFeaturesCard.tsx b/apps/web/src/features/spaces/components/Dashboard/NewFeaturesCard.tsx new file mode 100644 index 0000000000..11256fba51 --- /dev/null +++ b/apps/web/src/features/spaces/components/Dashboard/NewFeaturesCard.tsx @@ -0,0 +1,27 @@ +import WhatsNewIcon from '@/public/images/common/whatsnew.svg' +import { Typography, Paper, Box, SvgIcon, Chip } from '@mui/material' +import css from './styles.module.css' + +const NewFeaturesCard = () => { + return ( + <Paper sx={{ p: 3, borderRadius: '12px' }}> + <Box position="relative" width={1}> + <Box className={css.iconBG}> + <SvgIcon component={WhatsNewIcon} inheritViewBox /> + </Box> + + <Chip label="Coming soon" size="small" sx={{ position: 'absolute', top: 0, right: 0, fontWeight: 'bold' }} /> + </Box> + <Box> + <Typography variant="body1" color="text.primary" fontWeight={700} mb={1}> + New exciting features + </Typography> + <Typography variant="body2" color="text.secondary"> + Address book sharing, viewing transactions across accounts and more is coming soon. Stay tuned for updates! + </Typography> + </Box> + </Paper> + ) +} + +export default NewFeaturesCard diff --git a/apps/web/src/features/spaces/components/Dashboard/SpacesCTACard.tsx b/apps/web/src/features/spaces/components/Dashboard/SpacesCTACard.tsx new file mode 100644 index 0000000000..421d348515 --- /dev/null +++ b/apps/web/src/features/spaces/components/Dashboard/SpacesCTACard.tsx @@ -0,0 +1,53 @@ +import css from '@/features/spaces/components/Dashboard/styles.module.css' +import LightbulbIcon from '@/public/images/common/lightbulb.svg' +import { Typography, Paper, Box, Button, SvgIcon } from '@mui/material' +import SpaceInfoModal from '../SpaceInfoModal' +import { useState } from 'react' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import { trackEvent } from '@/services/analytics' + +const SpacesCTACard = () => { + const [isInfoOpen, setIsInfoOpen] = useState<boolean>(false) + + const handleLearnMore = () => { + trackEvent({ ...SPACE_EVENTS.INFO_MODAL, label: SPACE_LABELS.space_dashboard_card }) + setIsInfoOpen(true) + } + + return ( + <> + <Paper sx={{ p: 3, borderRadius: '12px', height: '100%' }}> + <Box position="relative" width={1}> + <Box className={css.iconBG}> + <SvgIcon component={LightbulbIcon} inheritViewBox /> + </Box> + + <Button + onClick={handleLearnMore} + variant="outlined" + size="compact" + sx={{ + position: 'absolute', + top: 0, + right: 0, + }} + aria-label="Invite team members" + > + Learn more + </Button> + </Box> + <Box> + <Typography variant="body1" color="text.primary" fontWeight={700} mb={1}> + Explore spaces + </Typography> + <Typography variant="body2" color="text.secondary"> + Seamlessly use your Safe Accounts from one place and collaborate with your team members. + </Typography> + </Box> + </Paper> + {isInfoOpen && <SpaceInfoModal showButtons={false} onClose={() => setIsInfoOpen(false)} />} + </> + ) +} + +export default SpacesCTACard diff --git a/apps/web/src/features/spaces/components/Dashboard/index.tsx b/apps/web/src/features/spaces/components/Dashboard/index.tsx new file mode 100644 index 0000000000..727bd6d7ba --- /dev/null +++ b/apps/web/src/features/spaces/components/Dashboard/index.tsx @@ -0,0 +1,126 @@ +import MembersCard from '@/features/spaces/components/Dashboard/MembersCard' +import NewFeaturesCard from '@/features/spaces/components/Dashboard/NewFeaturesCard' +import SpacesCTACard from '@/features/spaces/components/Dashboard/SpacesCTACard' +import { Card, Grid2, Stack, Typography } from '@mui/material' +import Grid from '@mui/material/Grid2' +import { useSpaceSafes } from '@/features/spaces/hooks/useSpaceSafes' +import SafesList from '@/features/myAccounts/components/SafesList' +import AddAccountsCard from './AddAccountsCard' +import { AppRoutes } from '@/config/routes' +import { useCurrentSpaceId } from '@/features/spaces/hooks/useCurrentSpaceId' +import type { LinkProps } from 'next/link' +import NextLink from 'next/link' +import { Link } from '@mui/material' +import ChevronRightIcon from '@mui/icons-material/ChevronRight' +import DashboardMembersList from '@/features/spaces/components/Dashboard/DashboardMembersList' +import { useSpaceMembersByStatus, useIsInvited } from '@/features/spaces/hooks/useSpaceMembers' +import PreviewInvite from '../InviteBanner/PreviewInvite' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import Track from '@/components/common/Track' +import AggregatedBalance from '@/features/spaces/components/Dashboard/AggregatedBalances' +import useTrackSpace from '@/features/spaces/hooks/useTrackSpace' +import { flattenSafeItems } from '@/features/myAccounts/hooks/useAllSafesGrouped' + +const ViewAllLink = ({ url }: { url: LinkProps['href'] }) => { + return ( + <NextLink href={url} passHref legacyBehavior> + <Link + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + textDecoration: 'none', + fontSize: '14px', + color: 'primary.main', + }} + > + View all <ChevronRightIcon fontSize="small" /> + </Link> + </NextLink> + ) +} + +const DASHBOARD_LIST_DISPLAY_LIMIT = 5 + +const SpaceDashboard = () => { + const { allSafes: safes } = useSpaceSafes() + const safeItems = flattenSafeItems(safes) + const spaceId = useCurrentSpaceId() + const { activeMembers } = useSpaceMembersByStatus() + const isInvited = useIsInvited() + useTrackSpace(safes, activeMembers) + + const safesToDisplay = safes.slice(0, DASHBOARD_LIST_DISPLAY_LIMIT) + const membersToDisplay = activeMembers.slice(0, DASHBOARD_LIST_DISPLAY_LIMIT) + + return ( + <> + {isInvited && <PreviewInvite />} + + {safeItems.length > 0 ? ( + <> + <Grid container> + <Grid size={12}> + <AggregatedBalance safeItems={safeItems} /> + </Grid> + </Grid> + + <Grid container spacing={3}> + <Grid size={{ xs: 12, md: 8 }}> + <Card data-testid="dashboard-safe-list" sx={{ p: 2 }}> + <Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}> + <Typography variant="h5">Safe Accounts ({safeItems.length})</Typography> + {spaceId && ( + <Track {...SPACE_EVENTS.VIEW_ALL_ACCOUNTS}> + <ViewAllLink url={{ pathname: AppRoutes.spaces.safeAccounts, query: { spaceId } }} /> + </Track> + )} + </Stack> + <SafesList safes={safesToDisplay} isSpaceSafe /> + </Card> + </Grid> + <Grid size={{ xs: 12, md: 4 }}> + <Card sx={{ p: 2 }}> + <Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}> + <Typography variant="h5">Members ({activeMembers.length})</Typography> + + {spaceId && ( + <Track {...SPACE_EVENTS.VIEW_ALL_MEMBERS}> + <ViewAllLink url={{ pathname: AppRoutes.spaces.members, query: { spaceId } }} /> + </Track> + )} + </Stack> + <DashboardMembersList members={membersToDisplay} /> + </Card> + </Grid> + </Grid> + </> + ) : ( + <> + <Typography variant="h1" fontWeight={700} mb={4}> + Getting started + </Typography> + + <Grid container spacing={3}> + <Grid size={12}> + <AddAccountsCard /> + </Grid> + <Grid size={{ xs: 12, md: 4 }}> + <MembersCard /> + </Grid> + + <Grid2 size={{ xs: 12, md: 4 }}> + <SpacesCTACard /> + </Grid2> + + <Grid2 size={{ xs: 12, md: 4 }}> + <NewFeaturesCard /> + </Grid2> + </Grid> + </> + )} + </> + ) +} + +export default SpaceDashboard diff --git a/apps/web/src/features/spaces/components/Dashboard/styles.module.css b/apps/web/src/features/spaces/components/Dashboard/styles.module.css new file mode 100644 index 0000000000..b5e058655c --- /dev/null +++ b/apps/web/src/features/spaces/components/Dashboard/styles.module.css @@ -0,0 +1,48 @@ +.content { + min-height: calc(100vh - 100px); /* Header + padding height */ + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; +} + +.contentWrapper { + position: relative; + z-index: 1; +} + +.contentInner { + background-color: var(--color-background-paper); + max-width: 500px; + padding: var(--space-5); + border-radius: var(--space-1); +} + +.iconBG { + width: 40px; + height: 40px; + background-color: var(--color-background-main); + border-radius: 50%; + margin-bottom: var(--space-2); + display: flex; + align-items: center; + justify-content: center; +} + +.image { + max-width: 100%; + height: auto; +} + +.chainIndicator { + width: 100%; + height: 4px; + background-color: var(--color-background-main); + border-radius: 5px; +} + +.chainIndicatorColor { + display: block; + height: 100%; + border-radius: 5px; +} diff --git a/apps/web/src/features/spaces/components/InitialsAvatar/index.tsx b/apps/web/src/features/spaces/components/InitialsAvatar/index.tsx new file mode 100644 index 0000000000..4127c4c7ab --- /dev/null +++ b/apps/web/src/features/spaces/components/InitialsAvatar/index.tsx @@ -0,0 +1,52 @@ +import { Box, hslToRgb } from '@mui/material' +import css from 'src/features/spaces/components/InitialsAvatar/styles.module.css' + +/** + * Returns a deterministic "random" color (in Hex format) based on a string. + * The color is constrained so it won't be too dark or too light or too saturated. + */ +export function getDeterministicColor(str: string): string { + const sum = [...str].reduce((acc, char) => acc + char.charCodeAt(0), 0) + + const hue = sum % 360 + const saturation = 40 + (sum % 31) + const lightness = 40 + (sum % 31) + + return hslToRgb(`hsl(${hue}, ${saturation}, ${lightness})`) +} + +const InitialsAvatar = ({ + name, + size = 'large', + rounded = false, +}: { + name: string + size?: 'xsmall' | 'small' | 'medium' | 'large' + rounded?: boolean +}) => { + const logoLetters = name.slice(0, 1) + const logoColor = getDeterministicColor(name) + const dimensions = { + xsmall: { width: 20, height: 20, fontSize: '12px !important' }, + small: { width: 24, height: 24, fontSize: '12px !important' }, + medium: { width: 32, height: 32, fontSize: '16px !important' }, + large: { width: 48, height: 48, fontSize: '20px !important' }, + } + + const { width, height, fontSize } = dimensions[size] + + return ( + <Box + className={css.initialsAvatar} + bgcolor={logoColor} + width={width} + height={height} + fontSize={fontSize} + borderRadius={rounded ? '50%' : '6px'} + > + {logoLetters} + </Box> + ) +} + +export default InitialsAvatar diff --git a/apps/web/src/features/spaces/components/InitialsAvatar/styles.module.css b/apps/web/src/features/spaces/components/InitialsAvatar/styles.module.css new file mode 100644 index 0000000000..f6e699e516 --- /dev/null +++ b/apps/web/src/features/spaces/components/InitialsAvatar/styles.module.css @@ -0,0 +1,10 @@ +.initialsAvatar { + grid-area: logo; + text-transform: uppercase; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + color: white; + justify-self: start; +} diff --git a/apps/web/src/features/spaces/components/InviteBanner/AcceptButton.tsx b/apps/web/src/features/spaces/components/InviteBanner/AcceptButton.tsx new file mode 100644 index 0000000000..e94937d505 --- /dev/null +++ b/apps/web/src/features/spaces/components/InviteBanner/AcceptButton.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react' +import { Button } from '@mui/material' +import type { GetSpaceResponse } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import AcceptInviteDialog from './AcceptInviteDialog' +import css from './styles.module.css' + +type AcceptButtonProps = { + space: GetSpaceResponse +} + +const AcceptButton = ({ space }: AcceptButtonProps) => { + const [inviteOpen, setInviteOpen] = useState(false) + + const handleAcceptInvite = (e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() + setInviteOpen(true) + } + + const handleCloseInviteDialog = () => { + setInviteOpen(false) + } + + return ( + <> + <Button + data-testid="accept-invite-button" + className={css.inviteButton} + variant="contained" + onClick={handleAcceptInvite} + aria-label="Accept invitation" + > + Accept + </Button> + {inviteOpen && <AcceptInviteDialog space={space} onClose={handleCloseInviteDialog} />} + </> + ) +} + +export default AcceptButton diff --git a/apps/web/src/features/spaces/components/InviteBanner/AcceptInviteDialog.tsx b/apps/web/src/features/spaces/components/InviteBanner/AcceptInviteDialog.tsx new file mode 100644 index 0000000000..63a3f6f589 --- /dev/null +++ b/apps/web/src/features/spaces/components/InviteBanner/AcceptInviteDialog.tsx @@ -0,0 +1,105 @@ +import { + type GetSpaceResponse, + useMembersAcceptInviteV1Mutation, +} from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useRouter } from 'next/router' +import { type ReactElement, useState } from 'react' +import { Alert, Box, Button, CircularProgress, DialogActions, DialogContent, Typography } from '@mui/material' +import { FormProvider, useForm } from 'react-hook-form' +import ModalDialog from '@/components/common/ModalDialog' +import NameInput from '@/components/common/NameInput' +import { AppRoutes } from '@/config/routes' +import { useAppDispatch, useAppSelector } from '@/store' +import { isAuthenticated } from '@/store/authSlice' +import { useUsersGetWithWalletsV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/users' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import { trackEvent } from '@/services/analytics' +import { showNotification } from '@/store/notificationsSlice' +import ExternalLink from '@/components/common/ExternalLink' + +function AcceptInviteDialog({ space, onClose }: { space: GetSpaceResponse; onClose: () => void }): ReactElement { + const [error, setError] = useState<string>() + const [isSubmitting, setIsSubmitting] = useState(false) + + const dispatch = useAppDispatch() + const router = useRouter() + const isUserSignedIn = useAppSelector(isAuthenticated) + const { data: currentUser } = useUsersGetWithWalletsV1Query(undefined, { skip: !isUserSignedIn }) + const [acceptInvite] = useMembersAcceptInviteV1Mutation() + const memberName = space.members.find((member) => member.user.id === currentUser?.id)?.name + + const methods = useForm<{ name: string }>({ mode: 'onChange', defaultValues: { name: memberName } }) + const { handleSubmit, formState } = methods + + const onSubmit = handleSubmit(async (data) => { + setError(undefined) + trackEvent({ ...SPACE_EVENTS.ACCEPT_INVITE_SUBMIT }) + + try { + setIsSubmitting(true) + const response = await acceptInvite({ spaceId: space.id, acceptInviteDto: { name: data.name } }) + + if (response.error) { + throw response.error + } + + if (router.pathname === AppRoutes.welcome.spaces) { + router.push({ pathname: AppRoutes.spaces.index, query: { spaceId: space.id } }) + } + + onClose() + + dispatch( + showNotification({ + message: `Accepted invite to ${space.name}`, + variant: 'success', + groupKey: 'accept-invite-success', + }), + ) + } catch (e) { + setError('Failed accepting the invite. Please try again.') + } finally { + setIsSubmitting(false) + } + }) + + return ( + <ModalDialog open onClose={onClose} dialogTitle="Accept invite" hideChainIndicator> + <FormProvider {...methods}> + <form onSubmit={onSubmit}> + <DialogContent sx={{ py: 2 }}> + <Box mb={2}> + <NameInput data-testid="invite-name-input" label="Name" autoFocus name="name" required /> + </Box> + <Typography variant="body2" color="text.secondary"> + How is my data processed? Read our <ExternalLink href={AppRoutes.privacy}>privacy policy</ExternalLink> + </Typography> + + {error && ( + <Alert severity="error" sx={{ mt: 2 }}> + {error} + </Alert> + )} + </DialogContent> + + <DialogActions> + <Button data-testid="cancel-btn" onClick={onClose}> + Cancel + </Button> + <Button + data-testid="confirm-accept-invite-button" + type="submit" + variant="contained" + disabled={!formState.isValid} + disableElevation + > + {isSubmitting ? <CircularProgress size={20} /> : 'Accept invite'} + </Button> + </DialogActions> + </form> + </FormProvider> + </ModalDialog> + ) +} + +export default AcceptInviteDialog diff --git a/apps/web/src/features/spaces/components/InviteBanner/DeclineButton.tsx b/apps/web/src/features/spaces/components/InviteBanner/DeclineButton.tsx new file mode 100644 index 0000000000..9faf262f83 --- /dev/null +++ b/apps/web/src/features/spaces/components/InviteBanner/DeclineButton.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react' +import { Button } from '@mui/material' +import type { GetSpaceResponse } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import DeclineInviteDialog from './DeclineInviteDialog' +import css from './styles.module.css' + +type DeclineButtonProps = { + space: GetSpaceResponse +} + +const DeclineButton = ({ space }: DeclineButtonProps) => { + const [declineOpen, setDeclineOpen] = useState(false) + + const handleDeclineInvite = (e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() + setDeclineOpen(true) + } + + const handleCloseDeclineDialog = () => { + setDeclineOpen(false) + } + + return ( + <> + <Button + className={css.inviteButton} + variant="outlined" + onClick={handleDeclineInvite} + aria-label="Decline invitation" + > + Decline + </Button> + {declineOpen && <DeclineInviteDialog space={space} onClose={handleCloseDeclineDialog} />} + </> + ) +} + +export default DeclineButton diff --git a/apps/web/src/features/spaces/components/InviteBanner/DeclineInviteDialog.tsx b/apps/web/src/features/spaces/components/InviteBanner/DeclineInviteDialog.tsx new file mode 100644 index 0000000000..dc9de0095a --- /dev/null +++ b/apps/web/src/features/spaces/components/InviteBanner/DeclineInviteDialog.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react' +import { Typography } from '@mui/material' +import { DialogContent, DialogActions, Button } from '@mui/material' +import ModalDialog from '@/components/common/ModalDialog' +import ErrorMessage from '@/components/tx/ErrorMessage' +import type { GetSpaceResponse } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useMembersDeclineInviteV1Mutation } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import { trackEvent } from '@/services/analytics' +import { showNotification } from '@/store/notificationsSlice' +import { useAppDispatch } from '@/store' + +type DeclineInviteDialogProps = { + space: GetSpaceResponse + onClose: () => void +} + +const DeclineInviteDialog = ({ space, onClose }: DeclineInviteDialogProps) => { + const [errorMessage, setErrorMessage] = useState<string>('') + const [declineInvite] = useMembersDeclineInviteV1Mutation() + const dispatch = useAppDispatch() + + const handleConfirm = async () => { + setErrorMessage('') + trackEvent({ ...SPACE_EVENTS.DECLINE_INVITE_SUBMIT }) + try { + const { error } = await declineInvite({ spaceId: space.id }) + + if (error) { + throw error + } + + onClose() + + dispatch( + showNotification({ + message: `Declined invite to ${space.name}`, + variant: 'success', + groupKey: 'decline-invite-success', + }), + ) + } catch (e) { + setErrorMessage('An unexpected error occurred while declining the invitation.') + } + } + + return ( + <ModalDialog open onClose={onClose} dialogTitle="Decline invitation" hideChainIndicator> + <DialogContent sx={{ p: '24px !important' }}> + <Typography> + Are you sure you want to decline the invitation to <b>{space.name}</b>? + </Typography> + {errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>} + </DialogContent> + + <DialogActions> + <Button data-testid="cancel-btn" onClick={onClose}> + Cancel + </Button> + <Button data-testid="decline-btn" onClick={handleConfirm} variant="danger" disableElevation> + Decline + </Button> + </DialogActions> + </ModalDialog> + ) +} + +export default DeclineInviteDialog diff --git a/apps/web/src/features/spaces/components/InviteBanner/PreviewInvite.tsx b/apps/web/src/features/spaces/components/InviteBanner/PreviewInvite.tsx new file mode 100644 index 0000000000..9d7d329e22 --- /dev/null +++ b/apps/web/src/features/spaces/components/InviteBanner/PreviewInvite.tsx @@ -0,0 +1,72 @@ +import { Typography, Paper, Box, Stack } from '@mui/material' +import { useSpacesGetOneV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import InitialsAvatar from '../InitialsAvatar' +import css from './styles.module.css' +import { useCurrentSpaceId } from 'src/features/spaces/hooks/useCurrentSpaceId' +import { isAuthenticated } from '@/store/authSlice' +import { useAppSelector } from '@/store' +import AcceptButton from './AcceptButton' +import { SPACE_LABELS } from '@/services/analytics/events/spaces' +import Track from '@/components/common/Track' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import DeclineButton from './DeclineButton' +import EthHashInfo from '@/components/common/EthHashInfo' +import { useUsersGetWithWalletsV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/users' +import { useDarkMode } from '@/hooks/useDarkMode' + +const PreviewInvite = () => { + const isDarkMode = useDarkMode() + const isUserSignedIn = useAppSelector(isAuthenticated) + const spaceId = useCurrentSpaceId() + const { currentData: currentUser } = useUsersGetWithWalletsV1Query() + const { currentData: space } = useSpacesGetOneV1Query({ id: Number(spaceId) }, { skip: !isUserSignedIn || !spaceId }) + const invitedBy = space?.members.find((member) => member.user.id === currentUser?.id)?.invitedBy + + if (!space) return null + + return ( + <Paper sx={{ p: 2, mb: 4, backgroundColor: isDarkMode ? 'info.background' : 'info.light' }}> + <Box className={css.previewInviteContent}> + <InitialsAvatar name={space.name} size="medium" /> + <Typography variant="body1" color="text.primary" flexGrow={1}> + You were invited to join <strong>{space.name}</strong> + {invitedBy && ( + <> + {' '} + by + <Typography + component="span" + variant="body1" + fontWeight={700} + color="primary.main" + position="relative" + top="4px" + ml="6px" + display="inline-block" + sx={{ '> div': { gap: '4px' } }} + > + <EthHashInfo + address={invitedBy} + avatarSize={20} + showName={false} + showPrefix={false} + copyPrefix={false} + /> + </Typography> + </> + )} + </Typography> + <Stack direction="row" spacing={1}> + <Track {...SPACE_EVENTS.ACCEPT_INVITE} label={SPACE_LABELS.preview_banner}> + <AcceptButton space={space} /> + </Track> + <Track {...SPACE_EVENTS.DECLINE_INVITE} label={SPACE_LABELS.preview_banner}> + <DeclineButton space={space} /> + </Track> + </Stack> + </Box> + </Paper> + ) +} + +export default PreviewInvite diff --git a/apps/web/src/features/spaces/components/InviteBanner/index.tsx b/apps/web/src/features/spaces/components/InviteBanner/index.tsx new file mode 100644 index 0000000000..25dcbe219c --- /dev/null +++ b/apps/web/src/features/spaces/components/InviteBanner/index.tsx @@ -0,0 +1,92 @@ +import { Card, Box, Typography, Link as MUILink, Stack } from '@mui/material' +import type { GetSpaceResponse } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { SpaceSummary } from '../SpaceCard' +import { useSpaceSafeCount } from '@/features/spaces/hooks/useSpaceSafeCount' +import InitialsAvatar from '../InitialsAvatar' +import Link from 'next/link' +import { AppRoutes } from '@/config/routes' +import css from './styles.module.css' +import EthHashInfo from '@/components/common/EthHashInfo' +import { useUsersGetWithWalletsV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/users' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import Track from '@/components/common/Track' +import AcceptButton from './AcceptButton' +import DeclineButton from './DeclineButton' +import { trackEvent } from '@/services/analytics' +import { MemberStatus } from '@/features/spaces/hooks/useSpaceMembers' + +type SpaceListInvite = { + space: GetSpaceResponse +} + +const SpaceListInvite = ({ space }: SpaceListInvite) => { + const { id, name, members } = space + const { currentData: currentUser } = useUsersGetWithWalletsV1Query() + const numberOfAccounts = useSpaceSafeCount(id) + const numberOfMembers = members.filter((member) => member.status === MemberStatus.ACTIVE).length + + const invitedBy = space.members.find((member) => member.user.id === currentUser?.id)?.invitedBy + + return ( + <Card sx={{ p: 2, mb: 2 }}> + <Typography variant="h4" fontWeight={700} mb={2} color="primary.light"> + You were invited to join{' '} + <Typography component="span" variant="h4" fontWeight={700} color="primary.main"> + {name} + </Typography> + {invitedBy && ( + <> + {' '} + by + <Typography + component="span" + variant="h4" + fontWeight={700} + color="primary.main" + position="relative" + top="4px" + ml="6px" + display="inline-block" + sx={{ '> div': { gap: '4px' } }} + > + <EthHashInfo address={invitedBy} avatarSize={24} showName={false} showPrefix={false} copyPrefix={false} /> + </Typography> + </> + )} + </Typography> + + <Link href={{ pathname: AppRoutes.spaces.index, query: { spaceId: id } }} passHref legacyBehavior> + <MUILink + underline="none" + sx={{ display: 'block' }} + onClick={() => trackEvent({ ...SPACE_EVENTS.VIEW_INVITING_SPACE })} + > + <Card sx={{ p: 2, backgroundColor: 'background.main', '&:hover': { backgroundColor: 'background.light' } }}> + <Box className={css.spacesListInviteContent}> + <Stack direction="row" spacing={2} alignItems="center" flexGrow={1}> + <Box> + <InitialsAvatar name={name} size="large" /> + </Box> + + <Box> + <SpaceSummary name={name} numberOfAccounts={numberOfAccounts} numberOfMembers={numberOfMembers} /> + </Box> + </Stack> + + <Stack direction="row" spacing={1}> + <Track {...SPACE_EVENTS.ACCEPT_INVITE} label={SPACE_LABELS.space_list_page}> + <AcceptButton space={space} /> + </Track> + <Track {...SPACE_EVENTS.DECLINE_INVITE} label={SPACE_LABELS.space_list_page}> + <DeclineButton space={space} /> + </Track> + </Stack> + </Box> + </Card> + </MUILink> + </Link> + </Card> + ) +} + +export default SpaceListInvite diff --git a/apps/web/src/features/spaces/components/InviteBanner/styles.module.css b/apps/web/src/features/spaces/components/InviteBanner/styles.module.css new file mode 100644 index 0000000000..8bd4524a5a --- /dev/null +++ b/apps/web/src/features/spaces/components/InviteBanner/styles.module.css @@ -0,0 +1,49 @@ +.previewInviteContent { + display: flex; + flex-direction: row; + gap: var(--space-2); +} + +.spacesListInviteContent { + display: flex; + flex-direction: row; + gap: var(--space-2); + align-items: center; +} + +.inviteButtonContainer { + display: flex; + flex-direction: row; + gap: var(--space-1); +} + +.inviteButton { + padding: 4px var(--space-2); + min-height: 32px; +} + +@media (max-width: 600px) { + .previewInviteContent { + flex-direction: column; + gap: var(--space-1); + } + + .spacesListInviteContent { + flex-direction: column; + gap: var(--space-1); + align-items: flex-start; + } + + .inviteButtonContainer { + flex-direction: column; + gap: var(--space-1); + width: 100%; + } + + .inviteButton { + width: 100%; + padding-top: var(--space-1); + padding-bottom: var(--space-1); + min-height: 40px; + } +} diff --git a/apps/web/src/features/spaces/components/LoadingState/index.tsx b/apps/web/src/features/spaces/components/LoadingState/index.tsx new file mode 100644 index 0000000000..d7292f9dab --- /dev/null +++ b/apps/web/src/features/spaces/components/LoadingState/index.tsx @@ -0,0 +1,11 @@ +import { Box, CircularProgress } from '@mui/material' + +const LoadingState = () => { + return ( + <Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh"> + <CircularProgress aria-label="Loading content" /> + </Box> + ) +} + +export default LoadingState diff --git a/apps/web/src/features/spaces/components/Members/index.tsx b/apps/web/src/features/spaces/components/Members/index.tsx new file mode 100644 index 0000000000..c1865bcd9e --- /dev/null +++ b/apps/web/src/features/spaces/components/Members/index.tsx @@ -0,0 +1,90 @@ +import PlusIcon from '@/public/images/common/plus.svg' +import { Button, Stack, Typography } from '@mui/material' +import AddMemberModal from 'src/features/spaces/components/AddMemberModal' +import { useEffect, useState } from 'react' +import MembersList from '@/features/spaces/components/MembersList' +import { useMembersSearch } from '@/features/spaces/hooks/useMembersSearch' +import { useIsInvited, useSpaceMembersByStatus, useIsAdmin } from '@/features/spaces/hooks/useSpaceMembers' +import PreviewInvite from '../InviteBanner/PreviewInvite' +import { SPACE_LABELS } from '@/services/analytics/events/spaces' +import Track from '@/components/common/Track' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import { trackEvent } from '@/services/analytics' +import SearchInput from '../SearchInput' + +const SpaceMembers = () => { + const [openAddMembersModal, setOpenAddMembersModal] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const { activeMembers, invitedMembers } = useSpaceMembersByStatus() + const isAdmin = useIsAdmin() + const isInvited = useIsInvited() + + const filteredMembers = useMembersSearch(activeMembers, searchQuery) + const filteredInvites = useMembersSearch(invitedMembers, searchQuery) + + useEffect(() => { + if (searchQuery) { + trackEvent({ ...SPACE_EVENTS.SEARCH_MEMBERS }) + } + }, [searchQuery]) + + return ( + <> + {isInvited && <PreviewInvite />} + <Typography variant="h1" mb={3}> + Members + </Typography> + <Stack + direction="row" + justifyContent="space-between" + alignItems="flex-start" + mb={3} + flexWrap="nowrap" + gap={2} + flexDirection={{ xs: 'column-reverse', sm: 'row' }} + > + <SearchInput onSearch={setSearchQuery} /> + {isAdmin && ( + <Track {...SPACE_EVENTS.ADD_MEMBER_MODAL} label={SPACE_LABELS.members_page}> + <Button + data-testid="add-member-button" + variant="contained" + startIcon={<PlusIcon />} + onClick={() => setOpenAddMembersModal(true)} + sx={{ whiteSpace: 'nowrap' }} + > + Add member + </Button> + </Track> + )} + </Stack> + <> + {searchQuery && !filteredMembers.length && !filteredInvites.length && ( + <Typography variant="h5" fontWeight="normal" mb={2} color="primary.light"> + Found 0 results + </Typography> + )} + {filteredInvites.length > 0 && ( + <> + <Typography variant="h5" fontWeight={700} mb={2}> + Pending invitations ({filteredInvites.length}) + </Typography> + <MembersList members={filteredInvites} /> + </> + )} + {filteredMembers.length > 0 && ( + <> + <Typography variant="h5" fontWeight={700} mb={2} mt={1}> + All members ({filteredMembers.length}) + </Typography> + <MembersList members={filteredMembers} /> + </> + )} + </> + + {openAddMembersModal && <AddMemberModal onClose={() => setOpenAddMembersModal(false)} />} + </> + ) +} + +export default SpaceMembers diff --git a/apps/web/src/features/spaces/components/MembersList/EditMemberDialog.tsx b/apps/web/src/features/spaces/components/MembersList/EditMemberDialog.tsx new file mode 100644 index 0000000000..a96fe8855b --- /dev/null +++ b/apps/web/src/features/spaces/components/MembersList/EditMemberDialog.tsx @@ -0,0 +1,101 @@ +import ModalDialog from '@/components/common/ModalDialog' +import { DialogContent, DialogActions, Button, Typography } from '@mui/material' +import { type Member, useMembersUpdateRoleV1Mutation } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useCurrentSpaceId } from '@/features/spaces/hooks/useCurrentSpaceId' +import ErrorMessage from '@/components/tx/ErrorMessage' +import { useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { showNotification } from '@/store/notificationsSlice' +import { useAppDispatch } from '@/store' +import MemberInfoForm from '@/features/spaces/components/AddMemberModal/MemberInfoForm' + +type MemberField = { + name: string + role: Member['role'] +} + +const EditMemberDialog = ({ member, handleClose }: { member: Member; handleClose: () => void }) => { + const spaceId = useCurrentSpaceId() + const dispatch = useAppDispatch() + const [editMember] = useMembersUpdateRoleV1Mutation() + const [error, setError] = useState<string>() + + const methods = useForm<MemberField>({ + mode: 'onChange', + defaultValues: { + name: member.name, + role: member.role, + }, + }) + + const { handleSubmit, formState } = methods + + const onSubmit = handleSubmit(async (data) => { + setError(undefined) + + if (!spaceId) { + setError('Something went wrong. Please try again.') + return + } + + try { + const { error } = await editMember({ + spaceId: Number(spaceId), + userId: member.user.id, + updateRoleDto: { + role: data.role, + }, + }) + + if (error) { + throw error + } + + dispatch( + showNotification({ + message: `Updated role of ${data.name} to ${data.role}`, + variant: 'success', + groupKey: 'update-member-success', + }), + ) + + handleClose() + } catch (e) { + setError('An unexpected error occurred while editing the member.') + } + }) + + return ( + <ModalDialog open onClose={handleClose} dialogTitle="Edit member" hideChainIndicator> + <FormProvider {...methods}> + <form onSubmit={onSubmit}> + <DialogContent sx={{ p: '24px !important' }}> + <Typography mb={2}> + Edit the role of <b>{`${member.name}`}</b> in this space. + </Typography> + + <MemberInfoForm isEdit /> + {error && <ErrorMessage>{error}</ErrorMessage>} + </DialogContent> + + <DialogActions> + <Button data-testid="cancel-btn" onClick={handleClose}> + Cancel + </Button> + <Button + type="submit" + data-testid="delete-btn" + variant="danger" + disableElevation + disabled={!formState.isDirty} + > + Update + </Button> + </DialogActions> + </form> + </FormProvider> + </ModalDialog> + ) +} + +export default EditMemberDialog diff --git a/apps/web/src/features/spaces/components/MembersList/MemberName.tsx b/apps/web/src/features/spaces/components/MembersList/MemberName.tsx new file mode 100644 index 0000000000..223a103380 --- /dev/null +++ b/apps/web/src/features/spaces/components/MembersList/MemberName.tsx @@ -0,0 +1,25 @@ +import InitialsAvatar from '../InitialsAvatar' +import { Stack, Typography } from '@mui/material' +import type { Member } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useUsersGetWithWalletsV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/users' + +const MemberName = ({ member }: { member: Member }) => { + const { currentData: user } = useUsersGetWithWalletsV1Query() + const isCurrentUser = member.user.id === user?.id + + return ( + <Stack direction="row" spacing={1} alignItems="center" key={member.id}> + <InitialsAvatar size="medium" name={member.name || ''} rounded /> + <Typography variant="body2"> + {member.name}{' '} + {isCurrentUser && ( + <Typography variant="body2" component="span" color="text.secondary" ml={1}> + You + </Typography> + )} + </Typography> + </Stack> + ) +} + +export default MemberName diff --git a/apps/web/src/features/spaces/components/MembersList/RemoveMemberDialog.tsx b/apps/web/src/features/spaces/components/MembersList/RemoveMemberDialog.tsx new file mode 100644 index 0000000000..ee5295ca9f --- /dev/null +++ b/apps/web/src/features/spaces/components/MembersList/RemoveMemberDialog.tsx @@ -0,0 +1,80 @@ +import ModalDialog from '@/components/common/ModalDialog' +import { DialogContent, DialogActions, Button, Typography } from '@mui/material' +import { useMembersRemoveUserV1Mutation } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useCurrentSpaceId } from '@/features/spaces/hooks/useCurrentSpaceId' +import ErrorMessage from '@/components/tx/ErrorMessage' +import { useState } from 'react' +import { trackEvent } from '@/services/analytics' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import { showNotification } from '@/store/notificationsSlice' +import { useAppDispatch } from '@/store' + +const RemoveMemberDialog = ({ + userId, + memberName, + handleClose, + isInvite = false, +}: { + userId: number + memberName: string + handleClose: () => void + isInvite?: boolean +}) => { + const spaceId = useCurrentSpaceId() + const dispatch = useAppDispatch() + const [deleteMember] = useMembersRemoveUserV1Mutation() + const [errorMessage, setErrorMessage] = useState<string>('') + + const handleConfirm = async () => { + setErrorMessage('') + trackEvent({ ...SPACE_EVENTS.REMOVE_MEMBER, label: isInvite ? SPACE_LABELS.invite_list : SPACE_LABELS.member_list }) + try { + const { error } = await deleteMember({ spaceId: Number(spaceId), userId }) + + if (error) { + throw error + } + + dispatch( + showNotification({ + message: `Removed ${memberName} from space`, + variant: 'success', + groupKey: 'remove-member-success', + }), + ) + + handleClose() + } catch (e) { + setErrorMessage('An unexpected error occurred while removing the member.') + } + } + + return ( + <ModalDialog + open + onClose={handleClose} + dialogTitle={isInvite ? 'Remove invitation' : 'Remove member'} + hideChainIndicator + > + <DialogContent sx={{ p: '24px !important' }}> + <Typography> + {isInvite ? `Are you sure you want to remove the invitation for ` : `Are you sure you want to remove `} + <b>{memberName}</b> + {isInvite ? `` : ` from this space?`} + </Typography> + {errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>} + </DialogContent> + + <DialogActions> + <Button data-testid="cancel-btn" onClick={handleClose}> + Cancel + </Button> + <Button data-testid="delete-btn" onClick={handleConfirm} variant="danger" disableElevation> + Remove + </Button> + </DialogActions> + </ModalDialog> + ) +} + +export default RemoveMemberDialog diff --git a/apps/web/src/features/spaces/components/MembersList/index.tsx b/apps/web/src/features/spaces/components/MembersList/index.tsx new file mode 100644 index 0000000000..e028c0f6b0 --- /dev/null +++ b/apps/web/src/features/spaces/components/MembersList/index.tsx @@ -0,0 +1,151 @@ +import { Box, Chip, IconButton, Stack, SvgIcon, Tooltip } from '@mui/material' +import { type Member } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import EditIcon from '@/public/images/common/edit.svg' +import DeleteIcon from '@/public/images/common/delete.svg' +import EnhancedTable from '@/components/common/EnhancedTable' +import tableCss from '@/components/common/EnhancedTable/styles.module.css' +import MemberName from './MemberName' +import RemoveMemberDialog from './RemoveMemberDialog' +import { useState } from 'react' +import { useIsAdmin } from '@/features/spaces/hooks/useSpaceMembers' +import EditMemberDialog from '@/features/spaces/components/MembersList/EditMemberDialog' +import { isAdmin as checkIsAdmin, isActiveAdmin, MemberStatus } from '@/features/spaces/hooks/useSpaceMembers' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import Track from '@/components/common/Track' +import { useAdminCount } from '@/features/spaces/hooks/useIsLastActiveAdmin' + +const headCells = [ + { + id: 'name', + label: 'Name', + width: '70%', + }, + { + id: 'role', + label: 'Role', + width: '15%', + }, + { + id: 'actions', + label: '', + width: '15%', + sticky: true, + }, +] + +const EditButton = ({ member, disabled }: { member: Member; disabled: boolean }) => { + const [open, setOpen] = useState(false) + + return ( + <> + <Tooltip title={disabled ? 'Cannot edit role of last admin' : 'Edit member'} placement="top"> + <Box component="span"> + <IconButton onClick={() => setOpen(true)} size="small" disabled={disabled}> + <SvgIcon component={EditIcon} inheritViewBox color="border" fontSize="small" /> + </IconButton> + </Box> + </Tooltip> + {open && <EditMemberDialog member={member} handleClose={() => setOpen(false)} />} + </> + ) +} + +export const RemoveMemberButton = ({ + member, + disabled, + isInvite, +}: { + member: Member + disabled: boolean + isInvite: boolean +}) => { + const [openRemoveMemberDialog, setOpenRemoveMemberDialog] = useState(false) + + return ( + <> + <Tooltip + title={disabled ? 'Cannot remove last admin' : `Remove ${isInvite ? 'invitation' : 'member'}`} + placement="top" + > + <Box component="span"> + <Track + {...SPACE_EVENTS.REMOVE_MEMBER_MODAL} + label={isInvite ? SPACE_LABELS.invite_list : SPACE_LABELS.member_list} + > + <IconButton disabled={disabled} onClick={() => setOpenRemoveMemberDialog(true)} size="small"> + <SvgIcon component={DeleteIcon} inheritViewBox color={disabled ? 'disabled' : 'error'} fontSize="small" /> + </IconButton> + </Track> + </Box> + </Tooltip> + {openRemoveMemberDialog && ( + <RemoveMemberDialog + userId={member.user.id} + memberName={member.name} + handleClose={() => setOpenRemoveMemberDialog(false)} + isInvite={isInvite} + /> + )} + </> + ) +} + +const MembersList = ({ members }: { members: Member[] }) => { + const isAdmin = useIsAdmin() + const adminCount = useAdminCount(members) + + const rows = members.map((member) => { + const isLastAdmin = adminCount === 1 && isActiveAdmin(member) + const isInvite = member.status === MemberStatus.INVITED || member.status === MemberStatus.DECLINED + const isDeclined = member.status === MemberStatus.DECLINED + const isDisabled = isAdmin && isLastAdmin && !isInvite + + return { + cells: { + name: { + rawValue: member.name, + content: ( + <Stack direction="row" alignItems="center" justifyContent="left" gap={1}> + <MemberName member={member} /> + {isDeclined && ( + <Chip + label="Declined" + size="small" + sx={{ backgroundColor: 'error.light', color: 'static.main', borderRadius: 0.5 }} + /> + )} + </Stack> + ), + }, + role: { + rawValue: member.role, + content: ( + <Chip + size="small" + label={checkIsAdmin(member) ? 'Admin' : 'Member'} + sx={{ backgroundColor: 'background.lightgrey', borderRadius: 0.5 }} + /> + ), + }, + actions: { + rawValue: '', + sticky: true, + content: isAdmin ? ( + <div className={tableCss.actions}> + {!isInvite && <EditButton member={member} disabled={isDisabled} />} + <RemoveMemberButton member={member} disabled={isDisabled} isInvite={isInvite} /> + </div> + ) : null, + }, + }, + } + }) + + if (!rows.length) { + return null + } + + return <EnhancedTable rows={rows} headCells={headCells} /> +} + +export default MembersList diff --git a/apps/web/src/features/spaces/components/SafeAccounts/EmptySafeAccounts.tsx b/apps/web/src/features/spaces/components/SafeAccounts/EmptySafeAccounts.tsx new file mode 100644 index 0000000000..a223666bdc --- /dev/null +++ b/apps/web/src/features/spaces/components/SafeAccounts/EmptySafeAccounts.tsx @@ -0,0 +1,18 @@ +import { Card, Typography } from '@mui/material' +import SafeAccountsIcon from '@/public/images/spaces/safe-accounts.svg' + +const EmptySafeAccounts = () => { + return ( + <> + <Card sx={{ p: 5, textAlign: 'center' }}> + <SafeAccountsIcon /> + + <Typography color="text.secondary" mb={2}> + Add existing Safe Accounts in your space to see them here. + </Typography> + </Card> + </> + ) +} + +export default EmptySafeAccounts diff --git a/apps/web/src/features/spaces/components/SafeAccounts/RemoveSafeDialog.tsx b/apps/web/src/features/spaces/components/SafeAccounts/RemoveSafeDialog.tsx new file mode 100644 index 0000000000..c20f287002 --- /dev/null +++ b/apps/web/src/features/spaces/components/SafeAccounts/RemoveSafeDialog.tsx @@ -0,0 +1,90 @@ +import ModalDialog from '@/components/common/ModalDialog' +import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' +import type { SafeItem } from '@/features/myAccounts/hooks/useAllSafes' +import type { MultiChainSafeItem } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import { useCurrentSpaceId } from '@/features/spaces/hooks/useCurrentSpaceId' +import { trackEvent } from '@/services/analytics' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import { Alert } from '@mui/material' +import Button from '@mui/material/Button' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import Typography from '@mui/material/Typography' +import { useSpaceSafesDeleteV1Mutation } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useState } from 'react' +import { showNotification } from '@/store/notificationsSlice' +import { useAppDispatch } from '@/store' + +function getToBeDeletedSafeAccounts(safeItem: SafeItem | MultiChainSafeItem) { + if (isMultiChainSafeItem(safeItem)) { + return safeItem.safes.map((safe) => ({ chainId: safe.chainId, address: safe.address })) + } + + return [{ chainId: safeItem.chainId, address: safeItem.address }] +} + +const RemoveSafeDialog = ({ + safeItem, + handleClose, +}: { + safeItem: SafeItem | MultiChainSafeItem + handleClose: () => void +}) => { + const { address } = safeItem + const spaceId = useCurrentSpaceId() + const dispatch = useAppDispatch() + const [removeSafeAccounts] = useSpaceSafesDeleteV1Mutation() + const [error, setError] = useState('') + + const handleConfirm = async () => { + const safeAccounts = getToBeDeletedSafeAccounts(safeItem) + trackEvent({ ...SPACE_EVENTS.DELETE_ACCOUNT }) + + try { + const result = await removeSafeAccounts({ + spaceId: Number(spaceId), + deleteSpaceSafesDto: { safes: safeAccounts }, + }) + + if (result.error) { + throw result.error + } + + dispatch( + showNotification({ + message: `Removed safe account from space`, + variant: 'success', + groupKey: 'remove-safe-account-success', + }), + ) + } catch (e) { + setError('Error removing safe account.') + } + } + + return ( + <ModalDialog open onClose={handleClose} dialogTitle="Remove Safe Account" hideChainIndicator> + <DialogContent sx={{ p: '24px !important' }}> + <Typography> + Are you sure you want to remove <b>{address}</b> from this space? + </Typography> + {error && ( + <Alert severity="error" sx={{ mt: 2 }}> + {error} + </Alert> + )} + </DialogContent> + + <DialogActions> + <Button data-testid="cancel-btn" onClick={handleClose}> + Cancel + </Button> + <Button data-testid="delete-btn" onClick={handleConfirm} variant="danger" disableElevation> + Remove + </Button> + </DialogActions> + </ModalDialog> + ) +} + +export default RemoveSafeDialog diff --git a/apps/web/src/features/spaces/components/SafeAccounts/SendTransactionButton.tsx b/apps/web/src/features/spaces/components/SafeAccounts/SendTransactionButton.tsx new file mode 100644 index 0000000000..940f6eb033 --- /dev/null +++ b/apps/web/src/features/spaces/components/SafeAccounts/SendTransactionButton.tsx @@ -0,0 +1,75 @@ +import { useContext } from 'react' +import { IconButton, Tooltip } from '@mui/material' +import { useRouter } from 'next/router' +import ArrowOutwardIcon from '@/public/images/transactions/outgoing.svg' +import css from './styles.module.css' +import { TxModalContext } from '@/components/tx-flow' +import { TokenTransferFlow } from '@/components/tx-flow/flows' +import { networks } from '@safe-global/protocol-kit/dist/src/utils/eip-3770/config' +import type { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import useWallet from '@/hooks/wallets/useWallet' +import { isOwner } from '@/utils/transaction-guards' +import type { AddressEx } from '@safe-global/safe-gateway-typescript-sdk' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import { trackEvent } from '@/services/analytics' +import { gtmSetSafeAddress } from '@/services/analytics/gtm' + +type Chains = Record<string, string> + +const chains = networks.reduce<Chains>((result, { shortName, chainId }) => { + result[chainId.toString()] = shortName.toString() + return result +}, {}) + +const SendTransactionButton = ({ safe }: { safe: SafeOverview }) => { + const router = useRouter() + const wallet = useWallet() + const canSend = isOwner(safe.owners as AddressEx[], wallet?.address) + + const { setTxFlow } = useContext(TxModalContext) + + const setActiveSafe = async () => { + const shortname = chains[safe.chainId] + + await router.replace({ + pathname: router.pathname, + query: { + ...router.query, + safe: `${shortname}:${safe.address.value}`, + chain: shortname, + }, + }) + } + + const resetActiveSafe = async () => { + await router.replace({ + pathname: router.pathname, + query: { + ...router.query, + safe: undefined, + chain: undefined, + }, + }) + } + + const onNewTxClick = async () => { + await setActiveSafe() + // We have to set it explicitly otherwise its missing in the trackEvent below + gtmSetSafeAddress(safe.address.value) + trackEvent(SPACE_EVENTS.CREATE_SPACE_TX) + + setTxFlow(<TokenTransferFlow />, resetActiveSafe, false) + } + + return ( + <Tooltip placement="top" title={canSend ? 'Send tokens' : 'You are not a signer of this Safe Account'}> + <span> + <IconButton className={css.sendButton} size="medium" onClick={onNewTxClick} disabled={!canSend}> + <ArrowOutwardIcon /> + </IconButton> + </span> + </Tooltip> + ) +} + +export default SendTransactionButton diff --git a/apps/web/src/features/spaces/components/SafeAccounts/SpaceSafeContextMenu.tsx b/apps/web/src/features/spaces/components/SafeAccounts/SpaceSafeContextMenu.tsx new file mode 100644 index 0000000000..cfe8a9f382 --- /dev/null +++ b/apps/web/src/features/spaces/components/SafeAccounts/SpaceSafeContextMenu.tsx @@ -0,0 +1,98 @@ +import type { SafeItem } from '@/features/myAccounts/hooks/useAllSafes' +import type { MultiChainSafeItem } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import RemoveSafeDialog from '@/features/spaces/components/SafeAccounts/RemoveSafeDialog' +import { type MouseEvent, useState } from 'react' +import MoreVertIcon from '@mui/icons-material/MoreVert' +import { SvgIcon } from '@mui/material' +import IconButton from '@mui/material/IconButton' +import ListItemIcon from '@mui/material/ListItemIcon' +import ListItemText from '@mui/material/ListItemText' +import MenuItem from '@mui/material/MenuItem' +import ContextMenu from '@/components/common/ContextMenu' +import DeleteIcon from '@/public/images/common/delete.svg' +import EditIcon from '@/public/images/common/edit.svg' +import EntryDialog from '@/components/address-book/EntryDialog' +import { useAppSelector } from '@/store' +import { selectAllAddressBooks } from '@/store/addressBookSlice' +import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import { trackEvent } from '@/services/analytics' +import { useIsAdmin } from '@/features/spaces/hooks/useSpaceMembers' + +enum ModalType { + RENAME = 'rename', + REMOVE = 'remove', +} + +const defaultOpen = { [ModalType.RENAME]: false, [ModalType.REMOVE]: false } + +const SpaceSafeContextMenu = ({ safeItem }: { safeItem: SafeItem | MultiChainSafeItem }) => { + const [anchorEl, setAnchorEl] = useState<HTMLElement | undefined>() + const [open, setOpen] = useState<typeof defaultOpen>(defaultOpen) + const isAdmin = useIsAdmin() + + const allAddressBooks = useAppSelector(selectAllAddressBooks) + const chainIds = isMultiChainSafeItem(safeItem) ? safeItem.safes.map((safe) => safe.chainId) : [safeItem.chainId] + const name = isMultiChainSafeItem(safeItem) ? safeItem.name : allAddressBooks[safeItem.chainId]?.[safeItem.address] + const hasName = !!name + + const handleOpenContextMenu = (e: MouseEvent<HTMLButtonElement, globalThis.MouseEvent>) => { + e.stopPropagation() + setAnchorEl(e.currentTarget) + } + + const handleCloseContextMenu = (e: Event) => { + e.stopPropagation() + setAnchorEl(undefined) + } + + const handleOpenModal = (e: MouseEvent, type: keyof typeof open) => { + e.stopPropagation() + if (type === ModalType.REMOVE) trackEvent({ ...SPACE_EVENTS.DELETE_ACCOUNT_MODAL }) + setAnchorEl(undefined) + setOpen((prev) => ({ ...prev, [type]: true })) + } + + const handleCloseModal = () => { + setOpen(defaultOpen) + } + + return ( + <> + <IconButton edge="end" size="small" onClick={handleOpenContextMenu}> + <MoreVertIcon sx={({ palette }) => ({ color: palette.border.main })} /> + </IconButton> + <ContextMenu anchorEl={anchorEl} open={!!anchorEl} onClose={handleCloseContextMenu}> + <MenuItem onClick={(e) => handleOpenModal(e, ModalType.RENAME)}> + <ListItemIcon> + <SvgIcon component={EditIcon} inheritViewBox fontSize="small" color="success" /> + </ListItemIcon> + <ListItemText>{hasName ? 'Rename' : 'Give name'}</ListItemText> + </MenuItem> + + {isAdmin && ( + <MenuItem onClick={(e) => handleOpenModal(e, ModalType.REMOVE)}> + <ListItemIcon> + <SvgIcon component={DeleteIcon} inheritViewBox fontSize="small" color="error" /> + </ListItemIcon> + <ListItemText>Remove</ListItemText> + </MenuItem> + )} + </ContextMenu> + + {open[ModalType.RENAME] && ( + <EntryDialog + handleClose={handleCloseModal} + defaultValues={{ name: name || '', address: safeItem.address }} + chainIds={chainIds} + currentChainId={isMultiChainSafeItem(safeItem) ? undefined : chainIds[0]} + disableAddressInput + /> + )} + + {open[ModalType.REMOVE] && <RemoveSafeDialog safeItem={safeItem} handleClose={handleCloseModal} />} + </> + ) +} + +export default SpaceSafeContextMenu diff --git a/apps/web/src/features/spaces/components/SafeAccounts/index.tsx b/apps/web/src/features/spaces/components/SafeAccounts/index.tsx new file mode 100644 index 0000000000..f63b6ecbd5 --- /dev/null +++ b/apps/web/src/features/spaces/components/SafeAccounts/index.tsx @@ -0,0 +1,68 @@ +import AddAccounts from '@/features/spaces/components/AddAccounts' +import EmptySafeAccounts from '@/features/spaces/components/SafeAccounts/EmptySafeAccounts' +import { Stack, Typography } from '@mui/material' +import { useEffect, useState } from 'react' +import SafesList from '@/features/myAccounts/components/SafesList' +import { useSpaceSafes } from '@/features/spaces/hooks/useSpaceSafes' +import { useSafesSearch } from '@/features/myAccounts/hooks/useSafesSearch' +import { useIsAdmin, useIsInvited } from '@/features/spaces/hooks/useSpaceMembers' +import PreviewInvite from '../InviteBanner/PreviewInvite' +import { SPACE_LABELS } from '@/services/analytics/events/spaces' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import Track from '@/components/common/Track' +import { trackEvent } from '@/services/analytics' +import SearchInput from '../SearchInput' + +const SpaceSafeAccounts = () => { + const [searchQuery, setSearchQuery] = useState('') + const { allSafes } = useSpaceSafes() + const filteredSafes = useSafesSearch(allSafes ?? [], searchQuery) + const isAdmin = useIsAdmin() + const isInvited = useIsInvited() + + const safes = searchQuery ? filteredSafes : allSafes + + useEffect(() => { + if (searchQuery) { + trackEvent({ ...SPACE_EVENTS.SEARCH_ACCOUNTS, label: SPACE_LABELS.accounts_page }) + } + }, [searchQuery]) + + return ( + <> + {isInvited && <PreviewInvite />} + <Typography variant="h1" mb={3}> + Safe Accounts + </Typography> + <Stack + direction="row" + justifyContent="space-between" + alignItems="flex-start" + gap={2} + mb={3} + flexWrap="nowrap" + flexDirection={{ xs: 'column-reverse', md: 'row' }} + > + <SearchInput onSearch={setSearchQuery} /> + + {isAdmin && ( + <Track {...SPACE_EVENTS.ADD_ACCOUNTS_MODAL} label={SPACE_LABELS.accounts_page}> + <AddAccounts /> + </Track> + )} + </Stack> + + {searchQuery && filteredSafes.length === 0 ? ( + <Typography variant="h5" fontWeight="normal" mb={2} color="primary.light"> + Found 0 results + </Typography> + ) : safes.length === 0 ? ( + <EmptySafeAccounts /> + ) : ( + <SafesList safes={safes} isSpaceSafe /> + )} + </> + ) +} + +export default SpaceSafeAccounts diff --git a/apps/web/src/features/spaces/components/SafeAccounts/styles.module.css b/apps/web/src/features/spaces/components/SafeAccounts/styles.module.css new file mode 100644 index 0000000000..81080ed361 --- /dev/null +++ b/apps/web/src/features/spaces/components/SafeAccounts/styles.module.css @@ -0,0 +1,25 @@ +.sendButton { + background-color: var(--color-background-main); + border-radius: 3px; + margin: 0 var(--space-1); +} + +.sendButton:hover { + background-color: rgba(0, 0, 0, 0.1); +} + +[data-theme='dark'] .sendButton:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.sendButton svg path { + fill: var(--color-text-primary); +} + +.sendButton:global(.Mui-disabled) { + background-color: var(--color-background-main); +} + +.sendButton:global(.Mui-disabled) svg path { + fill: var(--color-border-main); +} diff --git a/apps/web/src/features/spaces/components/SearchInput/index.test.tsx b/apps/web/src/features/spaces/components/SearchInput/index.test.tsx new file mode 100644 index 0000000000..d873cc3cda --- /dev/null +++ b/apps/web/src/features/spaces/components/SearchInput/index.test.tsx @@ -0,0 +1,31 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import SearchInput from './index' + +describe('SearchInput', () => { + const mockOnSearch = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders the component', () => { + render(<SearchInput onSearch={mockOnSearch} />) + + const input = screen.getByPlaceholderText('Search') + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('type', 'text') + const searchIcon = screen.getByTestId('search-icon') + expect(searchIcon).toBeInTheDocument() + }) + + it('calls onSearch with input value', async () => { + render(<SearchInput onSearch={mockOnSearch} />) + + const input = screen.getByPlaceholderText('Search') + fireEvent.change(input, { target: { value: 'test search' } }) + + await waitFor(() => { + expect(mockOnSearch).toHaveBeenCalledWith('test search') + }) + }) +}) diff --git a/apps/web/src/features/spaces/components/SearchInput/index.tsx b/apps/web/src/features/spaces/components/SearchInput/index.tsx new file mode 100644 index 0000000000..0584a6a2eb --- /dev/null +++ b/apps/web/src/features/spaces/components/SearchInput/index.tsx @@ -0,0 +1,45 @@ +import { InputAdornment, SvgIcon, TextField } from '@mui/material' +import SearchIcon from '@/public/images/common/search.svg' +import { useCallback } from 'react' +import { debounce } from 'lodash' + +interface SearchInputProps { + placeholder?: string + onSearch: (value: string) => void + debounceTime?: number +} + +const SearchInput = ({ onSearch, debounceTime = 300 }: SearchInputProps) => { + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleSearch = useCallback(debounce(onSearch, debounceTime), [onSearch, debounceTime]) + + return ( + <TextField + aria-label="Search" + placeholder="Search" + variant="filled" + hiddenLabel + onChange={(e) => { + handleSearch(e.target.value) + }} + InputProps={{ + startAdornment: ( + <InputAdornment position="start"> + <SvgIcon component={SearchIcon} inheritViewBox color="border" fontSize="small" data-testid="search-icon" /> + </InputAdornment> + ), + disableUnderline: true, + }} + size="small" + sx={{ + transition: 'width 0.15s ease-in-out', + width: { xs: '100%', sm: '250px' }, + '&:focus-within': { + width: { xs: '100%', sm: '470px' }, + }, + }} + /> + ) +} + +export default SearchInput diff --git a/apps/web/src/features/spaces/components/SignInButton/index.tsx b/apps/web/src/features/spaces/components/SignInButton/index.tsx new file mode 100644 index 0000000000..e3c89ba1d3 --- /dev/null +++ b/apps/web/src/features/spaces/components/SignInButton/index.tsx @@ -0,0 +1,49 @@ +import WalletLogin from '@/components/welcome/WelcomeLogin/WalletLogin' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import { useSiwe } from '@/services/siwe/useSiwe' +import { useAppDispatch } from '@/store' +import { setAuthenticated } from '@/store/authSlice' +import { showNotification } from '@/store/notificationsSlice' +import { logError } from '@/services/exceptions' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' + +const SignInButton = () => { + const dispatch = useAppDispatch() + const { signIn } = useSiwe() + + const handleLogin = () => { + trackEvent({ ...OVERVIEW_EVENTS.OPEN_ONBOARD, label: OVERVIEW_LABELS.space_list_page }) + } + + const handleSignIn = async () => { + trackEvent({ ...SPACE_EVENTS.SIGN_IN_BUTTON, label: SPACE_LABELS.space_list_page }) + + try { + const result = await signIn() + + if (result && result.error) { + throw result.error + } + + if (result) { + const oneDayInMs = 24 * 60 * 60 * 1000 + dispatch(setAuthenticated(Date.now() + oneDayInMs)) + } + } catch (error) { + logError(ErrorCodes._640) + + dispatch( + showNotification({ + message: `Something went wrong while trying to sign in`, + variant: 'error', + groupKey: 'sign-in-failed', + }), + ) + } + } + + return <WalletLogin onLogin={handleLogin} onContinue={handleSignIn} buttonText="Sign in with" /> +} + +export default SignInButton diff --git a/apps/web/src/features/spaces/components/SignedOutState/index.tsx b/apps/web/src/features/spaces/components/SignedOutState/index.tsx new file mode 100644 index 0000000000..41cbd37cc5 --- /dev/null +++ b/apps/web/src/features/spaces/components/SignedOutState/index.tsx @@ -0,0 +1,26 @@ +import { Box, Typography } from '@mui/material' +import css from '@/features/spaces/components/Dashboard/styles.module.css' +import SignInButton from '@/features/spaces/components/SignInButton' + +const SignedOutState = () => { + return ( + <Box className={css.content}> + <Box textAlign="center" className={css.contentWrapper}> + <Box className={css.contentInner}> + <Typography fontWeight={700} mb={2}> + Sign in to see content + </Typography> + + <Typography color="text.secondary" mb={2}> + To view and interact with spaces, you need to sign in with the wallet, that is a member of the space. Sign + in to continue. + </Typography> + + <SignInButton /> + </Box> + </Box> + </Box> + ) +} + +export default SignedOutState diff --git a/apps/web/src/features/spaces/components/SpaceBreadcrumbs/index.tsx b/apps/web/src/features/spaces/components/SpaceBreadcrumbs/index.tsx new file mode 100644 index 0000000000..93fa9cec13 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceBreadcrumbs/index.tsx @@ -0,0 +1,75 @@ +import { useRouter } from 'next/router' +import css from './styles.module.css' +import { IconButton, SvgIcon, Typography } from '@mui/material' +import { useAppSelector } from '@/store' +import { isAuthenticated } from '@/store/authSlice' +import SpaceIcon from '@/public/images/spaces/space.svg' +import Link from 'next/link' +import { AppRoutes } from '@/config/routes' +import { useSpaceSafesGetV1Query, useSpacesGetOneV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import InitialsAvatar from '@/features/spaces/components/InitialsAvatar' +import { BreadcrumbItem } from '@/components/common/Breadcrumbs/BreadcrumbItem' +import { useParentSafe } from '@/hooks/useParentSafe' +import { useCurrentSpaceId } from '@/features/spaces/hooks/useCurrentSpaceId' +import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' +import Track from '@/components/common/Track' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import { useSafeAddressFromUrl } from '@/hooks/useSafeAddressFromUrl' +import useChainId from '@/hooks/useChainId' +import { useMemo } from 'react' + +const SpaceBreadcrumbs = () => { + const isSpacesFeatureEnabled = useHasFeature(FEATURES.SPACES) + const { pathname } = useRouter() + const spaceId = useCurrentSpaceId() + const isUserSignedIn = useAppSelector(isAuthenticated) + const { currentData: space } = useSpacesGetOneV1Query({ id: Number(spaceId) }, { skip: !isUserSignedIn || !spaceId }) + const { currentData: safes } = useSpaceSafesGetV1Query( + { spaceId: Number(spaceId) }, + { skip: !isUserSignedIn || !spaceId }, + ) + const safeAddress = useSafeAddressFromUrl() + const chainId = useChainId() + const parentSafe = useParentSafe() + const isSpaceRoute = pathname.startsWith(AppRoutes.spaces.index) || pathname.startsWith(AppRoutes.welcome.spaces) + + const isSafePartOfSpace = useMemo( + () => safes && Object.entries(safes.safes).some((safe) => safe[0] === chainId && safe[1].includes(safeAddress)), + [chainId, safeAddress, safes], + ) + + if (!isUserSignedIn || !spaceId || isSpaceRoute || !space || !isSpacesFeatureEnabled || !isSafePartOfSpace) { + return null + } + + return ( + <> + <Track {...SPACE_EVENTS.OPEN_SPACE_LIST_PAGE} label={SPACE_LABELS.space_breadcrumbs}> + <Link href={{ pathname: AppRoutes.welcome.spaces }} passHref> + <IconButton size="small"> + <SvgIcon component={SpaceIcon} inheritViewBox sx={{ fill: 'none' }} fontSize="small" color="primary" /> + </IconButton> + </Link> + </Track> + + <Typography variant="body2">/</Typography> + + <Track {...SPACE_EVENTS.OPEN_SPACE_DASHBOARD} label={SPACE_LABELS.space_breadcrumbs}> + <Link href={{ pathname: AppRoutes.spaces.index, query: { spaceId } }} passHref className={css.spaceName}> + <InitialsAvatar name={space.name} size="xsmall" /> + <Typography variant="body2" fontWeight="bold"> + {space.name} + </Typography> + </Link> + </Track> + + <Typography variant="body2">/</Typography> + + {/* In case the nested breadcrumbs are not rendered we want to show the current safe address */} + {!parentSafe && <BreadcrumbItem title="Current Safe" address={safeAddress} />} + </> + ) +} + +export default SpaceBreadcrumbs diff --git a/apps/web/src/features/spaces/components/SpaceBreadcrumbs/styles.module.css b/apps/web/src/features/spaces/components/SpaceBreadcrumbs/styles.module.css new file mode 100644 index 0000000000..b5db063ba3 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceBreadcrumbs/styles.module.css @@ -0,0 +1,5 @@ +.spaceName { + display: flex; + gap: 4px; + align-items: center; +} diff --git a/apps/web/src/features/spaces/components/SpaceCard/SpaceContextMenu.tsx b/apps/web/src/features/spaces/components/SpaceCard/SpaceContextMenu.tsx new file mode 100644 index 0000000000..e3553c5725 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceCard/SpaceContextMenu.tsx @@ -0,0 +1,79 @@ +import { type MouseEvent, useState } from 'react' +import MoreVertIcon from '@mui/icons-material/MoreVert' +import { SvgIcon } from '@mui/material' +import IconButton from '@mui/material/IconButton' +import ListItemIcon from '@mui/material/ListItemIcon' +import ListItemText from '@mui/material/ListItemText' +import MenuItem from '@mui/material/MenuItem' +import ContextMenu from '@/components/common/ContextMenu' +import DeleteIcon from '@/public/images/common/delete.svg' +import EditIcon from '@/public/images/common/edit.svg' +import type { GetSpaceResponse } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import css from './styles.module.css' +import DeleteSpaceDialog from '@/features/spaces/components/SpaceSettings/DeleteSpaceDialog' +import UpdateSpaceDialog from '@/features/spaces/components/SpaceSettings/UpdateSpaceDialog' +import Track from '@/components/common/Track' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' + +enum ModalType { + RENAME = 'rename', + REMOVE = 'remove', +} + +const defaultOpen = { [ModalType.RENAME]: false, [ModalType.REMOVE]: false } + +const SpaceContextMenu = ({ space }: { space: GetSpaceResponse }) => { + const [anchorEl, setAnchorEl] = useState<HTMLElement | undefined>() + const [open, setOpen] = useState<typeof defaultOpen>(defaultOpen) + + const handleOpenContextMenu = (e: MouseEvent<HTMLButtonElement, globalThis.MouseEvent>) => { + e.stopPropagation() + setAnchorEl(e.currentTarget) + } + + const handleCloseContextMenu = (e: Event) => { + e.stopPropagation() + setAnchorEl(undefined) + } + + const handleOpenModal = (e: MouseEvent, type: keyof typeof open) => { + e.stopPropagation() + setAnchorEl(undefined) + setOpen((prev) => ({ ...prev, [type]: true })) + } + + const handleCloseModal = () => { + setOpen(defaultOpen) + } + + return ( + <> + <IconButton className={css.spaceActions} size="small" onClick={handleOpenContextMenu}> + <MoreVertIcon sx={({ palette }) => ({ color: palette.border.main })} /> + </IconButton> + <ContextMenu anchorEl={anchorEl} open={!!anchorEl} onClose={handleCloseContextMenu}> + <MenuItem onClick={(e) => handleOpenModal(e, ModalType.RENAME)}> + <ListItemIcon> + <SvgIcon component={EditIcon} inheritViewBox fontSize="small" color="success" /> + </ListItemIcon> + <ListItemText>Rename</ListItemText> + </MenuItem> + + <Track {...SPACE_EVENTS.DELETE_SPACE_MODAL} label={SPACE_LABELS.space_context_menu}> + <MenuItem data-testid="remove-button" onClick={(e) => handleOpenModal(e, ModalType.REMOVE)}> + <ListItemIcon> + <SvgIcon component={DeleteIcon} inheritViewBox fontSize="small" color="error" /> + </ListItemIcon> + <ListItemText>Remove</ListItemText> + </MenuItem> + </Track> + </ContextMenu> + + {open[ModalType.RENAME] && <UpdateSpaceDialog space={space} onClose={handleCloseModal} />} + + {open[ModalType.REMOVE] && <DeleteSpaceDialog space={space} onClose={handleCloseModal} />} + </> + ) +} + +export default SpaceContextMenu diff --git a/apps/web/src/features/spaces/components/SpaceCard/index.tsx b/apps/web/src/features/spaces/components/SpaceCard/index.tsx new file mode 100644 index 0000000000..2d14620217 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceCard/index.tsx @@ -0,0 +1,78 @@ +import { AppRoutes } from '@/config/routes' +import { Box, Card, Stack, Typography } from '@mui/material' +import Link from 'next/link' + +import css from './styles.module.css' +import type { GetSpaceResponse } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import classNames from 'classnames' +import { useSpaceSafeCount } from '@/features/spaces/hooks/useSpaceSafeCount' +import InitialsAvatar from '@/features/spaces/components/InitialsAvatar' +import SpaceContextMenu from '@/features/spaces/components/SpaceCard/SpaceContextMenu' +import { MemberStatus, useIsAdmin } from '@/features/spaces/hooks/useSpaceMembers' +import { maybePlural } from '@safe-global/utils/utils/formatters' + +export const SpaceSummary = ({ + name, + numberOfAccounts, + numberOfMembers, + isCompact = false, +}: { + name: string + numberOfAccounts: number + numberOfMembers: number + isCompact?: boolean +}) => { + return ( + <Box className={css.spaceInfo}> + <Typography variant="body2" fontWeight="bold"> + {name} + </Typography> + + <Stack direction="row" spacing={1} alignItems="center" mt={isCompact ? 0 : 0.5}> + <Typography variant="caption" color="text.secondary"> + {numberOfAccounts} Account{maybePlural(numberOfAccounts)} + </Typography> + + <div className={css.dot} /> + + <Typography variant="caption" color="text.secondary"> + {numberOfMembers} Member{maybePlural(numberOfMembers)} + </Typography> + </Stack> + </Box> + ) +} + +const SpaceCard = ({ + space, + isCompact = false, + isLink = true, +}: { + space: GetSpaceResponse + isCompact?: boolean + isLink?: boolean +}) => { + const { id, name, members } = space + const numberOfMembers = members.filter((member) => member.status === MemberStatus.ACTIVE).length + const numberOfAccounts = useSpaceSafeCount(id) + const isAdmin = useIsAdmin(id) + + return ( + <Card data-testid="space-card" className={classNames(css.card, { [css.compact]: isCompact })}> + {isLink && <Link className={css.cardLink} href={{ pathname: AppRoutes.spaces.index, query: { spaceId: id } }} />} + + <InitialsAvatar name={name} size={isCompact ? 'medium' : 'large'} /> + + <SpaceSummary + name={name} + numberOfAccounts={numberOfAccounts} + numberOfMembers={numberOfMembers} + isCompact={isCompact} + /> + + {isAdmin && <SpaceContextMenu space={space} />} + </Card> + ) +} + +export default SpaceCard diff --git a/apps/web/src/features/spaces/components/SpaceCard/styles.module.css b/apps/web/src/features/spaces/components/SpaceCard/styles.module.css new file mode 100644 index 0000000000..73075611b8 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceCard/styles.module.css @@ -0,0 +1,56 @@ +.card { + position: relative; + flex-basis: 50%; + display: grid; + grid-template-areas: + 'logo logo actions' + 'info info actions'; + grid-template-columns: auto 1fr auto; + gap: 8px; + padding: var(--space-2); +} + +.card.compact { + grid-template-areas: + 'logo info actions' + 'logo info actions'; + grid-template-columns: auto 1fr auto; + padding: var(--space-1); + padding-left: 12px; + border: 0; +} + +.initialsAvatar { + grid-area: logo; + text-transform: uppercase; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + color: white; + justify-self: start; +} + +.spaceInfo { + grid-area: info; +} + +.dot { + width: 2px; + height: 2px; + background-color: var(--color-border-main); + border-radius: 50%; +} + +.spaceActions { + grid-area: actions; + align-self: start; +} + +.cardLink { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; +} diff --git a/apps/web/src/features/spaces/components/SpaceCreationModal/index.tsx b/apps/web/src/features/spaces/components/SpaceCreationModal/index.tsx new file mode 100644 index 0000000000..035788c070 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceCreationModal/index.tsx @@ -0,0 +1,109 @@ +import { useSpacesCreateWithUserV1Mutation } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useRouter } from 'next/router' +import { type ReactElement, useState } from 'react' +import { Alert, Box, Button, CircularProgress, DialogActions, DialogContent, SvgIcon, Typography } from '@mui/material' +import { FormProvider, useForm } from 'react-hook-form' +import SpaceIcon from '@/public/images/spaces/space.svg' +import ModalDialog from '@/components/common/ModalDialog' +import NameInput from '@/components/common/NameInput' +import { AppRoutes } from '@/config/routes' +import { trackEvent } from '@/services/analytics' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import { showNotification } from '@/store/notificationsSlice' +import { useAppDispatch } from '@/store' +import ExternalLink from '@/components/common/ExternalLink' + +function SpaceCreationModal({ onClose }: { onClose: () => void }): ReactElement { + const [error, setError] = useState<string>() + const [isSubmitting, setIsSubmitting] = useState(false) + const router = useRouter() + const dispatch = useAppDispatch() + const methods = useForm<{ name: string }>({ mode: 'onChange' }) + const [createSpaceWithUser] = useSpacesCreateWithUserV1Mutation() + const { handleSubmit, formState } = methods + + const onSubmit = handleSubmit(async (data) => { + setError(undefined) + + try { + setIsSubmitting(true) + trackEvent({ ...SPACE_EVENTS.CREATE_SPACE }) + const response = await createSpaceWithUser({ createSpaceDto: { name: data.name } }) + + if (response.data) { + const spaceId = response.data.id.toString() + router.push({ pathname: AppRoutes.spaces.index, query: { spaceId } }) + onClose() + + dispatch( + showNotification({ + message: `Created space with name ${data.name}.`, + variant: 'success', + groupKey: 'create-space-success', + }), + ) + } + + if (response.error) { + throw response.error + } + } catch (error) { + // @ts-ignore + const errorMessage = error?.data?.message || 'Failed creating the space. Please try again.' + setError(errorMessage) + } finally { + setIsSubmitting(false) + } + }) + + return ( + <ModalDialog + open + onClose={onClose} + dialogTitle={ + <> + <SvgIcon component={SpaceIcon} inheritViewBox sx={{ fill: 'none', mr: 1 }} /> + Create space + </> + } + hideChainIndicator + > + <FormProvider {...methods}> + <form onSubmit={onSubmit}> + <DialogContent sx={{ py: 2 }}> + <Box mb={2}> + <NameInput data-testid="space-name-input" label="Name" autoFocus name="name" required /> + </Box> + <Typography variant="body2" color="text.secondary"> + How is my data processed? Read our <ExternalLink href={AppRoutes.privacy}>privacy policy</ExternalLink> + </Typography> + + {error && ( + <Alert severity="error" sx={{ mt: 2 }}> + {error} + </Alert> + )} + </DialogContent> + + <DialogActions> + <Button data-testid="cancel-btn" onClick={onClose}> + Cancel + </Button> + <Button + data-testid="create-space-modal-button" + type="submit" + variant="contained" + disabled={!formState.isValid || isSubmitting} + disableElevation + sx={{ minWidth: '200px' }} + > + {isSubmitting ? <CircularProgress size={20} /> : 'Create space'} + </Button> + </DialogActions> + </form> + </FormProvider> + </ModalDialog> + ) +} + +export default SpaceCreationModal diff --git a/apps/web/src/features/spaces/components/SpaceInfoModal/index.tsx b/apps/web/src/features/spaces/components/SpaceInfoModal/index.tsx new file mode 100644 index 0000000000..baac7f7116 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceInfoModal/index.tsx @@ -0,0 +1,161 @@ +import { + Button, + Chip, + Dialog, + DialogContent, + Grid2, + IconButton, + List, + ListItem, + ListItemIcon, + Stack, + SvgIcon, + Typography, +} from '@mui/material' +import CheckIcon from '@/public/images/common/check.svg' +import CloseIcon from '@mui/icons-material/Close' +import CreateSpaceInfo from '@/public/images/spaces/create_space_info.png' +import Image from 'next/image' +import { AppRoutes } from '@/config/routes' +import Link from 'next/link' +import { trackEvent } from '@/services/analytics' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import ExternalLink from '@/components/common/ExternalLink' + +const ListIcon = () => ( + <ListItemIcon + sx={{ + alignSelf: 'flex-start', + minWidth: '20px', + marginRight: '16px', + marginTop: '0', + color: 'success.main', + '& path:last-child': { + fill: 'var(--color-success-main)', + }, + backgroundColor: 'success.light', + borderRadius: '50%', + width: '20px', + height: '20px', + alignItems: 'center', + justifyContent: 'center', + }} + > + <SvgIcon component={CheckIcon} inheritViewBox fontSize="small" sx={{ width: '12px', height: '12px' }} /> + </ListItemIcon> +) + +const SPACE_HELP_ARTICLE_LINK = 'https://help.safe.global/en/articles/285386-spaces' + +const SpaceInfoModal = ({ + showButtons = true, + onClose, + onCreateSpace, +}: { + showButtons?: boolean + onClose: () => void + onCreateSpace?: () => void +}) => { + return ( + <Dialog open PaperProps={{ style: { width: '870px', maxWidth: '98%', borderRadius: '16px' } }} onClose={onClose}> + <DialogContent dividers sx={{ p: 0, border: 0 }}> + <Grid2 container> + <Grid2 size={{ xs: 12, md: 6 }} p={5} display="flex" flexDirection="column"> + <Typography component="div" variant="h1" mb={1} position="relative"> + Introducing spaces + <Chip + label="Beta" + size="small" + sx={{ ml: 1, fontWeight: 'normal', position: 'absolute', top: '0', right: '0' }} + /> + </Typography> + + <Typography mt={2} mb={3}> + Collaborate seamlessly with your team and keep your treasury organized. + </Typography> + + <List sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> + <ListItem disablePadding> + <ListIcon /> + Bring all your Safe Accounts into one shared space. + </ListItem> + + <ListItem disablePadding> + <ListIcon /> + Invite team members with shared access—whether they’re signers or just viewers. + </ListItem> + + <ListItem disablePadding> + <ListIcon /> + Everyone sees the same account names, team members, and data. + </ListItem> + + <ListItem disablePadding> + <ListIcon /> + Aggregated balances and actions across multiple accounts are coming soon! + </ListItem> + </List> + + <Typography mt={1}> + Read the <ExternalLink href={SPACE_HELP_ARTICLE_LINK}>Spaces help article</ExternalLink> + </Typography> + + {showButtons && ( + <Stack gap={2} mt={{ xs: 3, md: 'auto' }}> + {onCreateSpace ? ( + <Button + variant="contained" + color="primary" + onClick={() => { + trackEvent({ ...SPACE_EVENTS.CREATE_SPACE_MODAL, label: SPACE_LABELS.info_modal }) + onClose() + onCreateSpace() + }} + > + Create a space + </Button> + ) : ( + <Link href={AppRoutes.welcome.spaces} passHref legacyBehavior> + <Button + variant="contained" + color="primary" + onClick={() => + trackEvent({ ...SPACE_EVENTS.OPEN_SPACE_LIST_PAGE, label: SPACE_LABELS.info_modal }) + } + > + Create a space + </Button> + </Link> + )} + + <Button variant="text" color="primary" onClick={onClose}> + Maybe later + </Button> + </Stack> + )} + </Grid2> + + <Grid2 size={6} display={{ xs: 'none', md: 'flex' }} justifyContent="center" flex={1} bgcolor="#121312"> + <Image src={CreateSpaceInfo} style={{ width: '100%' }} alt="An illustration of multiple safe accounts" /> + </Grid2> + </Grid2> + + <IconButton + onClick={onClose} + sx={{ + position: 'absolute', + top: 0, + right: 0, + p: 1, + m: 1, + color: '#ffffff', + }} + > + <CloseIcon /> + </IconButton> + </DialogContent> + </Dialog> + ) +} + +export default SpaceInfoModal diff --git a/apps/web/src/features/spaces/components/SpaceSettings/DeleteSpaceDialog.tsx b/apps/web/src/features/spaces/components/SpaceSettings/DeleteSpaceDialog.tsx new file mode 100644 index 0000000000..a276cfdd4e --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceSettings/DeleteSpaceDialog.tsx @@ -0,0 +1,106 @@ +import { + Alert, + Button, + DialogActions, + DialogContent, + List, + ListItem, + ListItemIcon, + SvgIcon, + Typography, +} from '@mui/material' +import ModalDialog from '@/components/common/ModalDialog' +import { type GetSpaceResponse, useSpacesDeleteV1Mutation } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import CheckIcon from '@/public/images/common/check.svg' +import CloseIcon from '@/public/images/common/close.svg' +import css from './styles.module.css' +import { AppRoutes } from '@/config/routes' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { showNotification } from '@/store/notificationsSlice' +import { useAppDispatch } from '@/store' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import { trackEvent } from '@/services/analytics' + +const ListIcon = ({ variant }: { variant: 'success' | 'danger' }) => { + const Icon = variant === 'success' ? CheckIcon : CloseIcon + + return ( + <ListItemIcon className={variant === 'success' ? css.success : css.danger}> + <SvgIcon component={Icon} inheritViewBox /> + </ListItemIcon> + ) +} + +const DeleteSpaceDialog = ({ space, onClose }: { space: GetSpaceResponse | undefined; onClose: () => void }) => { + const [error, setError] = useState<string>() + const router = useRouter() + const dispatch = useAppDispatch() + const [deleteSpace] = useSpacesDeleteV1Mutation() + + const onDelete = async () => { + if (!space) return + + setError(undefined) + + try { + await deleteSpace({ id: space.id }) + + onClose() + + trackEvent({ ...SPACE_EVENTS.DELETE_SPACE }) + dispatch( + showNotification({ + message: `Deleted space ${space.name}.`, + variant: 'success', + groupKey: 'delete-space-success', + }), + ) + + router.push({ pathname: AppRoutes.welcome.spaces }) + } catch (e) { + console.error(e) + setError('Error deleting the space. Please try again.') + } + } + + return ( + <ModalDialog dialogTitle="Delete space" hideChainIndicator open onClose={onClose}> + <DialogContent sx={{ mt: 2 }}> + <Typography mb={2}> + Are you sure you want to delete <b>{space?.name}</b>? Deleting this space: + </Typography> + + <List sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> + <ListItem disablePadding> + <ListIcon variant="danger" /> + Will permanently revoke access to space data for you and its members + </ListItem> + <ListItem disablePadding> + <ListIcon variant="danger" /> + Will remove members and Safe Accounts names from our database + </ListItem> + <ListItem disablePadding> + <ListIcon variant="success" /> + Will keep access to the Safe Accounts added to this space. They will not be deleted. + </ListItem> + </List> + + {error && ( + <Alert severity="error" sx={{ mt: 2 }}> + {error} + </Alert> + )} + </DialogContent> + + <DialogActions> + <Button onClick={onClose}>No, keep it</Button> + <Button data-testid="space-confirm-delete-button" variant="danger" onClick={onDelete}> + Permanently delete it + </Button> + </DialogActions> + </ModalDialog> + ) +} + +export default DeleteSpaceDialog diff --git a/apps/web/src/features/spaces/components/SpaceSettings/LeaveSpaceDialog.tsx b/apps/web/src/features/spaces/components/SpaceSettings/LeaveSpaceDialog.tsx new file mode 100644 index 0000000000..d47d3e0696 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceSettings/LeaveSpaceDialog.tsx @@ -0,0 +1,72 @@ +import { Alert, Button, DialogActions, DialogContent, Typography } from '@mui/material' +import ModalDialog from '@/components/common/ModalDialog' +import { type GetSpaceResponse, useMembersSelfRemoveV1Mutation } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { AppRoutes } from '@/config/routes' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { showNotification } from '@/store/notificationsSlice' +import { useAppDispatch } from '@/store' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import { trackEvent } from '@/services/analytics' + +const LeaveSpaceDialog = ({ space, onClose }: { space: GetSpaceResponse | undefined; onClose: () => void }) => { + const [error, setError] = useState<string>() + const router = useRouter() + const dispatch = useAppDispatch() + const [leaveSpace] = useMembersSelfRemoveV1Mutation() + + const onLeave = async () => { + if (!space) return + + setError(undefined) + + try { + const res = await leaveSpace({ spaceId: space.id }) + + if (res.error) { + throw new Error(JSON.stringify(res.error)) + } + + onClose() + + trackEvent({ ...SPACE_EVENTS.LEAVE_SPACE }) + dispatch( + showNotification({ + message: `Left space ${space.name}.`, + variant: 'success', + groupKey: 'leave-space-success', + }), + ) + + router.push({ pathname: AppRoutes.welcome.spaces }) + } catch (e) { + console.error(e) + setError('Error leaving the space. Please try again.') + } + } + + return ( + <ModalDialog dialogTitle="Leave space" hideChainIndicator open onClose={onClose}> + <DialogContent sx={{ mt: 2 }}> + <Typography mb={2}> + Are you sure you want to leave this space? You won’t be able to access its data anymore. + </Typography> + + {error && ( + <Alert severity="error" sx={{ mt: 2 }}> + {error} + </Alert> + )} + </DialogContent> + + <DialogActions> + <Button onClick={onClose}>Cancel</Button> + <Button data-testid="space-confirm-leave-button" variant="danger" onClick={onLeave}> + Leave space + </Button> + </DialogActions> + </ModalDialog> + ) +} + +export default LeaveSpaceDialog diff --git a/apps/web/src/features/spaces/components/SpaceSettings/UpdateSpaceDialog.tsx b/apps/web/src/features/spaces/components/SpaceSettings/UpdateSpaceDialog.tsx new file mode 100644 index 0000000000..2288796b89 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceSettings/UpdateSpaceDialog.tsx @@ -0,0 +1,23 @@ +import ModalDialog from '@/components/common/ModalDialog' +import DialogContent from '@mui/material/DialogContent' +import UpdateSpaceForm from '@/features/spaces/components/SpaceSettings/UpdateSpaceForm' +import type { GetSpaceResponse } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { Typography } from '@mui/material' +import { AppRoutes } from '@/config/routes' +import ExternalLink from '@/components/common/ExternalLink' + +const UpdateSpaceDialog = ({ space, onClose }: { space: GetSpaceResponse; onClose: () => void }) => { + return ( + <ModalDialog dialogTitle="Update space" hideChainIndicator open onClose={onClose}> + <DialogContent sx={{ mt: 2 }}> + <Typography mb={2}> + The space name is visible in the sidebar menu, headings to all its members. Usually it's a name of the + company or a business. <ExternalLink href={AppRoutes.privacy}>How is this data stored?</ExternalLink> + </Typography> + <UpdateSpaceForm space={space} /> + </DialogContent> + </ModalDialog> + ) +} + +export default UpdateSpaceDialog diff --git a/apps/web/src/features/spaces/components/SpaceSettings/UpdateSpaceForm.tsx b/apps/web/src/features/spaces/components/SpaceSettings/UpdateSpaceForm.tsx new file mode 100644 index 0000000000..736070694f --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceSettings/UpdateSpaceForm.tsx @@ -0,0 +1,83 @@ +import { Alert, Button, TextField } from '@mui/material' +import { FormProvider, useForm } from 'react-hook-form' +import { showNotification } from '@/store/notificationsSlice' +import { type GetSpaceResponse, useSpacesUpdateV1Mutation } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useAppDispatch } from '@/store' +import { useIsAdmin } from '@/features/spaces/hooks/useSpaceMembers' +import { useState } from 'react' + +type UpdateSpaceFormData = { + name: string +} + +const UpdateSpaceForm = ({ space }: { space: GetSpaceResponse | undefined }) => { + const [error, setError] = useState<string>() + const dispatch = useAppDispatch() + const [updateSpace] = useSpacesUpdateV1Mutation() + const isAdmin = useIsAdmin(space?.id) + + const formMethods = useForm<UpdateSpaceFormData>({ + mode: 'onChange', + values: { + name: space?.name || '', + }, + }) + + const { register, handleSubmit, watch } = formMethods + + const formName = watch('name') + const isNameChanged = formName !== space?.name + + const onSubmit = handleSubmit(async (data) => { + setError(undefined) + + if (!space) return + + try { + await updateSpace({ id: space.id, updateSpaceDto: { name: data.name } }) + + dispatch( + showNotification({ + variant: 'success', + message: 'Updated space name', + groupKey: 'space-update-name', + }), + ) + } catch (e) { + console.error(e) + setError('Error updating the space. Please try again.') + } + }) + + return ( + <FormProvider {...formMethods}> + <form onSubmit={onSubmit}> + <TextField + {...register('name')} + label="Space name" + fullWidth + slotProps={{ inputLabel: { shrink: true } }} + onKeyDown={(e) => e.stopPropagation()} + /> + + {error && ( + <Alert severity="error" sx={{ mt: 2 }}> + {error} + </Alert> + )} + + <Button + data-testid="space-save-button" + variant="contained" + type="submit" + sx={{ mt: 2 }} + disabled={!isNameChanged || !isAdmin} + > + Save + </Button> + </form> + </FormProvider> + ) +} + +export default UpdateSpaceForm diff --git a/apps/web/src/features/spaces/components/SpaceSettings/index.tsx b/apps/web/src/features/spaces/components/SpaceSettings/index.tsx new file mode 100644 index 0000000000..a412f3dde6 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceSettings/index.tsx @@ -0,0 +1,97 @@ +import { useAppSelector } from '@/store' +import { Button, Card, Grid2, Stack, Tooltip, Typography } from '@mui/material' +import { useSpacesGetOneV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useState } from 'react' +import { useCurrentSpaceId } from '@/features/spaces/hooks/useCurrentSpaceId' +import { isAuthenticated } from '@/store/authSlice' +import { useIsAdmin, useIsInvited, useIsActiceMember } from '@/features/spaces/hooks/useSpaceMembers' +import PreviewInvite from '@/features/spaces/components/InviteBanner/PreviewInvite' +import DeleteSpaceDialog from '@/features/spaces/components/SpaceSettings/DeleteSpaceDialog' +import UpdateSpaceForm from '@/features/spaces/components/SpaceSettings/UpdateSpaceForm' +import { trackEvent } from '@/services/analytics' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import ExternalLink from '@/components/common/ExternalLink' +import { AppRoutes } from '@/config/routes' +import LeaveSpaceDialog from './LeaveSpaceDialog' +import { useIsLastActiveAdmin } from '../../hooks/useIsLastActiveAdmin' + +const SpaceSettings = () => { + const [deleteSpaceOpen, setDeleteSpaceOpen] = useState(false) + const [leaveSpaceOpen, setLeaveSpaceOpen] = useState(false) + const isAdmin = useIsAdmin() + const spaceId = useCurrentSpaceId() + const isUserSignedIn = useAppSelector(isAuthenticated) + const { currentData: space } = useSpacesGetOneV1Query({ id: Number(spaceId) }, { skip: !isUserSignedIn || !spaceId }) + const isInvited = useIsInvited() + const isLastActiveAdmin = useIsLastActiveAdmin() + const isActiveMember = useIsActiceMember() + + return ( + <div> + {isInvited && <PreviewInvite />} + <Typography variant="h2" mb={3}> + Settings + </Typography> + <Card> + <Grid2 container p={4} spacing={2}> + <Grid2 size={{ xs: 12, md: 4 }}> + <Typography fontWeight="bold">General</Typography> + </Grid2> + <Grid2 size={{ xs: 12, md: 8 }}> + <Typography mb={2}> + The space name is visible in the sidebar menu, headings to all its members. Usually it's a name of + the company or a business. <ExternalLink href={AppRoutes.privacy}>How is this data stored?</ExternalLink> + </Typography> + + <UpdateSpaceForm space={space} /> + </Grid2> + </Grid2> + + <Grid2 container p={4} spacing={2}> + <Grid2 size={{ xs: 12, md: 4 }}> + <Typography fontWeight="bold">Danger Zone</Typography> + </Grid2> + <Grid2 size={{ xs: 12, md: 8 }}> + <Typography mb={2}>This action cannot be undone.</Typography> + + <Stack direction="row" spacing={2}> + <Tooltip title={isLastActiveAdmin ? 'You are the last active admin and cannot leave the space.' : ''}> + <span> + <Button + data-testid="space-leave-button" + onClick={() => { + setLeaveSpaceOpen(true) + trackEvent({ ...SPACE_EVENTS.LEAVE_SPACE_MODAL, label: SPACE_LABELS.space_settings }) + }} + variant={isAdmin ? 'outlined' : 'danger'} + color="error" + disabled={isLastActiveAdmin || !isActiveMember} + > + Leave space + </Button> + </span> + </Tooltip> + + {isAdmin && ( + <Button + data-testid="space-delete-button" + variant="danger" + onClick={() => { + setDeleteSpaceOpen(true) + trackEvent({ ...SPACE_EVENTS.DELETE_SPACE_MODAL, label: SPACE_LABELS.space_settings }) + }} + > + Delete space + </Button> + )} + </Stack> + </Grid2> + </Grid2> + </Card> + {deleteSpaceOpen && <DeleteSpaceDialog space={space} onClose={() => setDeleteSpaceOpen(false)} />} + {leaveSpaceOpen && <LeaveSpaceDialog space={space} onClose={() => setLeaveSpaceOpen(false)} />} + </div> + ) +} + +export default SpaceSettings diff --git a/apps/web/src/features/spaces/components/SpaceSettings/styles.module.css b/apps/web/src/features/spaces/components/SpaceSettings/styles.module.css new file mode 100644 index 0000000000..35b9db2461 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceSettings/styles.module.css @@ -0,0 +1,45 @@ +.success { + align-self: flex-start; + min-width: 20px; + margin-right: 16px; + margin-top: 0; + color: var(--color-success-main); + background-color: var(--color-success-light); + border-radius: 50%; + width: 20px; + height: 20px; + align-items: center; + justify-content: center; +} + +.success path:last-child { + fill: var(--color-success-main); +} + +.success svg { + width: 12px; + height: 12px; +} + +.danger { + align-self: flex-start; + min-width: 20px; + margin-right: 16px; + margin-top: 0; + color: var(--color-error-main); + background-color: var(--color-error-light); + border-radius: 50%; + width: 20px; + height: 20px; + align-items: center; + justify-content: center; +} + +.danger path:last-child { + fill: var(--color-error-main); +} + +.danger svg { + width: 10px; + height: 10px; +} diff --git a/apps/web/src/features/spaces/components/SpaceSidebar/index.tsx b/apps/web/src/features/spaces/components/SpaceSidebar/index.tsx new file mode 100644 index 0000000000..391ada40be --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceSidebar/index.tsx @@ -0,0 +1,16 @@ +import { type ReactElement } from 'react' + +import css from './styles.module.css' +import SpaceSidebarNavigation from '@/features/spaces/components/SpaceSidebarNavigation' +import SpaceSidebarSelector from '@/features/spaces/components/SpaceSidebarSelector' + +const SpaceSidebar = (): ReactElement => { + return ( + <div className={css.container}> + <SpaceSidebarSelector /> + <SpaceSidebarNavigation /> + </div> + ) +} + +export default SpaceSidebar diff --git a/apps/web/src/features/spaces/components/SpaceSidebar/styles.module.css b/apps/web/src/features/spaces/components/SpaceSidebar/styles.module.css new file mode 100644 index 0000000000..c651673b55 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceSidebar/styles.module.css @@ -0,0 +1,9 @@ +.container { + height: 100vh; + padding-top: var(--header-height); + display: flex; + overflow: hidden; + flex-direction: column; + background-color: var(--color-background-paper); + width: 230px; +} diff --git a/apps/web/src/features/spaces/components/SpaceSidebarNavigation/config.tsx b/apps/web/src/features/spaces/components/SpaceSidebarNavigation/config.tsx new file mode 100644 index 0000000000..b3edf8b880 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceSidebarNavigation/config.tsx @@ -0,0 +1,57 @@ +import { Chip } from '@/components/common/Chip' +import ABIcon from '@/public/images/sidebar/address-book.svg' +import TransactionIcon from '@/public/images/sidebar/transactions.svg' +import React, { type ReactElement } from 'react' +import { AppRoutes } from '@/config/routes' +import HomeIcon from '@/public/images/sidebar/home.svg' +import SettingsIcon from '@/public/images/sidebar/settings.svg' +import MembersIcon from '@/public/images/sidebar/members.svg' +import AccountsIcon from '@/public/images/sidebar/wallet.svg' +import { SvgIcon } from '@mui/material' + +export type DynamicNavItem = { + label: string + icon?: ReactElement + href: string + tag?: ReactElement + disabled?: boolean + activeMemberOnly?: boolean +} + +export const navItems: DynamicNavItem[] = [ + { + label: 'Home', + icon: <SvgIcon component={HomeIcon} inheritViewBox />, + href: AppRoutes.spaces.index, + }, + { + label: 'Safe Accounts', + icon: <SvgIcon component={AccountsIcon} inheritViewBox />, + href: AppRoutes.spaces.safeAccounts, + }, + { + label: 'Transactions', + icon: <SvgIcon component={TransactionIcon} inheritViewBox />, + href: '', // TODO: Replace with empty page + disabled: true, + tag: <Chip label="Soon" sx={{ backgroundColor: 'background.main', color: 'primary.light' }} />, + }, + { + label: 'Members', + icon: <SvgIcon component={MembersIcon} inheritViewBox />, + href: AppRoutes.spaces.members, + }, + { + label: 'Address book', + icon: <SvgIcon component={ABIcon} inheritViewBox />, + href: '', // TODO: Replace with empty page + disabled: true, + tag: <Chip label="Soon" sx={{ backgroundColor: 'background.main', color: 'primary.light' }} />, + }, + { + label: 'Settings', + icon: <SvgIcon component={SettingsIcon} inheritViewBox />, + href: AppRoutes.spaces.settings, + activeMemberOnly: true, + }, +] diff --git a/apps/web/src/features/spaces/components/SpaceSidebarNavigation/index.tsx b/apps/web/src/features/spaces/components/SpaceSidebarNavigation/index.tsx new file mode 100644 index 0000000000..5861de3ac2 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceSidebarNavigation/index.tsx @@ -0,0 +1,50 @@ +import React, { type ReactElement } from 'react' +import { useRouter } from 'next/router' +import { ListItemButton } from '@mui/material' + +import { + SidebarList, + SidebarListItemButton, + SidebarListItemIcon, + SidebarListItemText, +} from '@/components/sidebar/SidebarList' +import { useCurrentSpaceId } from '@/features/spaces/hooks/useCurrentSpaceId' +import { useIsActiceMember } from '@/features/spaces/hooks/useSpaceMembers' +import { navItems } from './config' + +const Navigation = (): ReactElement => { + const router = useRouter() + const spaceId = useCurrentSpaceId() + const isActiveMember = useIsActiceMember() + + return ( + <SidebarList> + {navItems.map((item) => { + const hideItem = item.activeMemberOnly && !isActiveMember + const isSelected = router.pathname === item.href + + if (hideItem) return null + + return ( + <div key={item.label}> + <ListItemButton disabled={item.disabled} sx={{ padding: 0 }} selected={isSelected}> + <SidebarListItemButton + selected={isSelected} + href={item.href ? { pathname: item.href, query: { spaceId } } : ''} + > + {item.icon && <SidebarListItemIcon>{item.icon}</SidebarListItemIcon>} + + <SidebarListItemText data-testid="sidebar-list-item" bold> + {item.label} + {item.tag} + </SidebarListItemText> + </SidebarListItemButton> + </ListItemButton> + </div> + ) + })} + </SidebarList> + ) +} + +export default React.memo(Navigation) diff --git a/apps/web/src/features/spaces/components/SpaceSidebarSelector/index.tsx b/apps/web/src/features/spaces/components/SpaceSidebarSelector/index.tsx new file mode 100644 index 0000000000..2a7f46ffac --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceSidebarSelector/index.tsx @@ -0,0 +1,149 @@ +import { Box, Button, Divider, Menu, MenuItem, Typography } from '@mui/material' +import { type GetSpaceResponse, useSpacesGetV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useState } from 'react' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import CheckIcon from '@mui/icons-material/Check' +import SpaceCard from '../SpaceCard' +import InitialsAvatar from '../InitialsAvatar' + +import css from './styles.module.css' +import { useRouter } from 'next/router' +import { AppRoutes } from '@/config/routes' +import SpaceCreationModal from '../SpaceCreationModal' +import { useCurrentSpaceId } from 'src/features/spaces/hooks/useCurrentSpaceId' +import { useAppSelector } from '@/store' +import { isAuthenticated } from '@/store/authSlice' +import { SPACE_LABELS } from '@/services/analytics/events/spaces' +import { trackEvent } from '@/services/analytics' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' +import { getNonDeclinedSpaces } from '@/features/spaces/utils' +import { useUsersGetWithWalletsV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/users' + +const SpaceSidebarSelector = () => { + const [isCreationModalOpen, setIsCreationModalOpen] = useState(false) + const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null) + const router = useRouter() + const open = Boolean(anchorEl) + const spaceId = useCurrentSpaceId() + const isUserSignedIn = useAppSelector(isAuthenticated) + const { currentData: currentUser } = useUsersGetWithWalletsV1Query(undefined, { skip: !isUserSignedIn }) + const { currentData: spaces } = useSpacesGetV1Query(undefined, { skip: !isUserSignedIn }) + const selectedSpace = spaces?.find((space) => space.id === Number(spaceId)) + + const nonDeclinedSpaces = getNonDeclinedSpaces(currentUser, spaces || []) + + const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { + setAnchorEl(event.currentTarget) + } + + const handleClose = () => { + setAnchorEl(null) + } + + const handleSelectSpace = (space: GetSpaceResponse) => { + router.push({ + pathname: router.pathname, + query: { ...router.query, spaceId: space.id.toString() }, + }) + + handleClose() + } + + if (!selectedSpace) return null + + return ( + <> + <Box display="flex" width="100%"> + <Button + data-testid="space-selector-button" + id="space-selector-button" + onClick={handleClick} + endIcon={ + <ExpandMoreIcon + className={css.expandIcon} + sx={{ + transform: open ? 'rotate(180deg)' : undefined, + color: 'border.main', + }} + /> + } + fullWidth + className={css.spaceSelectorButton} + > + <Box display="flex" alignItems="center" gap={1}> + <InitialsAvatar name={selectedSpace.name} size="small" /> + <Typography + variant="body2" + fontWeight="bold" + noWrap + color="text.primary" + sx={{ maxWidth: '140px', textOverflow: 'ellipsis', overflow: 'hidden' }} + > + {selectedSpace.name} + </Typography> + </Box> + </Button> + + <Menu + data-testid="space-selector-menu" + id="space-selector-menu" + anchorEl={anchorEl} + open={open} + onClose={handleClose} + sx={{ '& .MuiPaper-root': { minWidth: '260px !important' } }} + > + <SpaceCard space={selectedSpace} isCompact isLink={false} /> + + <Divider sx={{ mb: 1 }} /> + + {nonDeclinedSpaces.map((space) => ( + <MenuItem + key={space.id} + onClick={() => handleSelectSpace(space)} + selected={space.id === selectedSpace.id} + sx={{ + display: 'flex', + justifyContent: 'space-between', + gap: 1, + }} + > + <Box display="flex" alignItems="center" gap={1}> + <InitialsAvatar name={space.name} size="small" /> + <Typography variant="body2">{space.name}</Typography> + </Box> + {space.id === selectedSpace.id && <CheckIcon fontSize="small" color="primary" />} + </MenuItem> + ))} + + <Divider /> + + <MenuItem + onClick={() => { + handleClose() + setIsCreationModalOpen(true) + trackEvent({ ...SPACE_EVENTS.CREATE_SPACE_MODAL, label: SPACE_LABELS.space_selector }) + }} + sx={{ fontWeight: 700 }} + > + Create space + </MenuItem> + + <MenuItem + onClick={() => { + handleClose() + trackEvent({ ...SPACE_EVENTS.OPEN_SPACE_LIST_PAGE, label: SPACE_LABELS.space_selector }) + router.push(AppRoutes.welcome.spaces) + }} + sx={{ fontWeight: 700 }} + > + View spaces + </MenuItem> + </Menu> + </Box> + + {isCreationModalOpen && <SpaceCreationModal onClose={() => setIsCreationModalOpen(false)} />} + </> + ) +} + +export default SpaceSidebarSelector diff --git a/apps/web/src/features/spaces/components/SpaceSidebarSelector/styles.module.css b/apps/web/src/features/spaces/components/SpaceSidebarSelector/styles.module.css new file mode 100644 index 0000000000..9d91186e82 --- /dev/null +++ b/apps/web/src/features/spaces/components/SpaceSidebarSelector/styles.module.css @@ -0,0 +1,12 @@ +.spaceSelectorButton { + justify-content: space-between; + text-align: left; + padding: var(--space-1); + margin: var(--space-1); + border: 1px solid var(--color-border-light); + font-size: 14px; +} + +.spaceSelectorButton:hover { + border: 1px solid var(--color-border-light); +} diff --git a/apps/web/src/features/spaces/components/SpacesList/index.tsx b/apps/web/src/features/spaces/components/SpacesList/index.tsx new file mode 100644 index 0000000000..0f4de4eced --- /dev/null +++ b/apps/web/src/features/spaces/components/SpacesList/index.tsx @@ -0,0 +1,145 @@ +import AccountsNavigation from '@/features/myAccounts/components/AccountsNavigation' +import SpaceCard from 'src/features/spaces/components/SpaceCard' +import SpaceCreationModal from '@/features/spaces/components/SpaceCreationModal' +import SignInButton from '@/features/spaces/components/SignInButton' +import SpacesIcon from '@/public/images/spaces/spaces.svg' +import { useAppSelector } from '@/store' +import { isAuthenticated } from '@/store/authSlice' +import { Box, Button, Card, Grid2, Link, Typography } from '@mui/material' +import { type GetSpaceResponse, useSpacesGetV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useUsersGetWithWalletsV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/users' +import SpaceListInvite from '../InviteBanner' +import { useState } from 'react' +import css from './styles.module.css' +import { MemberStatus } from '@/features/spaces/hooks/useSpaceMembers' +import useWallet from '@/hooks/wallets/useWallet' +import { SPACE_EVENTS, SPACE_LABELS } from '@/services/analytics/events/spaces' +import Track from '@/components/common/Track' +import SpaceInfoModal from '../SpaceInfoModal' +import { filterSpacesByStatus } from '@/features/spaces/utils' + +const AddSpaceButton = () => { + const [openCreationModal, setOpenCreationModal] = useState<boolean>(false) + + return ( + <> + <Button + data-testid="create-space-button" + disableElevation + variant="contained" + size="small" + onClick={() => setOpenCreationModal(true)} + sx={{ height: '36px' }} + > + <Box mt="1px">Create space</Box> + </Button> + {openCreationModal && <SpaceCreationModal onClose={() => setOpenCreationModal(false)} />} + </> + ) +} + +const SignedOutState = () => { + const wallet = useWallet() + const [isInfoOpen, setIsInfoOpen] = useState<boolean>(false) + + return ( + <> + <Card sx={{ p: 5, textAlign: 'center' }}> + <SpacesIcon /> + + <Box mb={3}> + <Typography color="text.secondary" mb={1}> + To view your space or create one,{' '} + {!!wallet ? 'sign in with your connected wallet.' : 'connect your wallet.'} + <br /> + </Typography> + <Link onClick={() => setIsInfoOpen(true)} href="#"> + What are spaces? + </Link> + </Box> + + <SignInButton /> + </Card> + {isInfoOpen && <SpaceInfoModal onClose={() => setIsInfoOpen(false)} showButtons={false} />} + </> + ) +} + +const NoSpacesState = () => { + const [isInfoOpen, setIsInfoOpen] = useState<boolean>(false) + const [openCreationModal, setOpenCreationModal] = useState<boolean>(false) + + return ( + <> + <Card sx={{ p: 5, textAlign: 'center', width: 1 }}> + <SpacesIcon /> + + <Box mb={3}> + <Typography color="text.secondary" mb={1}> + No spaces found. + <br /> + </Typography> + <Link onClick={() => setIsInfoOpen(true)} href="#"> + What are spaces? + </Link> + </Box> + <Track {...SPACE_EVENTS.CREATE_SPACE_MODAL} label={SPACE_LABELS.space_list_page}> + <AddSpaceButton /> + </Track> + </Card> + {isInfoOpen && ( + <SpaceInfoModal onCreateSpace={() => setOpenCreationModal(true)} onClose={() => setIsInfoOpen(false)} /> + )} + {openCreationModal && <SpaceCreationModal onClose={() => setOpenCreationModal(false)} />} + </> + ) +} + +const SpacesList = () => { + const isUserSignedIn = useAppSelector(isAuthenticated) + const { currentData: currentUser } = useUsersGetWithWalletsV1Query(undefined, { skip: !isUserSignedIn }) + const { currentData: spaces } = useSpacesGetV1Query(undefined, { skip: !isUserSignedIn }) + + const pendingInvites = filterSpacesByStatus(currentUser, spaces || [], MemberStatus.INVITED) + const activeSpaces = filterSpacesByStatus(currentUser, spaces || [], MemberStatus.ACTIVE) + + return ( + <Box className={css.container}> + <Box className={css.mySpaces}> + <Box className={css.spacesHeader}> + <AccountsNavigation /> + + {isUserSignedIn && activeSpaces.length > 0 && ( + <Track {...SPACE_EVENTS.CREATE_SPACE_MODAL} label={SPACE_LABELS.space_list_page}> + <AddSpaceButton /> + </Track> + )} + </Box> + + {isUserSignedIn && + pendingInvites.length > 0 && + pendingInvites.map((invitingSpace: GetSpaceResponse) => ( + <SpaceListInvite key={invitingSpace.id} space={invitingSpace} /> + ))} + + {isUserSignedIn ? ( + <Grid2 container spacing={2} flexWrap="wrap"> + {activeSpaces.length > 0 ? ( + activeSpaces.map((space) => ( + <Grid2 size={{ xs: 12, md: 6 }} key={space.name}> + <SpaceCard space={space} /> + </Grid2> + )) + ) : ( + <NoSpacesState /> + )} + </Grid2> + ) : ( + <SignedOutState /> + )} + </Box> + </Box> + ) +} + +export default SpacesList diff --git a/apps/web/src/features/spaces/components/SpacesList/styles.module.css b/apps/web/src/features/spaces/components/SpacesList/styles.module.css new file mode 100644 index 0000000000..a72cb28e8f --- /dev/null +++ b/apps/web/src/features/spaces/components/SpacesList/styles.module.css @@ -0,0 +1,20 @@ +.container { + container-type: inline-size; + container-name: my-spaces-container; + display: flex; + justify-content: center; +} + +.mySpaces { + width: 100vw; + max-width: 750px; + margin: var(--space-2); +} + +.spacesHeader { + display: flex; + justify-content: space-between; + padding: var(--space-3) 0; + gap: var(--space-2); + flex-wrap: wrap; +} diff --git a/apps/web/src/features/spaces/components/UnauthorizedState/index.tsx b/apps/web/src/features/spaces/components/UnauthorizedState/index.tsx new file mode 100644 index 0000000000..296c5af914 --- /dev/null +++ b/apps/web/src/features/spaces/components/UnauthorizedState/index.tsx @@ -0,0 +1,30 @@ +import { Box, Typography } from '@mui/material' +import css from '@/features/spaces/components/Dashboard/styles.module.css' +import Button from '@mui/material/Button' +import Link from 'next/link' +import { AppRoutes } from '@/config/routes' + +const UnauthorizedState = () => { + return ( + <Box className={css.content}> + <Box textAlign="center" className={css.contentWrapper}> + <Box className={css.contentInner}> + <Typography fontWeight={700} mb={2}> + You don’t have permissions to this page + </Typography> + + <Typography color="text.secondary" mb={2}> + Sorry, you don’t have permissions to view this page, as your wallet is not a member of the space. Try to + sign in with a different wallet or go back to the overview. + </Typography> + + <Link href={AppRoutes.welcome.spaces} passHref> + <Button variant="outlined">Back to overview</Button> + </Link> + </Box> + </Box> + </Box> + ) +} + +export default UnauthorizedState diff --git a/apps/web/src/features/spaces/components/UserSettings/index.tsx b/apps/web/src/features/spaces/components/UserSettings/index.tsx new file mode 100644 index 0000000000..584d0806da --- /dev/null +++ b/apps/web/src/features/spaces/components/UserSettings/index.tsx @@ -0,0 +1,88 @@ +import { Box, Typography, Card, Stack, Button, SvgIcon, Tooltip } from '@mui/material' +import { useAppSelector } from '@/store' +import { isAuthenticated } from '@/store/authSlice' +import { useUsersGetWithWalletsV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/users' +import EthHashInfo from '@/components/common/EthHashInfo' +import LinkIcon from '@/public/images/messages/link.svg' +import css from './styles.module.css' +import InfoBox from '@/components/safe-messages/InfoBox' +import SignedOutState from '@/features/spaces/components/SignedOutState' + +const UserSettings = () => { + const isUserSignedIn = useAppSelector(isAuthenticated) + const { currentData: user } = useUsersGetWithWalletsV1Query(undefined, { skip: !isUserSignedIn }) + + if (!isUserSignedIn) return <SignedOutState /> + + return ( + <Box className={css.container}> + <Box className={css.userSettings}> + <Typography variant="h1" mb={3} align="center"> + Manage Wallets + </Typography> + + <Card sx={{ p: 4 }}> + <Stack spacing={2}> + <Typography variant="h3" fontWeight="bold"> + Linked wallets + </Typography> + <Typography variant="body1"> + A linked wallet allows you to sign in to your Safe Spaces while keeping all your data, such as account + names and team members, consistent across all linked wallets. + </Typography> + + <Box> + {user?.wallets.map((wallet) => ( + <Stack + direction="row" + spacing={2} + key={wallet.address} + alignItems="center" + justifyContent="space-between" + > + <EthHashInfo shortAddress={false} address={wallet.address} showCopyButton hasExplorer /> + </Stack> + ))} + </Box> + <Tooltip title="Coming soon"> + <Typography component="span" sx={{ alignSelf: 'flex-start' }}> + <Button + startIcon={<SvgIcon component={LinkIcon} inheritViewBox fontSize="medium" className={css.linkIcon} />} + variant="text" + color="primary" + sx={{ p: 1 }} + disabled + > + Link another wallet + </Button> + </Typography> + </Tooltip> + <InfoBox + title="How to link a wallet?" + message={ + <> + <div className={css.steps}> + {[ + 'Add an address to your profile and confirm with a signature.', + 'Sign in with the new address and confirm again', + 'Your wallet now shares the same profile data!', + ].map((stepText, index) => ( + <Typography key={index} className={css.step} variant="body1" display="flex" gap={1}> + <Box component="span" className={css.stepNumber}> + {index + 1} + </Box> + {stepText} + </Typography> + ))} + </div> + </> + } + ></InfoBox> + </Stack> + </Card> + </Box> + </Box> + ) +} + +export default UserSettings diff --git a/apps/web/src/features/spaces/components/UserSettings/styles.module.css b/apps/web/src/features/spaces/components/UserSettings/styles.module.css new file mode 100644 index 0000000000..ffc804a4b2 --- /dev/null +++ b/apps/web/src/features/spaces/components/UserSettings/styles.module.css @@ -0,0 +1,36 @@ +.container { + display: flex; + justify-content: center; +} + +.userSettings { + max-width: 750px; + margin-top: var(--space-3); +} + +.linkIcon { + width: 24px; + height: 24px; +} + +.step { + font-size: 14px; +} + +.stepNumber { + margin-top: 2px; + display: flex; + align-items: center; + justify-content: center; + background: #d7f6ff; + border-radius: 50%; + width: 16px; + height: 16px; + font-size: 12px; +} + +@media (max-width: 599.95px) { + .userSettings { + margin: var(--space-2) 0 0 0; + } +} diff --git a/apps/web/src/features/spaces/hooks/useCurrentSpaceId.ts b/apps/web/src/features/spaces/hooks/useCurrentSpaceId.ts new file mode 100644 index 0000000000..75399e4c96 --- /dev/null +++ b/apps/web/src/features/spaces/hooks/useCurrentSpaceId.ts @@ -0,0 +1,6 @@ +import { useAppSelector } from '@/store' +import { lastUsedSpace } from '@/store/authSlice' + +export const useCurrentSpaceId = () => { + return useAppSelector(lastUsedSpace) +} diff --git a/apps/web/src/features/spaces/hooks/useFeatureFlagRedirect.ts b/apps/web/src/features/spaces/hooks/useFeatureFlagRedirect.ts new file mode 100644 index 0000000000..8e3be3449b --- /dev/null +++ b/apps/web/src/features/spaces/hooks/useFeatureFlagRedirect.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react' +import { AppRoutes } from '@/config/routes' +import { useRouter } from 'next/router' +import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' + +const useFeatureFlagRedirect = () => { + const router = useRouter() + const isSpacesFeatureEnabled = useHasFeature(FEATURES.SPACES) + + useEffect(() => { + if (isSpacesFeatureEnabled === false) { + router.push({ pathname: AppRoutes.welcome.accounts }) + } + }, [isSpacesFeatureEnabled, router]) +} + +export default useFeatureFlagRedirect diff --git a/apps/web/src/features/spaces/hooks/useIsLastActiveAdmin.ts b/apps/web/src/features/spaces/hooks/useIsLastActiveAdmin.ts new file mode 100644 index 0000000000..c82904214f --- /dev/null +++ b/apps/web/src/features/spaces/hooks/useIsLastActiveAdmin.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react' +import { isActiveAdmin, isAdmin, useSpaceMembersByStatus } from './useSpaceMembers' +import type { Member } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useCurrentMembership } from './useSpaceMembers' + +export const useAdminCount = (members?: Member[]) => { + const { activeMembers } = useSpaceMembersByStatus() + const membersToUse = members ?? activeMembers + return useMemo(() => membersToUse.filter(isAdmin).length, [membersToUse]) +} + +export const useIsLastActiveAdmin = () => { + const adminCount = useAdminCount() + const currentMembership = useCurrentMembership() + + return adminCount === 1 && !!currentMembership && isActiveAdmin(currentMembership) +} diff --git a/apps/web/src/features/spaces/hooks/useMembersSearch.ts b/apps/web/src/features/spaces/hooks/useMembersSearch.ts new file mode 100644 index 0000000000..e027fc9b17 --- /dev/null +++ b/apps/web/src/features/spaces/hooks/useMembersSearch.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react' +import Fuse from 'fuse.js' +import type { Member } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' + +const useMembersSearch = (members: Member[], query: string): Member[] => { + const fuse = useMemo( + () => + new Fuse(members, { + keys: [{ name: 'name' }], + threshold: 0.2, + findAllMatches: true, + ignoreLocation: true, + }), + [members], + ) + + return useMemo(() => (query ? fuse.search(query).map((result) => result.item) : members), [fuse, query, members]) +} + +export { useMembersSearch } diff --git a/apps/web/src/features/spaces/hooks/useSpaceMembers.tsx b/apps/web/src/features/spaces/hooks/useSpaceMembers.tsx new file mode 100644 index 0000000000..7b88d5e7dc --- /dev/null +++ b/apps/web/src/features/spaces/hooks/useSpaceMembers.tsx @@ -0,0 +1,60 @@ +import { useMembersGetUsersV1Query, type Member } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useCurrentSpaceId } from 'src/features/spaces/hooks/useCurrentSpaceId' +import { useAppSelector } from '@/store' +import { isAuthenticated } from '@/store/authSlice' +import { useUsersGetWithWalletsV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/users' + +export enum MemberStatus { + INVITED = 'INVITED', + ACTIVE = 'ACTIVE', + DECLINED = 'DECLINED', +} + +export enum MemberRole { + ADMIN = 'ADMIN', + MEMBER = 'MEMBER', +} + +export const isAdmin = (member: Member) => member.role === MemberRole.ADMIN + +export const isActiveAdmin = (member: Member) => isAdmin(member) && member.status === MemberStatus.ACTIVE + +const useAllMembers = (spaceId?: number) => { + const currentSpaceId = useCurrentSpaceId() + const actualSpaceId = spaceId ?? currentSpaceId + const isUserSignedIn = useAppSelector(isAuthenticated) + const { data: currentData } = useMembersGetUsersV1Query({ spaceId: Number(actualSpaceId) }, { skip: !isUserSignedIn }) + return currentData?.members || [] +} + +export const useSpaceMembersByStatus = () => { + const allMembers = useAllMembers() + + const invitedMembers = allMembers.filter( + (member) => member.status === MemberStatus.INVITED || member.status === MemberStatus.DECLINED, + ) + const activeMembers = allMembers.filter((member) => member.status === MemberStatus.ACTIVE) + + return { activeMembers, invitedMembers } +} + +export const useCurrentMembership = (spaceId?: number) => { + const allMembers = useAllMembers(spaceId) + const { currentData: user } = useUsersGetWithWalletsV1Query() + return allMembers.find((member) => member.user.id === user?.id) +} + +export const useIsActiceMember = (spaceId?: number) => { + const currentMembership = useCurrentMembership(spaceId) + return !!currentMembership && currentMembership.status === MemberStatus.ACTIVE +} + +export const useIsAdmin = (spaceId?: number) => { + const currentMembership = useCurrentMembership(spaceId) + return !!currentMembership && isActiveAdmin(currentMembership) +} + +export const useIsInvited = () => { + const currentMembership = useCurrentMembership() + return currentMembership?.status === MemberStatus.INVITED +} diff --git a/apps/web/src/features/spaces/hooks/useSpaceSafeCount.tsx b/apps/web/src/features/spaces/hooks/useSpaceSafeCount.tsx new file mode 100644 index 0000000000..24443d7ebe --- /dev/null +++ b/apps/web/src/features/spaces/hooks/useSpaceSafeCount.tsx @@ -0,0 +1,11 @@ +import { useAppSelector } from '@/store' +import { isAuthenticated } from '@/store/authSlice' +import { useSpaceSafesGetV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' + +export const useSpaceSafeCount = (spaceId: number): number => { + const isUserSignedIn = useAppSelector(isAuthenticated) + const { currentData } = useSpaceSafesGetV1Query({ spaceId }, { skip: !isUserSignedIn }) + const safes = currentData?.safes || {} + + return Object.values(safes).reduce((acc, safesOnChain) => acc + safesOnChain.length, 0) +} diff --git a/apps/web/src/features/spaces/hooks/useSpaceSafes.tsx b/apps/web/src/features/spaces/hooks/useSpaceSafes.tsx new file mode 100644 index 0000000000..fc23addeca --- /dev/null +++ b/apps/web/src/features/spaces/hooks/useSpaceSafes.tsx @@ -0,0 +1,33 @@ +import { useSpaceSafesGetV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useCurrentSpaceId } from 'src/features/spaces/hooks/useCurrentSpaceId' +import { _buildSafeItems, type AllSafeItems } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import { useAllSafesGrouped } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import { useAppSelector } from '@/store' +import { selectOrderByPreference } from '@/store/orderByPreferenceSlice' +import { getComparator } from '@/features/myAccounts/utils/utils' +import { useMemo } from 'react' +import { selectAllAddressBooks } from '@/store/addressBookSlice' +import { isAuthenticated } from '@/store/authSlice' +import useAllOwnedSafes from '@/features/myAccounts/hooks/useAllOwnedSafes' +import useWallet from '@/hooks/wallets/useWallet' + +export const useSpaceSafes = () => { + const spaceId = useCurrentSpaceId() + const isUserSignedIn = useAppSelector(isAuthenticated) + const { currentData, isLoading } = useSpaceSafesGetV1Query({ spaceId: Number(spaceId) }, { skip: !isUserSignedIn }) + + const allSafeNames = useAppSelector(selectAllAddressBooks) + const { address: walletAddress = '' } = useWallet() || {} + const [allOwned = {}] = useAllOwnedSafes(walletAddress) + const safeItems = currentData ? _buildSafeItems(currentData.safes, allSafeNames, allOwned) : [] + const safes = useAllSafesGrouped(safeItems) + const { orderBy } = useAppSelector(selectOrderByPreference) + const sortComparator = getComparator(orderBy) + + const allSafes = useMemo<AllSafeItems>( + () => [...(safes.allMultiChainSafes ?? []), ...(safes.allSingleSafes ?? [])].sort(sortComparator), + [safes.allMultiChainSafes, safes.allSingleSafes, sortComparator], + ) + + return { allSafes, isLoading } +} diff --git a/apps/web/src/features/spaces/hooks/useTrackSpace.ts b/apps/web/src/features/spaces/hooks/useTrackSpace.ts new file mode 100644 index 0000000000..bcf77b0fe2 --- /dev/null +++ b/apps/web/src/features/spaces/hooks/useTrackSpace.ts @@ -0,0 +1,26 @@ +import type { AllSafeItems } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import type { Member } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { useEffect } from 'react' +import { trackEvent } from '@/services/analytics' +import { SPACE_EVENTS } from '@/services/analytics/events/spaces' + +let isTotalSafesTracked = false +let isTotalMembersTracked = false + +const useTrackSpace = (safes: AllSafeItems, activeMembers: Member[]) => { + useEffect(() => { + if (isTotalSafesTracked) return + + trackEvent({ ...SPACE_EVENTS.TOTAL_SAFE_ACCOUNTS, label: safes.length }) + isTotalSafesTracked = true + }, [safes.length]) + + useEffect(() => { + if (isTotalMembersTracked) return + + trackEvent({ ...SPACE_EVENTS.TOTAL_ACTIVE_MEMBERS, label: activeMembers.length }) + isTotalMembersTracked = true + }, [activeMembers.length]) +} + +export default useTrackSpace diff --git a/apps/web/src/features/spaces/utils.ts b/apps/web/src/features/spaces/utils.ts new file mode 100644 index 0000000000..bdd9a1bedc --- /dev/null +++ b/apps/web/src/features/spaces/utils.ts @@ -0,0 +1,27 @@ +import type { FetchBaseQueryError } from '@reduxjs/toolkit/query' +import type { SerializedError } from '@reduxjs/toolkit' +import type { UserWithWallets } from '@safe-global/store/gateway/AUTO_GENERATED/users' +import type { GetSpaceResponse } from '@safe-global/store/gateway/AUTO_GENERATED/spaces' +import { MemberStatus } from '@/features/spaces/hooks/useSpaceMembers' + +// TODO: Currently also checks for 404 because the /v1/spaces/<orgId> endpoint does not return 401 +export const isUnauthorized = (error: FetchBaseQueryError | SerializedError | undefined) => { + return error && 'status' in error && (error.status === 401 || error.status === 404) +} + +export const filterSpacesByStatus = ( + currentUser: UserWithWallets | undefined, + spaces: GetSpaceResponse[], + status: MemberStatus, +) => { + return spaces.filter((space) => { + return space.members.some((member) => member.user.id === currentUser?.id && member.status === status) + }) +} + +export const getNonDeclinedSpaces = (currentUser: UserWithWallets | undefined, spaces: GetSpaceResponse[]) => { + const pendingInvites = filterSpacesByStatus(currentUser, spaces || [], MemberStatus.INVITED) + const activeSpaces = filterSpacesByStatus(currentUser, spaces || [], MemberStatus.ACTIVE) + + return [...pendingInvites, ...activeSpaces] +} diff --git a/apps/web/src/features/speedup/components/SpeedUpModal.tsx b/apps/web/src/features/speedup/components/SpeedUpModal.tsx index d43146fea4..d7288e96c2 100644 --- a/apps/web/src/features/speedup/components/SpeedUpModal.tsx +++ b/apps/web/src/features/speedup/components/SpeedUpModal.tsx @@ -12,23 +12,23 @@ import { createExistingTx, dispatchCustomTxSpeedUp, dispatchSafeTxSpeedUp } from import { showNotification } from '@/store/notificationsSlice' import { useCallback, useState } from 'react' import GasParams from '@/components/tx/GasParams' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { getTxOptions } from '@/utils/transactions' import { useCurrentChain, useHasFeature } from '@/hooks/useChains' import { SimpleTxWatcher } from '@/utils/SimpleTxWatcher' -import { FEATURES } from '@/utils/chains' import { isWalletRejection } from '@/utils/wallets' -import { type TransactionOptions } from '@safe-global/safe-core-sdk-types' +import { type TransactionOptions } from '@safe-global/types-kit' import { PendingTxType, type PendingProcessingTx } from '@/store/pendingTxsSlice' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { MODALS_EVENTS, trackEvent } from '@/services/analytics' import { TX_EVENTS } from '@/services/analytics/events/transactions' import { getTransactionTrackingType } from '@/services/analytics/tx-tracking' import { trackError } from '@/services/exceptions' -import ErrorCodes from '@/services/exceptions/ErrorCodes' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' import CheckWallet from '@/components/common/CheckWallet' import { useLazyGetTransactionDetailsQuery } from '@/store/api/gateway' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' +import { FEATURES } from '@safe-global/utils/utils/chains' type Props = { open: boolean @@ -62,12 +62,10 @@ export const SpeedUpModal = ({ const dispatch = useAppDispatch() const [trigger] = useLazyGetTransactionDetailsQuery() const isDisabled = waitingForConfirmation || !wallet || !speedUpFee || !onboard - const [safeTx] = useAsync(async () => { - if (!chainInfo?.chainId || !safeAddress) { - return null - } - return createExistingTx(chainInfo.chainId, safeAddress, txId) - }, [txId, chainInfo?.chainId, safeAddress]) + const [safeTx] = useAsync(() => { + if (!chainInfo?.chainId) return + return createExistingTx(chainInfo.chainId, txId) + }, [txId, chainInfo?.chainId]) const safeTxHasSignatures = !!safeTx?.signatures?.size ? true : false diff --git a/apps/web/src/features/speedup/components/SpeedUpMonitor.tsx b/apps/web/src/features/speedup/components/SpeedUpMonitor.tsx index 04bd2ce311..7e505c0062 100644 --- a/apps/web/src/features/speedup/components/SpeedUpMonitor.tsx +++ b/apps/web/src/features/speedup/components/SpeedUpMonitor.tsx @@ -5,14 +5,15 @@ import { useCounter } from '@/components/common/Notifications/useCounter' import type { MouseEventHandler } from 'react' import { useState } from 'react' import type { PendingProcessingTx } from '@/store/pendingTxsSlice' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { isSmartContract } from '@/utils/wallets' import useWallet from '@/hooks/wallets/useWallet' import { isSpeedableTx } from '@/features/speedup/utils/IsSpeedableTx' import { MODALS_EVENTS, trackEvent } from '@/services/analytics' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' + +import { FEATURES } from '@safe-global/utils/utils/chains' type SpeedUpMonitorProps = { txId: string diff --git a/apps/web/src/features/speedup/hooks/useSafeTransaction.tsx b/apps/web/src/features/speedup/hooks/useSafeTransaction.tsx index cb48de44c5..360e0e4a65 100644 --- a/apps/web/src/features/speedup/hooks/useSafeTransaction.tsx +++ b/apps/web/src/features/speedup/hooks/useSafeTransaction.tsx @@ -1,22 +1,20 @@ import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' import useChainId from '@/hooks/useChainId' -import useSafeAddress from '@/hooks/useSafeAddress' import { useEffect, useState } from 'react' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction } from '@safe-global/types-kit' import { createExistingTx } from '@/services/tx/tx-sender' export const useSafeTransaction = (txId: string) => { const safeSdk = useSafeSDK() const chainId = useChainId() - const safeAddress = useSafeAddress() const [safeTx, setSafeTx] = useState<SafeTransaction>() useEffect(() => { if (!safeSdk) { return } - createExistingTx(chainId, safeAddress, txId).then(setSafeTx) - }, [chainId, safeAddress, txId, safeSdk]) + createExistingTx(chainId, txId).then(setSafeTx) + }, [chainId, txId, safeSdk]) return safeTx } diff --git a/apps/web/src/features/speedup/utils/IsSpeedableTx.tsx b/apps/web/src/features/speedup/utils/IsSpeedableTx.tsx index 683f33fb91..eb7c9ffcf8 100644 --- a/apps/web/src/features/speedup/utils/IsSpeedableTx.tsx +++ b/apps/web/src/features/speedup/utils/IsSpeedableTx.tsx @@ -1,5 +1,5 @@ import { type PendingProcessingTx, PendingStatus, type PendingTx } from '@/store/pendingTxsSlice' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' export const isSpeedableTx = ( pendingTx: PendingTx, diff --git a/apps/web/src/features/stake/components/StakeButton/index.tsx b/apps/web/src/features/stake/components/StakeButton/index.tsx index efe0b38228..d5f2b90155 100644 --- a/apps/web/src/features/stake/components/StakeButton/index.tsx +++ b/apps/web/src/features/stake/components/StakeButton/index.tsx @@ -11,13 +11,17 @@ import StakeIcon from '@/public/images/common/stake.svg' import type { STAKE_LABELS } from '@/services/analytics/events/stake' import { STAKE_EVENTS } from '@/services/analytics/events/stake' import { useCurrentChain } from '@/hooks/useChains' +import css from './styles.module.css' +import classnames from 'classnames' const StakeButton = ({ tokenInfo, trackingLabel, + compact = true, }: { tokenInfo: TokenInfo trackingLabel: STAKE_LABELS + compact?: boolean }): ReactElement => { const spendingLimit = useSpendingLimit(tokenInfo) const chain = useCurrentChain() @@ -28,11 +32,13 @@ const StakeButton = ({ {(isOk) => ( <Track {...STAKE_EVENTS.OPEN_STAKE} label={trackingLabel}> <Button + className={classnames({ [css.button]: compact, [css.buttonDisabled]: !isOk })} data-testid="stake-btn" aria-label="Stake" - variant="text" - color="info" - size="small" + variant={compact ? 'text' : 'contained'} + color={compact ? 'info' : 'background.paper'} + size={compact ? 'small' : 'compact'} + disableElevation startIcon={<StakeIcon />} onClick={() => { router.push({ diff --git a/apps/web/src/features/stake/components/StakeButton/styles.module.css b/apps/web/src/features/stake/components/StakeButton/styles.module.css new file mode 100644 index 0000000000..1f558c9960 --- /dev/null +++ b/apps/web/src/features/stake/components/StakeButton/styles.module.css @@ -0,0 +1,16 @@ +.button { + padding: 0 4px; + border: 1px solid var(--color-info-light); + color: var(--color-info-dark); + border-radius: 100px; + font-size: 13px; +} + +.button :global .MuiButton-startIcon { + margin-left: 0; + margin-right: 4px; +} + +.buttonDisabled { + border-color: var(--color-text-disabled); +} diff --git a/apps/web/src/features/stake/components/StakePage/index.tsx b/apps/web/src/features/stake/components/StakePage/index.tsx index 5de1c02306..a780db079b 100644 --- a/apps/web/src/features/stake/components/StakePage/index.tsx +++ b/apps/web/src/features/stake/components/StakePage/index.tsx @@ -1,32 +1,19 @@ import { Stack } from '@mui/material' import Disclaimer from '@/components/common/Disclaimer' import WidgetDisclaimer from '@/components/common/WidgetDisclaimer' -import useStakeConsent from '@/features/stake/useStakeConsent' import StakingWidget from '../StakingWidget' import { useRouter } from 'next/router' -import { useGetIsSanctionedQuery } from '@/store/api/ofac' -import { skipToken } from '@reduxjs/toolkit/query/react' -import useWallet from '@/hooks/wallets/useWallet' -import useSafeInfo from '@/hooks/useSafeInfo' -import { getKeyWithTrueValue } from '@/utils/helpers' import BlockedAddress from '@/components/common/BlockedAddress' +import useBlockedAddress from '@/hooks/useBlockedAddress' +import useConsent from '@/hooks/useConsent' +import { STAKE_CONSENT_STORAGE_KEY } from '@/features/stake/constants' const StakePage = () => { - const { isConsentAccepted, onAccept } = useStakeConsent() + const { isConsentAccepted, onAccept } = useConsent(STAKE_CONSENT_STORAGE_KEY) const router = useRouter() const { asset } = router.query - const { safeAddress } = useSafeInfo() - const wallet = useWallet() - - const { data: isSafeAddressBlocked } = useGetIsSanctionedQuery(safeAddress || skipToken) - const { data: isWalletAddressBlocked } = useGetIsSanctionedQuery(wallet?.address || skipToken) - const blockedAddresses = { - [safeAddress]: !!isSafeAddressBlocked, - [wallet?.address || '']: !!isWalletAddressBlocked, - } - - const blockedAddress = getKeyWithTrueValue(blockedAddresses) + const blockedAddress = useBlockedAddress() if (blockedAddress) { return ( diff --git a/apps/web/src/features/stake/components/StakingConfirmationTx/Deposit.tsx b/apps/web/src/features/stake/components/StakingConfirmationTx/Deposit.tsx index aa0723349e..528c464a08 100644 --- a/apps/web/src/features/stake/components/StakingConfirmationTx/Deposit.tsx +++ b/apps/web/src/features/stake/components/StakingConfirmationTx/Deposit.tsx @@ -3,8 +3,8 @@ import FieldsGrid from '@/components/tx/FieldsGrid' import type { StakingTxDepositInfo } from '@safe-global/safe-gateway-typescript-sdk' import { type NativeStakingDepositConfirmationView } from '@safe-global/safe-gateway-typescript-sdk' import ConfirmationOrderHeader from '@/components/tx/ConfirmationOrder/ConfirmationOrderHeader' -import { formatDurationFromMilliseconds, formatVisualAmount, maybePlural } from '@/utils/formatters' -import { formatCurrency } from '@/utils/formatNumber' +import { formatDurationFromMilliseconds, formatVisualAmount, maybePlural } from '@safe-global/utils/utils/formatters' +import { formatCurrency } from '@safe-global/utils/utils/formatNumber' import StakingStatus from '@/features/stake/components/StakingStatus' import { InfoTooltip } from '@/features/stake/components/InfoTooltip' import { BRAND_NAME } from '@/config/constants' diff --git a/apps/web/src/features/stake/components/StakingConfirmationTx/Exit.tsx b/apps/web/src/features/stake/components/StakingConfirmationTx/Exit.tsx index a6b75e5a04..1b7b7eb6f1 100644 --- a/apps/web/src/features/stake/components/StakingConfirmationTx/Exit.tsx +++ b/apps/web/src/features/stake/components/StakingConfirmationTx/Exit.tsx @@ -1,6 +1,6 @@ import { Alert, Stack, Typography } from '@mui/material' import FieldsGrid from '@/components/tx/FieldsGrid' -import { formatDurationFromMilliseconds } from '@/utils/formatters' +import { formatDurationFromMilliseconds } from '@safe-global/utils/utils/formatters' import ConfirmationOrderHeader from '@/components/tx/ConfirmationOrder/ConfirmationOrderHeader' import { InfoTooltip } from '@/features/stake/components/InfoTooltip' import type { StakingTxExitInfo } from '@safe-global/safe-gateway-typescript-sdk' diff --git a/apps/web/src/features/stake/components/StakingTxExitDetails/index.tsx b/apps/web/src/features/stake/components/StakingTxExitDetails/index.tsx index d4cdb80722..b30e1de10c 100644 --- a/apps/web/src/features/stake/components/StakingTxExitDetails/index.tsx +++ b/apps/web/src/features/stake/components/StakingTxExitDetails/index.tsx @@ -3,7 +3,7 @@ import type { StakingTxExitInfo } from '@safe-global/safe-gateway-typescript-sdk import { NativeStakingStatus } from '@safe-global/safe-gateway-typescript-sdk' import FieldsGrid from '@/components/tx/FieldsGrid' import StakingStatus from '@/features/stake/components/StakingStatus' -import { formatDurationFromMilliseconds } from '@/utils/formatters' +import { formatDurationFromMilliseconds } from '@safe-global/utils/utils/formatters' import { BEACON_CHAIN_EXPLORERS } from '@/features/stake/constants' import useChainId from '@/hooks/useChainId' diff --git a/apps/web/src/features/stake/components/StakingTxExitInfo/index.tsx b/apps/web/src/features/stake/components/StakingTxExitInfo/index.tsx index 446c8abf73..d1dd98a57a 100644 --- a/apps/web/src/features/stake/components/StakingTxExitInfo/index.tsx +++ b/apps/web/src/features/stake/components/StakingTxExitInfo/index.tsx @@ -1,4 +1,4 @@ -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' import type { StakingTxExitInfo } from '@safe-global/safe-gateway-typescript-sdk' const StakingTxExitInfo = ({ info }: { info: StakingTxExitInfo }) => { diff --git a/apps/web/src/features/stake/components/StakingWidget/index.tsx b/apps/web/src/features/stake/components/StakingWidget/index.tsx index e06c911b03..1bf19f976a 100644 --- a/apps/web/src/features/stake/components/StakingWidget/index.tsx +++ b/apps/web/src/features/stake/components/StakingWidget/index.tsx @@ -3,17 +3,20 @@ import AppFrame from '@/components/safe-apps/AppFrame' import { getEmptySafeApp } from '@/components/safe-apps/utils' import { useGetStakeWidgetUrl } from '@/features/stake/hooks/useGetStakeWidgetUrl' import { widgetAppData } from '@/features/stake/constants' +import { useDarkMode } from '@/hooks/useDarkMode' const StakingWidget = ({ asset }: { asset?: string }) => { const url = useGetStakeWidgetUrl(asset) + const isDarkMode = useDarkMode() const appData = useMemo( () => ({ ...getEmptySafeApp(), ...widgetAppData, + iconUrl: isDarkMode ? '/images/common/stake-light.svg' : '/images/common/stake.svg', url, }), - [url], + [url, isDarkMode], ) return ( diff --git a/apps/web/src/features/stake/constants.ts b/apps/web/src/features/stake/constants.ts index f0efac2185..3e02db4fa8 100644 --- a/apps/web/src/features/stake/constants.ts +++ b/apps/web/src/features/stake/constants.ts @@ -1,12 +1,11 @@ export const STAKE_TITLE = 'Stake' - +export const STAKE_CONSENT_STORAGE_KEY = 'stakeDisclaimerAcceptedV1' export const WIDGET_PRODUCTION_URL = 'https://safe.widget.kiln.fi/overview' export const WIDGET_TESTNET_URL = 'https://safe.widget.testnet.kiln.fi/overview' export const widgetAppData = { url: WIDGET_PRODUCTION_URL, name: STAKE_TITLE, - iconUrl: '/images/common/stake.svg', chainIds: ['17000', '11155111', '1', '42161', '137', '56', '8453', '10'], } diff --git a/apps/web/src/features/stake/hooks/useIsStakingBannerEnabled.ts b/apps/web/src/features/stake/hooks/useIsStakingBannerEnabled.ts index 7c413e2350..e8c2cadbde 100644 --- a/apps/web/src/features/stake/hooks/useIsStakingBannerEnabled.ts +++ b/apps/web/src/features/stake/hooks/useIsStakingBannerEnabled.ts @@ -1,6 +1,6 @@ import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' -import useIsStakingFeatureEnabled from './useIsSwapFeatureEnabled' +import useIsStakingFeatureEnabled from './useIsStakingFeatureEnabled' +import { FEATURES } from '@safe-global/utils/utils/chains' const useIsStakingBannerEnabled = () => { const isStakingFeatureEnabled = useIsStakingFeatureEnabled() diff --git a/apps/web/src/features/stake/hooks/useIsSwapFeatureEnabled.ts b/apps/web/src/features/stake/hooks/useIsStakingFeatureEnabled.ts similarity index 86% rename from apps/web/src/features/stake/hooks/useIsSwapFeatureEnabled.ts rename to apps/web/src/features/stake/hooks/useIsStakingFeatureEnabled.ts index 964072036a..f55994b68d 100644 --- a/apps/web/src/features/stake/hooks/useIsSwapFeatureEnabled.ts +++ b/apps/web/src/features/stake/hooks/useIsStakingFeatureEnabled.ts @@ -1,7 +1,7 @@ import { GeoblockingContext } from '@/components/common/GeoblockingProvider' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' import { useContext } from 'react' +import { FEATURES } from '@safe-global/utils/utils/chains' const useIsStakingFeatureEnabled = () => { const isBlockedCountry = useContext(GeoblockingContext) diff --git a/apps/web/src/features/stake/useStakeConsent.ts b/apps/web/src/features/stake/useStakeConsent.ts deleted file mode 100644 index 4566126551..0000000000 --- a/apps/web/src/features/stake/useStakeConsent.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { localItem } from '@/services/local-storage/local' -import { useCallback, useEffect, useState } from 'react' - -const STAKE_CONSENT_STORAGE_KEY = 'stakeDisclaimerAcceptedV1' -const stakeConsentStorage = localItem<boolean>(STAKE_CONSENT_STORAGE_KEY) - -const useStakeConsent = (): { - isConsentAccepted: boolean | undefined - onAccept: () => void -} => { - const [isConsentAccepted, setIsConsentAccepted] = useState<boolean | undefined>() - - const onAccept = useCallback(() => { - setIsConsentAccepted(true) - stakeConsentStorage.set(true) - }, [setIsConsentAccepted]) - - useEffect(() => { - setIsConsentAccepted(stakeConsentStorage.get() || false) - }, [setIsConsentAccepted]) - - return { - isConsentAccepted, - onAccept, - } -} - -export default useStakeConsent diff --git a/apps/web/src/features/swap/components/FallbackSwapWidget/index.tsx b/apps/web/src/features/swap/components/FallbackSwapWidget/index.tsx new file mode 100644 index 0000000000..f2699a0ef0 --- /dev/null +++ b/apps/web/src/features/swap/components/FallbackSwapWidget/index.tsx @@ -0,0 +1,56 @@ +import { useMemo } from 'react' +import type { ReactElement } from 'react' + +import AppFrame from '@/components/safe-apps/AppFrame' +import { getEmptySafeApp } from '@/components/safe-apps/utils' +import { useCurrentChain } from '@/hooks/useChains' +import { useDarkMode } from '@/hooks/useDarkMode' +import type { SafeAppDataWithPermissions } from '@/components/safe-apps/types' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +export const SWAP_WIDGET_URL = 'https://iframe.jumper.exchange/swap' + +export function FallbackSwapWidget({ fromToken }: { fromToken?: string }): ReactElement | null { + const isDarkMode = useDarkMode() + const chain = useCurrentChain() + + const appData = useMemo((): SafeAppDataWithPermissions | null => { + if (!chain) { + return null + } + return _getAppData(isDarkMode, chain, fromToken) + }, [chain, isDarkMode, fromToken]) + + if (!appData) { + return null + } + + return ( + <AppFrame + appUrl={appData.url} + allowedFeaturesList="clipboard-read; clipboard-write" + safeAppFromManifest={appData} + isNativeEmbed + /> + ) +} + +export function _getAppData(isDarkMode: boolean, chain: ChainInfo, fromToken?: string): SafeAppDataWithPermissions { + const theme = isDarkMode ? 'dark' : 'light' + const appUrl = new URL(SWAP_WIDGET_URL) + appUrl.searchParams.set('theme', theme) + appUrl.searchParams.set('fromChain', chain.chainId) + if (fromToken) { + appUrl.searchParams.set('fromToken', fromToken) + } + + return { + ...getEmptySafeApp(), + name: 'Swap', + iconUrl: isDarkMode ? '/images/common/safe-swap-dark.svg' : '/images/common/safe-swap.svg', + chainIds: [chain.chainId], + url: appUrl.toString(), + } +} + +export default FallbackSwapWidget diff --git a/apps/web/src/features/swap/components/SwapButton/index.tsx b/apps/web/src/features/swap/components/SwapButton/index.tsx index 70610da8c8..aa7a6dc8fd 100644 --- a/apps/web/src/features/swap/components/SwapButton/index.tsx +++ b/apps/web/src/features/swap/components/SwapButton/index.tsx @@ -14,10 +14,12 @@ const SwapButton = ({ tokenInfo, amount, trackingLabel, + light = false, }: { tokenInfo: TokenInfo amount: string trackingLabel: SWAP_LABELS + light?: boolean }): ReactElement => { const spendingLimit = useSpendingLimit(tokenInfo) const router = useRouter() @@ -28,10 +30,11 @@ const SwapButton = ({ <Track {...SWAP_EVENTS.OPEN_SWAPS} label={trackingLabel}> <Button data-testid="swap-btn" - variant="outlined" - color="primary" - size="small" + variant="contained" + color={light ? 'background.paper' : 'primary'} + size="compact" startIcon={<SwapIcon />} + disableElevation onClick={() => { router.push({ pathname: AppRoutes.swap, diff --git a/apps/web/src/features/swap/components/SwapOrder/index.tsx b/apps/web/src/features/swap/components/SwapOrder/index.tsx index 1147c4993a..a24d0f0670 100644 --- a/apps/web/src/features/swap/components/SwapOrder/index.tsx +++ b/apps/web/src/features/swap/components/SwapOrder/index.tsx @@ -3,7 +3,7 @@ import OrderId from '@/features/swap/components/OrderId' import StatusLabel from '@/features/swap/components/StatusLabel' import SwapProgress from '@/features/swap/components/SwapProgress' import { capitalize } from '@/hooks/useMnemonicName' -import { formatDateTime, formatTimeInWords } from '@/utils/date' +import { formatDateTime, formatTimeInWords } from '@safe-global/utils/utils/date' import Stack from '@mui/material/Stack' import type { ReactElement } from 'react' import type { TwapOrder as SwapTwapOrder } from '@safe-global/safe-gateway-typescript-sdk' @@ -17,7 +17,7 @@ import { DataTable } from '@/components/common/Table/DataTable' import { compareAsc } from 'date-fns' import css from './styles.module.css' import { Typography } from '@mui/material' -import { formatAmount } from '@/utils/formatNumber' +import { formatAmount } from '@safe-global/utils/utils/formatNumber' import { getExecutionPrice, getLimitPrice, diff --git a/apps/web/src/features/swap/components/SwapOrder/rows/PartBuyAmount.tsx b/apps/web/src/features/swap/components/SwapOrder/rows/PartBuyAmount.tsx index 5076242c14..00b30f6510 100644 --- a/apps/web/src/features/swap/components/SwapOrder/rows/PartBuyAmount.tsx +++ b/apps/web/src/features/swap/components/SwapOrder/rows/PartBuyAmount.tsx @@ -1,5 +1,5 @@ import { Typography } from '@mui/material' -import { formatVisualAmount } from '@/utils/formatters' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' import { type TwapOrder } from '@safe-global/safe-gateway-typescript-sdk' import { DataRow } from '@/components/common/Table/DataRow' import { Box } from '@mui/system' diff --git a/apps/web/src/features/swap/components/SwapOrder/rows/PartDuration.tsx b/apps/web/src/features/swap/components/SwapOrder/rows/PartDuration.tsx index 1154b513fc..f5d0a09a47 100644 --- a/apps/web/src/features/swap/components/SwapOrder/rows/PartDuration.tsx +++ b/apps/web/src/features/swap/components/SwapOrder/rows/PartDuration.tsx @@ -1,6 +1,6 @@ import { DataRow } from '@/components/common/Table/DataRow' import { type TwapOrder } from '@safe-global/safe-gateway-typescript-sdk' -import { getPeriod } from '@/utils/date' +import { getPeriod } from '@safe-global/utils/utils/date' export const PartDuration = ({ order }: { order: Pick<TwapOrder, 'timeBetweenParts'> }) => { const { timeBetweenParts } = order diff --git a/apps/web/src/features/swap/components/SwapOrder/rows/PartSellAmount.tsx b/apps/web/src/features/swap/components/SwapOrder/rows/PartSellAmount.tsx index ac8fcf13b6..9087f5828e 100644 --- a/apps/web/src/features/swap/components/SwapOrder/rows/PartSellAmount.tsx +++ b/apps/web/src/features/swap/components/SwapOrder/rows/PartSellAmount.tsx @@ -1,5 +1,5 @@ import { Typography } from '@mui/material' -import { formatVisualAmount } from '@/utils/formatters' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' import { type TwapOrder } from '@safe-global/safe-gateway-typescript-sdk' import { DataRow } from '@/components/common/Table/DataRow' import { Box } from '@mui/system' diff --git a/apps/web/src/features/swap/components/SwapOrder/rows/SurplusFee.tsx b/apps/web/src/features/swap/components/SwapOrder/rows/SurplusFee.tsx index 6382794d66..9cd43176dd 100644 --- a/apps/web/src/features/swap/components/SwapOrder/rows/SurplusFee.tsx +++ b/apps/web/src/features/swap/components/SwapOrder/rows/SurplusFee.tsx @@ -1,7 +1,7 @@ import type { TwapOrder } from '@safe-global/safe-gateway-typescript-sdk' -import { getOrderFeeBps } from '@/features/swap/helpers/utils' +import { getOrderFeeBps } from '@safe-global/utils/features/swap/helpers/utils' import { DataRow } from '@/components/common/Table/DataRow' -import { formatVisualAmount } from '@/utils/formatters' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' import { HelpIconTooltip } from '@/features/swap/components/HelpIconTooltip' export const SurplusFee = ({ diff --git a/apps/web/src/features/swap/components/SwapOrderConfirmationView/OrderFeeConfirmationView.tsx b/apps/web/src/features/swap/components/SwapOrderConfirmationView/OrderFeeConfirmationView.tsx index d54fa80bb9..adb06b201f 100644 --- a/apps/web/src/features/swap/components/SwapOrderConfirmationView/OrderFeeConfirmationView.tsx +++ b/apps/web/src/features/swap/components/SwapOrderConfirmationView/OrderFeeConfirmationView.tsx @@ -1,9 +1,10 @@ import type { SwapOrderConfirmationView, TwapOrderConfirmationView } from '@safe-global/safe-gateway-typescript-sdk' -import { getOrderFeeBps } from '@/features/swap/helpers/utils' +import { getOrderFeeBps } from '@safe-global/utils/features/swap/helpers/utils' import { DataRow } from '@/components/common/Table/DataRow' -import { BRAND_NAME, HelpCenterArticle } from '@/config/constants' +import { BRAND_NAME } from '@/config/constants' import { HelpIconTooltip } from '@/features/swap/components/HelpIconTooltip' import MUILink from '@mui/material/Link' +import { HelpCenterArticle } from '@safe-global/utils/config/constants' export const OrderFeeConfirmationView = ({ order, diff --git a/apps/web/src/features/swap/components/SwapOrderConfirmationView/index.tsx b/apps/web/src/features/swap/components/SwapOrderConfirmationView/index.tsx index 13392a847d..116adc0f4a 100644 --- a/apps/web/src/features/swap/components/SwapOrderConfirmationView/index.tsx +++ b/apps/web/src/features/swap/components/SwapOrderConfirmationView/index.tsx @@ -1,11 +1,11 @@ import OrderId from '@/features/swap/components/OrderId' -import { formatDateTime, formatTimeInWords, getPeriod } from '@/utils/date' +import { formatDateTime, formatTimeInWords, getPeriod } from '@safe-global/utils/utils/date' import { Fragment, type ReactElement } from 'react' import { DataRow } from '@/components/common/Table/DataRow' import { DataTable } from '@/components/common/Table/DataTable' import { compareAsc } from 'date-fns' import { Alert, Typography } from '@mui/material' -import { formatAmount } from '@/utils/formatNumber' +import { formatAmount } from '@safe-global/utils/utils/formatNumber' import { getLimitPrice, getOrderClass, getSlippageInPercent } from '@/features/swap/helpers/utils' import type { DataDecoded, SwapOrder, SwapTransferOrder, TwapOrder } from '@safe-global/safe-gateway-typescript-sdk' import { StartTimeValue, TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' diff --git a/apps/web/src/features/swap/components/SwapProgress/index.tsx b/apps/web/src/features/swap/components/SwapProgress/index.tsx index ca4a1a2b97..198a7a62d9 100644 --- a/apps/web/src/features/swap/components/SwapProgress/index.tsx +++ b/apps/web/src/features/swap/components/SwapProgress/index.tsx @@ -1,5 +1,5 @@ import { getFilledAmount, getFilledPercentage } from '@/features/swap/helpers/utils' -import { formatAmount } from '@/utils/formatNumber' +import { formatAmount } from '@safe-global/utils/utils/formatNumber' import { LinearProgress, Stack, Typography } from '@mui/material' import type { Order } from '@safe-global/safe-gateway-typescript-sdk' diff --git a/apps/web/src/features/swap/constants.ts b/apps/web/src/features/swap/constants.ts index c6245fac90..c4d1c42c97 100644 --- a/apps/web/src/features/swap/constants.ts +++ b/apps/web/src/features/swap/constants.ts @@ -3,4 +3,4 @@ export const SWAP_ORDER_TITLE = 'Swap order' export const LIMIT_ORDER_TITLE = 'Limit order' export const TWAP_ORDER_TITLE = 'TWAP order' -export const SWAP_FEE_RECIPIENT = '0x63695Eee2c3141BDE314C5a6f89B98E62808d716' +export const SWAP_FEE_RECIPIENT = '0xE344241493D573428076c022835856a221dB3E26' diff --git a/apps/web/src/features/swap/helpers/swapOrderBuilder.ts b/apps/web/src/features/swap/helpers/swapOrderBuilder.ts index 30d751e1d6..9cdd71d2d7 100644 --- a/apps/web/src/features/swap/helpers/swapOrderBuilder.ts +++ b/apps/web/src/features/swap/helpers/swapOrderBuilder.ts @@ -64,7 +64,6 @@ export function swapOrderBuilder(): IBuilder<SwapOrder> { buyToken: orderTokenBuilder().build(), explorerUrl: 'https://explorer.cow.fi/orders/0x03a5d561ad2452d719a0d075573f4bed68217c696b52f151122c30e3e4426f1b05e6b5eb1d0e6aabab082057d5bb91f2ee6d11be66223d88', - executedSurplusFee: executedFee, executedFee, executedFeeToken: sellToken, fullAppData: appDataBuilder().build(), @@ -86,7 +85,6 @@ export function twapOrderBuilder(): IBuilder<TwapOrder> { executedBuyAmount: faker.string.numeric(), sellToken: orderTokenBuilder().build(), buyToken: orderTokenBuilder().build(), - executedSurplusFee: executedFee, executedFee, executedFeeToken: sellToken, fullAppData: appDataBuilder().build(), diff --git a/apps/web/src/features/swap/helpers/utils.ts b/apps/web/src/features/swap/helpers/utils.ts index 846bdb29d0..1eaa267d17 100644 --- a/apps/web/src/features/swap/helpers/utils.ts +++ b/apps/web/src/features/swap/helpers/utils.ts @@ -1,8 +1,8 @@ import type { DataDecoded, Order as SwapOrder } from '@safe-global/safe-gateway-typescript-sdk' import { formatUnits } from 'ethers' -import type { AnyAppDataDocVersion, latest, LatestAppDataDocVersion } from '@cowprotocol/app-data' +import type { AnyAppDataDocVersion, latest } from '@cowprotocol/app-data' -import { TradeType, UiOrderType } from '@/features/swap/types' +import { TradeType, UiOrderType } from '@safe-global/utils/features/swap/types' type Quantity = { amount: string | number | bigint @@ -25,7 +25,7 @@ function asDecimal(amount: number | bigint, decimals: number): number { export const TWAP_FALLBACK_HANDLER = '0x2f55e8b20D0B9FEFA187AA7d00B6Cbe563605bF5' // https://github.com/cowprotocol/composable-cow/blob/main/networks.json -export const TWAP_FALLBACK_HANDLER_NETWORKS = ['1', '100', '11155111', '42161'] +export const TWAP_FALLBACK_HANDLER_NETWORKS = ['1', '100', '137', '11155111', '8453', '42161', '43114'] export const getExecutionPrice = ( order: Pick<SwapOrder, 'executedSellAmount' | 'executedBuyAmount' | 'buyToken' | 'sellToken'>, @@ -167,13 +167,6 @@ export const getOrderClass = (order: Pick<SwapOrder, 'fullAppData'>): latest.Ord return orderClass || 'market' } -export const getOrderFeeBps = (order: Pick<SwapOrder, 'fullAppData'>): number => { - const fullAppData = order.fullAppData as unknown as LatestAppDataDocVersion - const basisPoints = (fullAppData?.metadata?.partnerFee as latest.PartnerFee)?.bps - - return Number(basisPoints) || 0 -} - export const isOrderPartiallyFilled = ( order: Pick<SwapOrder, 'executedBuyAmount' | 'executedSellAmount' | 'sellAmount' | 'buyAmount' | 'kind'>, ): boolean => { diff --git a/apps/web/src/features/swap/hooks/__tests__/useIsTWAPFallbackHandler.test.ts b/apps/web/src/features/swap/hooks/__tests__/useIsTWAPFallbackHandler.test.ts index 51ff36d3ac..6f85b482fe 100644 --- a/apps/web/src/features/swap/hooks/__tests__/useIsTWAPFallbackHandler.test.ts +++ b/apps/web/src/features/swap/hooks/__tests__/useIsTWAPFallbackHandler.test.ts @@ -1,7 +1,7 @@ import { useIsTWAPFallbackHandler } from '@/features/swap/hooks/useIsTWAPFallbackHandler' import { renderHook } from '@/tests/test-utils' import * as useSafeInfo from '@/hooks/useSafeInfo' -import type { ExtendedSafeInfo } from '@/store/safeInfoSlice' +import type { ExtendedSafeInfo } from '@safe-global/store/slices/SafeInfo/types' import { TWAP_FALLBACK_HANDLER, TWAP_FALLBACK_HANDLER_NETWORKS } from '../../helpers/utils' describe('useIsTWAPFallbackHandler', () => { diff --git a/apps/web/src/features/swap/hooks/useIsSwapFeatureEnabled.ts b/apps/web/src/features/swap/hooks/useIsSwapFeatureEnabled.ts index d43d02a3fd..f8b67366f3 100644 --- a/apps/web/src/features/swap/hooks/useIsSwapFeatureEnabled.ts +++ b/apps/web/src/features/swap/hooks/useIsSwapFeatureEnabled.ts @@ -1,7 +1,7 @@ import { GeoblockingContext } from '@/components/common/GeoblockingProvider' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' import { useContext } from 'react' +import { FEATURES } from '@safe-global/utils/utils/chains' const useIsSwapFeatureEnabled = () => { const isBlockedCountry = useContext(GeoblockingContext) diff --git a/apps/web/src/features/swap/hooks/useIsTWAPFallbackHandler.ts b/apps/web/src/features/swap/hooks/useIsTWAPFallbackHandler.ts index 0f6bb7d79c..cc55a47291 100644 --- a/apps/web/src/features/swap/hooks/useIsTWAPFallbackHandler.ts +++ b/apps/web/src/features/swap/hooks/useIsTWAPFallbackHandler.ts @@ -1,6 +1,16 @@ import { useMemo } from 'react' import useSafeInfo from '@/hooks/useSafeInfo' import { TWAP_FALLBACK_HANDLER, TWAP_FALLBACK_HANDLER_NETWORKS } from '../helpers/utils' +import { sameAddress } from '@safe-global/utils/utils/addresses' + +export const useTWAPFallbackHandlerAddress = () => { + const { safe } = useSafeInfo() + + return useMemo( + () => (TWAP_FALLBACK_HANDLER_NETWORKS.includes(safe.chainId) ? TWAP_FALLBACK_HANDLER : undefined), + [safe.chainId], + ) +} /** * Hook to check if the Safe's fallback handler (or optionally a provided address) is the TWAP fallback handler. @@ -9,11 +19,12 @@ import { TWAP_FALLBACK_HANDLER, TWAP_FALLBACK_HANDLER_NETWORKS } from '../helper */ export const useIsTWAPFallbackHandler = (fallbackHandler?: string) => { const { safe } = useSafeInfo() + const twapFallbackHandler = useTWAPFallbackHandlerAddress() const fallbackHandlerAddress = fallbackHandler || safe.fallbackHandler?.value return useMemo( - () => fallbackHandlerAddress === TWAP_FALLBACK_HANDLER && TWAP_FALLBACK_HANDLER_NETWORKS.includes(safe.chainId), - [fallbackHandlerAddress, safe.chainId], + () => sameAddress(fallbackHandlerAddress, twapFallbackHandler), + [fallbackHandlerAddress, twapFallbackHandler], ) } diff --git a/apps/web/src/features/swap/index.tsx b/apps/web/src/features/swap/index.tsx index 2a23081533..eb4c44583d 100644 --- a/apps/web/src/features/swap/index.tsx +++ b/apps/web/src/features/swap/index.tsx @@ -25,7 +25,6 @@ import { selectSwapParams, setSwapParams, type SwapState } from './store/swapPar import { setSwapOrder } from '@/store/swapOrderSlice' import useChainId from '@/hooks/useChainId' import { type BaseTransaction } from '@safe-global/safe-apps-sdk' -import { APPROVAL_SIGNATURE_HASH } from '@/components/tx/ApprovalEditor/utils/approvals' import { id } from 'ethers' import { LIMIT_ORDER_TITLE, @@ -36,11 +35,12 @@ import { } from '@/features/swap/constants' import { calculateFeePercentageInBps } from '@/features/swap/helpers/fee' import { UiOrderTypeToOrderType } from '@/features/swap/helpers/utils' -import { FEATURES } from '@/utils/chains' import { useGetIsSanctionedQuery } from '@/store/api/ofac' import { skipToken } from '@reduxjs/toolkit/query/react' import { getKeyWithTrueValue } from '@/utils/helpers' import { BRAND_NAME } from '@/config/constants' +import { APPROVAL_SIGNATURE_HASH } from '@safe-global/utils/components/tx/ApprovalEditor/utils/approvals' +import { FEATURES } from '@safe-global/utils/utils/chains' const BASE_URL = typeof window !== 'undefined' && window.location.origin ? window.location.origin : '' diff --git a/apps/web/src/features/swap/store/swapParamsSlice.ts b/apps/web/src/features/swap/store/swapParamsSlice.ts index 137a8f6abe..5f669e7d2b 100644 --- a/apps/web/src/features/swap/store/swapParamsSlice.ts +++ b/apps/web/src/features/swap/store/swapParamsSlice.ts @@ -2,7 +2,7 @@ import type { RootState } from '@/store' import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import { UiOrderTypeToOrderType } from '@/features/swap/helpers/utils' -import { TradeType, type UiOrderType } from '@/features/swap/types' +import { TradeType, type UiOrderType } from '@safe-global/utils/features/swap/types' export type SwapState = { tradeType: TradeType diff --git a/apps/web/src/features/targetedFeatures/constants.ts b/apps/web/src/features/targetedFeatures/constants.ts new file mode 100644 index 0000000000..5003aa65a9 --- /dev/null +++ b/apps/web/src/features/targetedFeatures/constants.ts @@ -0,0 +1,4 @@ +export const TARGETED_FEATURES = [ + // example of a targeted feature + // { id: 3, feature: FEATURES.NESTED_SAFES }, +] as const diff --git a/apps/web/src/features/targetedFeatures/hooks/__tests__/useIsOutreachSafe.test.ts b/apps/web/src/features/targetedFeatures/hooks/__tests__/useIsOutreachSafe.test.ts new file mode 100644 index 0000000000..ad388bbd7c --- /dev/null +++ b/apps/web/src/features/targetedFeatures/hooks/__tests__/useIsOutreachSafe.test.ts @@ -0,0 +1,140 @@ +import { faker } from '@faker-js/faker' +import * as targetedMessages from '@safe-global/store/gateway/AUTO_GENERATED/targeted-messages' + +import * as useSafeInfoHook from '@/hooks/useSafeInfo' +import { safeInfoBuilder } from '@/tests/builders/safe' +import { renderHook } from '@/tests/test-utils' +import { useIsOutreachSafe } from '../useIsOutreachSafe' + +describe('useIsOutreachSafe', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns true if the Safe is targeted for messaging', () => { + const safeInfo = safeInfoBuilder().build() + const outreachId = faker.number.int() + jest.spyOn(useSafeInfoHook, 'default').mockReturnValue({ + safeAddress: safeInfo.address.value, + safe: { + ...safeInfo, + deployed: true, + }, + safeLoaded: true, + safeLoading: false, + safeError: undefined, + }) + jest.spyOn(targetedMessages, 'useTargetedMessagingGetTargetedSafeV1Query').mockReturnValue({ + data: { + outreachId, + address: safeInfo.address.value, + }, + refetch: jest.fn(), + }) + + const { result } = renderHook(() => useIsOutreachSafe(outreachId)) + + expect(result.current).toBe(true) + }) + + it('returns false if the Safe is not targeted for messaging', () => { + const safeInfo = safeInfoBuilder().build() + const outreachId = faker.number.int() + jest.spyOn(useSafeInfoHook, 'default').mockReturnValue({ + safeAddress: safeInfo.address.value, + safe: { + ...safeInfo, + deployed: true, + }, + safeLoaded: true, + safeLoading: false, + safeError: undefined, + }) + jest.spyOn(targetedMessages, 'useTargetedMessagingGetTargetedSafeV1Query').mockReturnValue({ + data: undefined, + error: new Error('Safe not targeted'), + refetch: jest.fn(), + }) + + const { result } = renderHook(() => useIsOutreachSafe(outreachId)) + + expect(result.current).toBe(false) + }) + + it('returns false if the data is not available', () => { + const safeInfo = safeInfoBuilder().build() + const outreachId = faker.number.int() + jest.spyOn(useSafeInfoHook, 'default').mockReturnValue({ + safeAddress: safeInfo.address.value, + safe: { + ...safeInfo, + deployed: true, + }, + safeLoaded: true, + safeLoading: false, + safeError: undefined, + }) + jest.spyOn(targetedMessages, 'useTargetedMessagingGetTargetedSafeV1Query').mockReturnValue({ + data: undefined, // Yet to be fetched + refetch: jest.fn(), + }) + + const { result } = renderHook(() => useIsOutreachSafe(outreachId)) + + expect(result.current).toBe(false) + }) + + it('returns false if the outreachId does not match', () => { + const safeInfo = safeInfoBuilder().build() + const outreachId = faker.number.int() + const otherOutreachId = 'OTHER_FEATURE' + jest.spyOn(useSafeInfoHook, 'default').mockReturnValue({ + safeAddress: safeInfo.address.value, + safe: { + ...safeInfo, + deployed: true, + }, + safeLoaded: true, + safeLoading: false, + safeError: undefined, + }) + jest.spyOn(targetedMessages, 'useTargetedMessagingGetTargetedSafeV1Query').mockReturnValue({ + data: { + outreachId: otherOutreachId, + address: safeInfo.address.value, + }, + refetch: jest.fn(), + }) + + const { result } = renderHook(() => useIsOutreachSafe(outreachId)) + + expect(result.current).toBe(false) + }) + + it('returns false if the address does not match', () => { + const safeInfo = safeInfoBuilder().build() + const otherAddress = faker.finance.ethereumAddress() + const outreachId = faker.number.int() + jest.spyOn(useSafeInfoHook, 'default').mockReturnValue({ + safeAddress: safeInfo.address.value, + safe: { + ...safeInfo, + deployed: true, + }, + safeLoaded: true, + safeLoading: false, + safeError: undefined, + }) + jest.spyOn(targetedMessages, 'useTargetedMessagingGetTargetedSafeV1Query').mockReturnValue({ + data: { + outreachId, + address: otherAddress, + }, + refetch: jest.fn(), + }) + + const { result } = renderHook(() => useIsOutreachSafe(outreachId)) + + expect(result.current).toBe(false) + }) +}) diff --git a/apps/web/src/features/targetedFeatures/hooks/__tests__/useIsTargetedFeature.test.ts b/apps/web/src/features/targetedFeatures/hooks/__tests__/useIsTargetedFeature.test.ts new file mode 100644 index 0000000000..4f3ee057af --- /dev/null +++ b/apps/web/src/features/targetedFeatures/hooks/__tests__/useIsTargetedFeature.test.ts @@ -0,0 +1,81 @@ +import { faker } from '@faker-js/faker' + +import * as useOutreachSafeHook from '@/features/targetedFeatures/hooks/useIsOutreachSafe' +import * as useChainsHook from '@/hooks/useChains' +import * as useLocalStorageHook from '@/services/local-storage/useLocalStorage' +import { renderHook, waitFor } from '@/tests/test-utils' +import { type TargetedFeatures, useIsTargetedFeature } from '../useIsTargetedFeature' + +jest.mock('../../constants', () => ({ + TARGETED_FEATURES: [ + { id: 1, feature: 'FEATURE_1' }, + { id: 2, feature: 'FEATURE_2' }, + { id: 3, feature: 'FEATURE_3' }, + ], +})) + +const targetedFeatures = ['FEATURE_1', 'FEATURE_2', 'FEATURE_3'] + +describe('useIsTargetedFeature', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns true if the Safe is targeted and the feature is enabled', () => { + const feature = faker.helpers.arrayElement(targetedFeatures) + jest.spyOn(useChainsHook, 'useHasFeature').mockReturnValue(true) + jest.spyOn(useOutreachSafeHook, 'useIsOutreachSafe').mockReturnValue(true) + jest.spyOn(useLocalStorageHook, 'default').mockReturnValue([[feature], jest.fn()]) + + const { result } = renderHook(() => useIsTargetedFeature(feature as TargetedFeatures)) + + expect(result.current).toBe(true) + }) + + it('returns true if the the feature is unlocked and enabled', () => { + const feature = faker.helpers.arrayElement(targetedFeatures) + jest.spyOn(useChainsHook, 'useHasFeature').mockReturnValue(true) + jest.spyOn(useOutreachSafeHook, 'useIsOutreachSafe').mockReturnValue(false) + jest.spyOn(useLocalStorageHook, 'default').mockReturnValue([[feature], jest.fn()]) + + const { result } = renderHook(() => useIsTargetedFeature(feature as TargetedFeatures)) + + expect(result.current).toBe(true) + }) + + it('returns false if the Safe is targeted but the feature is disabled', () => { + const feature = faker.helpers.arrayElement(targetedFeatures) + jest.spyOn(useChainsHook, 'useHasFeature').mockReturnValue(false) + jest.spyOn(useOutreachSafeHook, 'useIsOutreachSafe').mockReturnValue(true) + jest.spyOn(useLocalStorageHook, 'default').mockReturnValue([[], jest.fn()]) + + const { result } = renderHook(() => useIsTargetedFeature(feature as TargetedFeatures)) + + expect(result.current).toBe(false) + }) + + it('returns false if the Safe is targeted and the feature is unlocked', () => { + const feature = faker.helpers.arrayElement(targetedFeatures) + jest.spyOn(useChainsHook, 'useHasFeature').mockReturnValue(false) + jest.spyOn(useOutreachSafeHook, 'useIsOutreachSafe').mockReturnValue(true) + jest.spyOn(useLocalStorageHook, 'default').mockReturnValue([[feature], jest.fn()]) + + const { result } = renderHook(() => useIsTargetedFeature(feature as TargetedFeatures)) + + expect(result.current).toBe(false) + }) + + it('caches targeted/enabled features', () => { + const feature = faker.helpers.arrayElement(targetedFeatures) + const setLocalStorageMock = jest.fn() + jest.spyOn(useChainsHook, 'useHasFeature').mockReturnValue(true) + jest.spyOn(useOutreachSafeHook, 'useIsOutreachSafe').mockReturnValue(true) + jest.spyOn(useLocalStorageHook, 'default').mockReturnValue([[feature], jest.fn()]) + + renderHook(() => useIsTargetedFeature(feature as TargetedFeatures)) + + waitFor(() => { + expect(setLocalStorageMock).toHaveBeenCalledWith([feature]) + }) + }) +}) diff --git a/apps/web/src/features/targetedFeatures/hooks/useIsOutreachSafe.ts b/apps/web/src/features/targetedFeatures/hooks/useIsOutreachSafe.ts new file mode 100644 index 0000000000..ef64f45f86 --- /dev/null +++ b/apps/web/src/features/targetedFeatures/hooks/useIsOutreachSafe.ts @@ -0,0 +1,18 @@ +import { useTargetedMessagingGetTargetedSafeV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/targeted-messages' + +import useSafeInfo from '@/hooks/useSafeInfo' +import { sameAddress } from '@safe-global/utils/utils/addresses' + +export function useIsOutreachSafe(outreachId: number): boolean { + const { safe } = useSafeInfo() + const { data } = useTargetedMessagingGetTargetedSafeV1Query( + { + outreachId, + chainId: safe.chainId, + safeAddress: safe.address.value, + }, + { skip: !safe.address.value }, + ) + + return data?.outreachId === outreachId && sameAddress(data.address, safe.address.value) +} diff --git a/apps/web/src/features/targetedFeatures/hooks/useIsTargetedFeature.ts b/apps/web/src/features/targetedFeatures/hooks/useIsTargetedFeature.ts new file mode 100644 index 0000000000..af19bb1f1f --- /dev/null +++ b/apps/web/src/features/targetedFeatures/hooks/useIsTargetedFeature.ts @@ -0,0 +1,29 @@ +import { useEffect } from 'react' + +import { useIsOutreachSafe } from '@/features/targetedFeatures/hooks/useIsOutreachSafe' +import { useHasFeature } from '@/hooks/useChains' +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import { TARGETED_FEATURES } from '../constants' + +const UNLOCKED_FEATURES_LS_KEY = 'unlockedFeatures' + +export type TargetedFeatures = (typeof TARGETED_FEATURES)[number]['feature'] + +export function useIsTargetedFeature(feature: TargetedFeatures): boolean { + const hasFeature = useHasFeature(feature) + + const outreachId = TARGETED_FEATURES.find((f) => f && f['feature'] === feature)!['id'] + const isTargeted = useIsOutreachSafe(outreachId) + + // Should a targeted Safe have been opened, we "unlock" the feature across the app + const [unlockedFeatures = [], setUnlockedFeatures] = + useLocalStorage<Array<TargetedFeatures>>(UNLOCKED_FEATURES_LS_KEY) + const isUnlocked = unlockedFeatures.includes(feature) + useEffect(() => { + if (hasFeature && isTargeted && !isUnlocked) { + setUnlockedFeatures([...unlockedFeatures, feature]) + } + }, [feature, hasFeature, isTargeted, isUnlocked, setUnlockedFeatures, unlockedFeatures]) + + return !!hasFeature && (isTargeted || isUnlocked) +} diff --git a/apps/web/src/features/targetedOutreach/components/OutreachPopup/index.tsx b/apps/web/src/features/targetedOutreach/components/OutreachPopup/index.tsx index cf30149c68..2f341b9915 100644 --- a/apps/web/src/features/targetedOutreach/components/OutreachPopup/index.tsx +++ b/apps/web/src/features/targetedOutreach/components/OutreachPopup/index.tsx @@ -1,6 +1,7 @@ import dynamic from 'next/dynamic' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' + +import { FEATURES } from '@safe-global/utils/utils/chains' const LazyOutreachPopup = dynamic(() => import('./OutreachPopup'), { ssr: false, diff --git a/apps/web/src/features/tx-notes/TxNote.tsx b/apps/web/src/features/tx-notes/TxNote.tsx index d648770ca8..483ee1cadf 100644 --- a/apps/web/src/features/tx-notes/TxNote.tsx +++ b/apps/web/src/features/tx-notes/TxNote.tsx @@ -16,8 +16,9 @@ export function TxNote({ txDetails }: { txDetails: TransactionDetails | undefine <Typography variant="h5" display="flex" alignItems="center" justifyItems="center"> Note <Tooltip + data-testid="tx-note-tooltip" title={ - <Stack direction="row" gap={1}> + <Stack data-testid="note-creator" direction="row" gap={1}> <span>By </span> {creator ? ( <EthHashInfo avatarSize={20} address={creator.value} showName onlyName /> @@ -34,7 +35,7 @@ export function TxNote({ txDetails }: { txDetails: TransactionDetails | undefine </Tooltip> </Typography> - <Typography p={2} mt={1} borderRadius={1} bgcolor="background.main"> + <Typography data-testid="tx-note" p={2} mt={1} borderRadius={1} bgcolor="background.main"> {note} </Typography> </div> diff --git a/apps/web/src/features/tx-notes/TxNoteInput.tsx b/apps/web/src/features/tx-notes/TxNoteInput.tsx index 374577829e..45d1e40f6c 100644 --- a/apps/web/src/features/tx-notes/TxNoteInput.tsx +++ b/apps/web/src/features/tx-notes/TxNoteInput.tsx @@ -1,38 +1,50 @@ -import { useCallback, useState } from 'react' -import { InputAdornment, Stack, TextField, Typography, Alert } from '@mui/material' +import { useCallback } from 'react' +import { InputAdornment, Stack, TextField, Typography, SvgIcon, Box } from '@mui/material' +import { MODALS_EVENTS, trackEvent } from '@/services/analytics' +import { useForm } from 'react-hook-form' +import InfoOutlinedIcon from '@/public/images/notifications/info.svg' const MAX_NOTE_LENGTH = 60 export const TxNoteInput = ({ onChange }: { onChange: (note: string) => void }) => { - const [note, setNote] = useState('') + const { + register, + watch, + reset, + formState: { isDirty }, + } = useForm<{ note: string }>() - const onInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { - setNote(e.target.value) - }, []) + const note = watch('note') || '' - const onInputChange = useCallback( + const onInput = useCallback( (e: React.ChangeEvent<HTMLInputElement>) => { onChange(e.target.value.slice(0, MAX_NOTE_LENGTH)) }, [onChange], ) + const onFocus = useCallback(() => { + // Reset the isDirty state when the user focuses on the input + reset({ note }) + }, [reset, note]) + + const onBlur = useCallback(() => { + if (isDirty && note.length > 0) { + // Track the event only if the note is dirty and not empty + // This prevents tracking the event when the user focuses and blurs the input without changing the note + trackEvent(MODALS_EVENTS.SUBMIT_TX_NOTE) + } + }, [isDirty, note]) + return ( <> <Stack direction="row" alignItems="flex-end" gap={1}> - <Typography variant="h5">Optional note</Typography> - <Typography variant="body2" color="text.secondary"> - Experimental - </Typography> + <Typography variant="h5">Add transaction note</Typography> </Stack> - <Alert severity="info"> - The notes are <b>publicly visible</b>, do not share any private or sensitive details. - </Alert> - <TextField - name="note" - label="Note" + data-testid="tx-note-textfield" + label="Note (optional)" fullWidth slotProps={{ htmlInput: { maxLength: MAX_NOTE_LENGTH }, @@ -46,9 +58,21 @@ export const TxNoteInput = ({ onChange }: { onChange: (note: string) => void }) ), }, }} + {...register('note')} onInput={onInput} - onChange={onInputChange} + onBlur={onBlur} + onFocus={onFocus} /> + + <Stack data-testid="tx-note-alert" direction="row" gap={1} color="text.secondary"> + <SvgIcon component={InfoOutlinedIcon} sx={{ width: '20px', height: '20px', rotate: '180deg' }} inheritViewBox /> + <Box> + <Typography variant="body2" fontWeight="700"> + Notes are publicly visible. + </Typography> + <Typography variant="body2">Do not share any private or sensitive details.</Typography> + </Box> + </Stack> </> ) } diff --git a/apps/web/src/features/walletconnect/WalletConnectContext.tsx b/apps/web/src/features/walletconnect/WalletConnectContext.tsx index 757f021fb1..359ec7dc08 100644 --- a/apps/web/src/features/walletconnect/WalletConnectContext.tsx +++ b/apps/web/src/features/walletconnect/WalletConnectContext.tsx @@ -1,23 +1,345 @@ -import type { WCLoadingState } from '@/features/walletconnect/components/WalletConnectProvider' -import { createContext, type Dispatch, type SetStateAction } from 'react' -import type WalletConnectWallet from '@/features/walletconnect/services/WalletConnectWallet' +import { + createContext, + useEffect, + useState, + useCallback, + type Dispatch, + type SetStateAction, + type ReactNode, +} from 'react' +import { getSdkError } from '@walletconnect/utils' +import { formatJsonRpcError } from '@walletconnect/jsonrpc-utils' +import type { SessionTypes } from '@walletconnect/types' +import type { WalletKitTypes } from '@reown/walletkit' + +import useSafeInfo from '@/hooks/useSafeInfo' +import useSafeWalletProvider from '@/services/safe-wallet-provider/useSafeWalletProvider' +import { IS_PRODUCTION } from '@/config/constants' +import { getEip155ChainId, getPeerName, stripEip155Prefix } from '@/features/walletconnect/services/utils' +import { trackRequest } from '@/features/walletconnect/services/tracking' +import { wcPopupStore } from '@/features/walletconnect/components' +import WalletConnectWallet from '@/features/walletconnect/services/WalletConnectWallet' +import useLocalStorage from '@/services/local-storage/useLocalStorage' type WalletConnectContextType = { walletConnect: WalletConnectWallet | null + sessions: SessionTypes.Struct[] + sessionProposal: WalletKitTypes.SessionProposal | null error: Error | null setError: Dispatch<SetStateAction<Error | null>> open: boolean setOpen: (open: boolean) => void - isLoading: WCLoadingState | undefined - setIsLoading: Dispatch<SetStateAction<WCLoadingState | undefined>> + loading: WCLoadingState | null + setLoading: Dispatch<SetStateAction<WCLoadingState | null>> + approveSession: () => Promise<void> + rejectSession: () => Promise<void> } export const WalletConnectContext = createContext<WalletConnectContextType>({ walletConnect: null, + sessions: [], + sessionProposal: null, error: null, setError: () => {}, open: false, setOpen: () => {}, - isLoading: undefined, - setIsLoading: () => {}, + loading: null, + setLoading: () => {}, + approveSession: () => Promise.resolve(), + rejectSession: () => Promise.resolve(), }) + +enum Errors { + WRONG_CHAIN = '%%dappName%% made a request on a different chain than the one you are connected to', +} + +export enum WCLoadingState { + APPROVE = 'Approve', + REJECT = 'Reject', + CONNECT = 'Connect', + DISCONNECT = 'Disconnect', +} + +// chainId -> origin -> boolean +type WcAutoApproveProps = Record<string, Record<string, boolean>> + +const WC_AUTO_APPROVE_KEY = 'wcAutoApprove' + +const FALLBACK_PEER_NAME = 'WalletConnect' + +// The URL of the former WalletConnect Safe App +// This is still used to differentiate these txs from Safe App txs in the analytics +const LEGACY_WC_APP_URL = 'https://apps-portal.safe.global/wallet-connect' + +const walletConnectSingleton = new WalletConnectWallet() + +const getWrongChainError = (dappName: string): Error => { + const message = Errors.WRONG_CHAIN.replace('%%dappName%%', dappName) + return new Error(message) +} + +export const WalletConnectProvider = ({ children }: { children: ReactNode }) => { + const { + safe: { chainId }, + safeAddress, + } = useSafeInfo() + const [walletConnect, setWalletConnect] = useState<WalletConnectWallet | null>(null) + const open = wcPopupStore.useStore() ?? false + const setOpen = wcPopupStore.setStore + const [error, setError] = useState<Error | null>(null) + const [loading, setLoading] = useState<WCLoadingState | null>(null) + const safeWalletProvider = useSafeWalletProvider() + const [autoApprove = {}, setAutoApprove] = useLocalStorage<WcAutoApproveProps>(WC_AUTO_APPROVE_KEY) + + // Init WalletConnect + useEffect(() => { + walletConnectSingleton + .init() + .then(() => setWalletConnect(walletConnectSingleton)) + .catch(setError) + }, []) + + // Update chainId/safeAddress + useEffect(() => { + if (!walletConnect || !chainId || !safeAddress) return + + walletConnect.updateSessions(chainId, safeAddress).catch(setError) + }, [walletConnect, chainId, safeAddress]) + + // + // --- Subscribe to requests + // + useEffect(() => { + if (!walletConnect || !safeWalletProvider || !chainId) return + + return walletConnect.onRequest(async (event) => { + if (!IS_PRODUCTION) { + console.log('[WalletConnect] request', event) + } + + const { topic } = event + const session = walletConnect.getActiveSessions().find((s) => s.topic === topic) + const requestChainId = stripEip155Prefix(event.params.chainId) + const peerName = (session && getPeerName(session.peer)) || FALLBACK_PEER_NAME + + // Track requests + if (session) { + trackRequest(session.peer.metadata.url, event.params.request.method) + } + + const getResponse = () => { + // Get error if wrong chain + if (!session || requestChainId !== chainId) { + if (session) { + setError(getWrongChainError(peerName)) + } + + const error = getSdkError('UNSUPPORTED_CHAINS') + return formatJsonRpcError(event.id, error) + } + + // Get response from Safe Wallet Provider + return safeWalletProvider.request(event.id, event.params.request, { + url: LEGACY_WC_APP_URL, // required for server-side analytics + name: peerName, + description: session.peer.metadata.description, + iconUrl: session.peer.metadata.icons[0], + }) + } + + try { + const response = await getResponse() + + // Send response to WalletConnect + await walletConnect.sendSessionResponse(topic, response) + } catch (e) { + setError(e as Error) + } + }) + }, [walletConnect, chainId, safeWalletProvider]) + + // + // --- One-click Auth + // + useEffect(() => { + if (!walletConnect || !safeWalletProvider || !chainId) return + + return walletConnect.onSessionAuth(async (event) => { + const { authPayload, requester } = event.params + const peerName = getPeerName(requester) || FALLBACK_PEER_NAME + + if (!IS_PRODUCTION) { + console.log('[WalletConnect] auth', authPayload, requester) + } + + if (!authPayload.chains.includes(getEip155ChainId(chainId))) { + setError(getWrongChainError(peerName)) + return + } + + const getSignature = async () => { + const message = walletConnect.formatAuthMessage(authPayload, chainId, safeAddress) + + if (!IS_PRODUCTION) { + console.log('[WalletConnect] SiWE message', message) + } + + const appInfo = { + url: LEGACY_WC_APP_URL, // required for server-side analytics + name: peerName, + description: requester.metadata.description, + iconUrl: requester.metadata.icons[0], + } + + return safeWalletProvider.request( + event.id, + { + method: 'personal_sign', + params: [message, safeAddress], + }, + appInfo, + ) + } + + // Close the popup + setLoading(WCLoadingState.APPROVE) + setOpen(false) + + // Get a signature and send it to WalletConnect + try { + const signature = await getSignature() + if ('error' in signature) throw new Error(signature.error.message) + await walletConnect.approveSessionAuth(event.id, authPayload, signature.result as string, chainId, safeAddress) + } catch (e) { + try { + await walletConnect.rejectSessionAuth(event.id) + } catch (err) { + e = err + } + setError(e as Error) + setOpen(true) + } + + setLoading(null) + }) + }, [walletConnect, safeWalletProvider, chainId, safeAddress, setOpen]) + + // + // --- Sessions + // + const [sessions, setSessions] = useState<SessionTypes.Struct[]>([]) + + const updateSessions = useCallback(() => { + walletConnect && setSessions(walletConnect.getActiveSessions()) + }, [walletConnect]) + + // Initial sessions + useEffect(updateSessions, [updateSessions]) + + // On session add + useEffect(() => { + return walletConnect?.onSessionAdd(updateSessions) + }, [walletConnect, updateSessions]) + + // On session delete + useEffect(() => { + return walletConnect?.onSessionDelete(updateSessions) + }, [walletConnect, updateSessions]) + + // + // --- Proposals + // + const [sessionProposal, setSessionProposal] = useState<WalletKitTypes.SessionProposal | null>(null) + + const approveSession = useCallback(async () => { + if (!walletConnect || !sessionProposal) return + + setLoading(WCLoadingState.APPROVE) + + try { + await walletConnect.approveSession(sessionProposal, chainId, safeAddress, { + capabilities: JSON.stringify({ + [safeAddress]: { + [`0x${Number(chainId).toString(16)}`]: { + atomicBatch: { + supported: true, + }, + }, + }, + }), + }) + + // Add session to auto approve list + if ( + sessionProposal.verifyContext.verified.validation !== 'INVALID' && + !sessionProposal.verifyContext.verified.isScam + ) { + setAutoApprove((prev) => ({ + ...prev, + [chainId]: { ...prev?.[chainId], [sessionProposal.verifyContext.verified.origin]: true }, + })) + } + } catch (e) { + setLoading(null) + throw e + } + + setLoading(null) + setSessionProposal(null) + setOpen(false) + }, [walletConnect, sessionProposal, chainId, safeAddress, setAutoApprove, setOpen]) + + // Auto approve previously approved non-malicious dApps + useEffect(() => { + if (sessionProposal && autoApprove[chainId]?.[sessionProposal.verifyContext.verified.origin]) { + approveSession().catch((e) => { + setError(e as Error) + }) + } + }, [autoApprove, approveSession, sessionProposal, chainId]) + + const rejectSession = useCallback(async () => { + if (!walletConnect || !sessionProposal) return + + setLoading(WCLoadingState.REJECT) + + try { + await walletConnect.rejectSession(sessionProposal) + } catch (e) { + setLoading(null) + throw e + } + + setLoading(null) + setSessionProposal(null) + setOpen(false) + }, [walletConnect, sessionProposal, setOpen]) + + // Subscribe to session proposals + useEffect(() => { + return walletConnect?.onSessionPropose((proposalData) => { + setLoading(null) + setSessionProposal(proposalData) + }) + }, [walletConnect]) + + return ( + <WalletConnectContext.Provider + value={{ + walletConnect, + error, + setError, + open, + setOpen, + loading, + setLoading, + sessions, + sessionProposal, + approveSession, + rejectSession, + }} + > + {children} + </WalletConnectContext.Provider> + ) +} diff --git a/apps/web/src/features/walletconnect/__tests__/WalletConnectContext.test.tsx b/apps/web/src/features/walletconnect/__tests__/WalletConnectContext.test.tsx index 3ead9dd5ec..aa5d6bf503 100644 --- a/apps/web/src/features/walletconnect/__tests__/WalletConnectContext.test.tsx +++ b/apps/web/src/features/walletconnect/__tests__/WalletConnectContext.test.tsx @@ -8,14 +8,13 @@ import type { SessionTypes } from '@walletconnect/types' import { act, fireEvent, render, waitFor } from '@/tests/test-utils' import { WalletConnectContext } from '../WalletConnectContext' import WalletConnectWallet from '../services/WalletConnectWallet' -import { WalletConnectProvider } from '../components/WalletConnectProvider' +import { WalletConnectProvider } from '../WalletConnectContext' import { safeInfoSlice } from '@/store/safeInfoSlice' import { useAppDispatch } from '@/store' import * as useSafeWalletProvider from '@/services/safe-wallet-provider/useSafeWalletProvider' jest.mock('@reown/walletkit', () => jest.fn()) -jest.mock('../services/WalletConnectWallet') jest.mock('@/services/safe-wallet-provider/useSafeWalletProvider') const TestComponent = () => { diff --git a/apps/web/src/features/walletconnect/components/WalletConnectProvider/index.tsx b/apps/web/src/features/walletconnect/components/WalletConnectProvider/index.tsx deleted file mode 100644 index a9787fc859..0000000000 --- a/apps/web/src/features/walletconnect/components/WalletConnectProvider/index.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { type ReactNode, useEffect, useState } from 'react' -import { getSdkError } from '@walletconnect/utils' -import { formatJsonRpcError } from '@walletconnect/jsonrpc-utils' - -import useSafeInfo from '@/hooks/useSafeInfo' -import useSafeWalletProvider from '@/services/safe-wallet-provider/useSafeWalletProvider' -import { asError } from '@/services/exceptions/utils' -import { IS_PRODUCTION } from '@/config/constants' -import { getPeerName, stripEip155Prefix } from '@/features/walletconnect/services/utils' -import { trackRequest } from '@/features/walletconnect//services/tracking' -import { wcPopupStore } from '@/features/walletconnect/components' -import WalletConnectWallet from '@/features/walletconnect/services/WalletConnectWallet' -import { WalletConnectContext } from '@/features/walletconnect/WalletConnectContext' - -enum Errors { - WRONG_CHAIN = '%%dappName%% made a request on a different chain than the one you are connected to', -} - -export enum WCLoadingState { - APPROVE = 'Approve', - REJECT = 'Reject', - CONNECT = 'Connect', - DISCONNECT = 'Disconnect', -} - -// The URL of the former WalletConnect Safe App -// This is still used to differentiate these txs from Safe App txs in the analytics -const LEGACY_WC_APP_URL = 'https://apps-portal.safe.global/wallet-connect' - -const walletConnectSingleton = new WalletConnectWallet() - -const getWrongChainError = (dappName: string): Error => { - const message = Errors.WRONG_CHAIN.replace('%%dappName%%', dappName) - return new Error(message) -} - -export const WalletConnectProvider = ({ children }: { children: ReactNode }) => { - const { - safe: { chainId }, - safeAddress, - } = useSafeInfo() - const [walletConnect, setWalletConnect] = useState<WalletConnectWallet | null>(null) - const open = wcPopupStore.useStore() ?? false - const setOpen = wcPopupStore.setStore - const [error, setError] = useState<Error | null>(null) - const [isLoading, setIsLoading] = useState<WCLoadingState>() - const safeWalletProvider = useSafeWalletProvider() - - // Init WalletConnect - useEffect(() => { - walletConnectSingleton - .init() - .then(() => setWalletConnect(walletConnectSingleton)) - .catch(setError) - }, []) - - // Update chainId/safeAddress - useEffect(() => { - if (!walletConnect || !chainId || !safeAddress) return - - walletConnect.updateSessions(chainId, safeAddress).catch(setError) - }, [walletConnect, chainId, safeAddress]) - - // Subscribe to requests - useEffect(() => { - if (!walletConnect || !safeWalletProvider || !chainId) return - - return walletConnect.onRequest(async (event) => { - if (!IS_PRODUCTION) { - console.log('[WalletConnect] request', event) - } - - const { topic } = event - const session = walletConnect.getActiveSessions().find((s) => s.topic === topic) - const requestChainId = stripEip155Prefix(event.params.chainId) - - // Track requests - if (session) { - trackRequest(session.peer.metadata.url, event.params.request.method) - } - - const getResponse = () => { - // Get error if wrong chain - if (!session || requestChainId !== chainId) { - if (session) { - setError(getWrongChainError(getPeerName(session.peer))) - } - - const error = getSdkError('UNSUPPORTED_CHAINS') - return formatJsonRpcError(event.id, error) - } - - // Get response from Safe Wallet Provider - return safeWalletProvider.request(event.id, event.params.request, { - url: LEGACY_WC_APP_URL, // required for server-side analytics - name: getPeerName(session.peer) || 'WalletConnect', - description: session.peer.metadata.description, - iconUrl: session.peer.metadata.icons[0], - }) - } - - try { - const response = await getResponse() - - // Send response to WalletConnect - await walletConnect.sendSessionResponse(topic, response) - } catch (e) { - setError(asError(e)) - } - }) - }, [walletConnect, chainId, safeWalletProvider]) - - return ( - <WalletConnectContext.Provider value={{ walletConnect, error, setError, open, setOpen, isLoading, setIsLoading }}> - {children} - </WalletConnectContext.Provider> - ) -} diff --git a/apps/web/src/features/walletconnect/components/WalletConnectUi/index.tsx b/apps/web/src/features/walletconnect/components/WalletConnectUi/index.tsx index dbd5bdb678..0a8009624e 100644 --- a/apps/web/src/features/walletconnect/components/WalletConnectUi/index.tsx +++ b/apps/web/src/features/walletconnect/components/WalletConnectUi/index.tsx @@ -1,17 +1,15 @@ import { useCallback, useContext, useEffect } from 'react' import { ErrorBoundary } from '@sentry/react' import useSafeInfo from '@/hooks/useSafeInfo' -import useWalletConnectSessions from '@/features/walletconnect/hooks/useWalletConnectSessions' import { WalletConnectContext } from '@/features/walletconnect/WalletConnectContext' import useWcUri from '../../hooks/useWcUri' import WcHeaderWidget from '../WcHeaderWidget' import WcSessionManager from '../WcSessionManager' -import { WalletConnectProvider } from '../WalletConnectProvider' +import { WalletConnectProvider } from '../../WalletConnectContext' const WalletConnectWidget = () => { - const { walletConnect, error, open, setOpen } = useContext(WalletConnectContext) + const { walletConnect, error, open, setOpen, sessions } = useContext(WalletConnectContext) const [uri, clearUri] = useWcUri() - const sessions = useWalletConnectSessions() const { safeLoaded } = useSafeInfo() const onOpen = useCallback(() => { @@ -36,7 +34,7 @@ const WalletConnectWidget = () => { return ( <WcHeaderWidget isError={!!error} isOpen={open} onOpen={onOpen} onClose={onClose} sessions={sessions}> - <WcSessionManager sessions={sessions} uri={uri} /> + <WcSessionManager uri={uri} /> </WcHeaderWidget> ) } diff --git a/apps/web/src/features/walletconnect/components/WcConnectionState/index.test.tsx b/apps/web/src/features/walletconnect/components/WcConnectionState/index.test.tsx new file mode 100644 index 0000000000..acbac4b811 --- /dev/null +++ b/apps/web/src/features/walletconnect/components/WcConnectionState/index.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react' +import WcConnectionState from '.' + +describe('WcConnectionState component tests', () => { + const SAFE_LOGO_ALT = 'Safe logo' + const DAPP_NAME = 'Test dApp' + const DAPP_LOGO_ALT = `${DAPP_NAME} logo` + const ICON_URL = 'test-icon-url' + const SUCCESS_MESSAGE = `${DAPP_NAME} successfully connected!` + const DISCONNECT_MESSAGE = `${DAPP_NAME} disconnected` + const FALLBACK_SUCCESS_MESSAGE = 'dApp successfully connected!' + const DAPP_URL = `https://${Math.random().toString(36).substring(7)}.com` + + const mockMetadata = { + name: DAPP_NAME, + icons: [ICON_URL], + description: 'Test description', + url: DAPP_URL, + } + + jest.mock('@/components/safe-apps/SafeAppIconCard', () => { + const MockSafeAppIconCard = (props: { alt: string }) => ( + <div data-testid="mock-safe-app-icon-card">{`${props.alt} TestdApp Logo`}</div> + ) + MockSafeAppIconCard.displayName = 'MockSafeAppIconCard' + return MockSafeAppIconCard + }) + + it('Verify successful connection state is rendered correctly', () => { + render(<WcConnectionState metadata={mockMetadata} isDelete={false} />) + + expect(screen.getByAltText(SAFE_LOGO_ALT)).toBeVisible() + expect(screen.getByTestId('connection-dots')).toBeVisible() + expect(screen.getByAltText(DAPP_LOGO_ALT)).toBeVisible() + expect(screen.getByText(SUCCESS_MESSAGE)).toBeVisible() + }) + + it('Verify disconnection state is rendered correctly', () => { + render(<WcConnectionState metadata={mockMetadata} isDelete={true} />) + + expect(screen.getByAltText(SAFE_LOGO_ALT)).toBeVisible() + const dots = screen.getByTestId('connection-dots') + expect(dots).toBeVisible() + expect(dots).toHaveClass('errorDots') + expect(screen.getByAltText(DAPP_LOGO_ALT)).toBeVisible() + expect(screen.getByText(DISCONNECT_MESSAGE)).toBeVisible() + }) + + it('Verify fallback dApp name is used when metadata is missing', () => { + render(<WcConnectionState isDelete={false} />) + + expect(screen.getByText(FALLBACK_SUCCESS_MESSAGE)).toBeVisible() + }) + + it('Verify fallback icon is used when dApp icons array is empty', () => { + render(<WcConnectionState metadata={{ ...mockMetadata, icons: [] }} isDelete={false} />) + + expect(screen.getByAltText(DAPP_LOGO_ALT)).toBeVisible() + }) + + it('Verify WcConnectionState component layout structure', () => { + render(<WcConnectionState metadata={mockMetadata} isDelete={false} />) + + const container = screen.getByTestId('wc-connection-state') + expect(container).toHaveClass('container') + + expect(screen.getByAltText(SAFE_LOGO_ALT)).toBeInTheDocument() + expect(screen.getByTestId('connection-dots')).toBeInTheDocument() + expect(screen.getByAltText(DAPP_LOGO_ALT)).toBeInTheDocument() + }) + + it('Verify WcConnectionState typography styling', () => { + render(<WcConnectionState metadata={mockMetadata} isDelete={false} />) + + const message = screen.getByText(SUCCESS_MESSAGE) + expect(message).toHaveClass('MuiTypography-h5') + expect(message).toHaveStyle({ marginTop: '24px' }) + }) +}) diff --git a/apps/web/src/features/walletconnect/components/WcConnectionState/index.tsx b/apps/web/src/features/walletconnect/components/WcConnectionState/index.tsx index d3f4284e72..81481deb97 100644 --- a/apps/web/src/features/walletconnect/components/WcConnectionState/index.tsx +++ b/apps/web/src/features/walletconnect/components/WcConnectionState/index.tsx @@ -11,11 +11,12 @@ const WcConnectionState = ({ metadata, isDelete }: { metadata?: CoreTypes.Metada const icon = metadata?.icons[0] || '' return ( - <div className={css.container}> + <div data-testid="wc-connection-state" className={css.container}> <div> <SafeLogo alt="Safe logo" width="28px" height="28px" /> <SvgIcon + data-testid="connection-dots" component={ConnectionDots} inheritViewBox sx={{ mx: 2 }} diff --git a/apps/web/src/features/walletconnect/components/WcHints/index.test.tsx b/apps/web/src/features/walletconnect/components/WcHints/index.test.tsx new file mode 100644 index 0000000000..6ab7953f15 --- /dev/null +++ b/apps/web/src/features/walletconnect/components/WcHints/index.test.tsx @@ -0,0 +1,86 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import WcHints from '.' +import { trackEvent } from '@/services/analytics' + +jest.mock('@/services/analytics', () => ({ + trackEvent: jest.fn(), +})) + +describe('WcHints component tests', () => { + const CONNECT_TITLE = 'How do I connect to a dApp?' + const INTERACT_TITLE = 'How do I interact with a dApp?' + const CONNECT_STEP = 'Open a WalletConnect supported dApp' + const INTERACT_STEP = 'Connect a dApp by following the above steps' + const CONNECT_WALLET = 'Connect a wallet' + const ENSURE_CHAIN = 'Ensure the dApp is connected to the same chain as your Safe Account' + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('Verify both help accordions are rendered collapsed by default', () => { + render(<WcHints />) + + expect(screen.getByText(CONNECT_TITLE)).toBeVisible() + expect(screen.getByText(INTERACT_TITLE)).toBeVisible() + + expect(screen.queryByText(CONNECT_STEP)).not.toBeVisible() + expect(screen.queryByText(INTERACT_STEP)).not.toBeVisible() + }) + + it('Verify connection accordion can be expanded', () => { + render(<WcHints />) + + fireEvent.click(screen.getByText(CONNECT_TITLE)) + + expect(screen.getByText(CONNECT_STEP)).toBeVisible() + expect(screen.getByText(CONNECT_WALLET)).toBeVisible() + + expect(trackEvent).toHaveBeenCalledWith({ + category: 'walletconnect', + action: 'WC expand hints', + }) + }) + + it('Verify interaction accordion can be expanded', () => { + render(<WcHints />) + + fireEvent.click(screen.getByText(INTERACT_TITLE)) + + expect(screen.getByText(INTERACT_STEP)).toBeVisible() + expect(screen.getByText(ENSURE_CHAIN)).toBeVisible() + + expect(trackEvent).toHaveBeenCalledWith({ + category: 'walletconnect', + action: 'WC expand hints', + }) + }) + + it('Verify expanded accordion can be collapsed', async () => { + render(<WcHints />) + + const connectionTitle = screen.getByText(CONNECT_TITLE) + + fireEvent.click(connectionTitle) + expect(screen.getByText(CONNECT_STEP)).toBeVisible() + + fireEvent.click(connectionTitle) + await waitFor(() => { + expect(screen.queryByText(CONNECT_STEP)).not.toBeVisible() + }) + }) + + it('Verify previously opened accordion is closed when opening a new one', async () => { + render(<WcHints />) + + fireEvent.click(screen.getByText(CONNECT_TITLE)) + expect(screen.getByText(CONNECT_STEP)).toBeVisible() + + fireEvent.click(screen.getByText(INTERACT_TITLE)) + + await waitFor(() => { + expect(screen.queryByText(CONNECT_STEP)).not.toBeVisible() + }) + expect(screen.getByText(INTERACT_STEP)).toBeVisible() + }) +}) diff --git a/apps/web/src/features/walletconnect/components/WcInput/index.tsx b/apps/web/src/features/walletconnect/components/WcInput/index.tsx index ff40714c80..75df78bfc5 100644 --- a/apps/web/src/features/walletconnect/components/WcInput/index.tsx +++ b/apps/web/src/features/walletconnect/components/WcInput/index.tsx @@ -1,11 +1,10 @@ import Track from '@/components/common/Track' -import { WCLoadingState } from '@/features/walletconnect/components/WalletConnectProvider' import { isPairingUri } from '@/features/walletconnect/services/utils' -import { WalletConnectContext } from '@/features/walletconnect/WalletConnectContext' +import { WalletConnectContext, WCLoadingState } from '@/features/walletconnect/WalletConnectContext' import useDebounce from '@/hooks/useDebounce' import { trackEvent } from '@/services/analytics' import { WALLETCONNECT_EVENTS } from '@/services/analytics/events/walletconnect' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { getClipboard, isClipboardSupported } from '@/utils/clipboard' import { Button, CircularProgress, InputAdornment, TextField } from '@mui/material' import { useCallback, useContext, useEffect, useState } from 'react' @@ -24,7 +23,7 @@ const useTrackErrors = (error?: Error) => { } const WcInput = ({ uri }: { uri: string }) => { - const { walletConnect, isLoading, setIsLoading, setError } = useContext(WalletConnectContext) + const { walletConnect, loading, setLoading, setError } = useContext(WalletConnectContext) const [value, setValue] = useState('') const [inputError, setInputError] = useState<Error>() useTrackErrors(inputError) @@ -44,27 +43,29 @@ const WcInput = ({ uri }: { uri: string }) => { if (!val) return - setIsLoading(WCLoadingState.CONNECT) + setLoading(WCLoadingState.CONNECT) try { await walletConnect.connect(val) } catch (e) { setInputError(asError(e)) - setIsLoading(undefined) + setLoading(null) } setTimeout(() => { - if (isLoading && isLoading !== WCLoadingState.APPROVE) { - setIsLoading(undefined) + if (loading && loading !== WCLoadingState.APPROVE) { + setLoading(null) setError(new Error('Connection timed out')) } }, PROPOSAL_TIMEOUT) }, - [isLoading, setError, setIsLoading, walletConnect], + [loading, setError, setLoading, walletConnect], ) // Insert a pre-filled uri useEffect(() => { - onInput(uri) + if (uri) { + onInput(uri) + } }, [onInput, uri]) const onPaste = useCallback(async () => { @@ -78,12 +79,13 @@ const WcInput = ({ uri }: { uri: string }) => { return ( <TextField + data-testid="wc-input" value={value} onChange={(e) => onInput(e.target.value)} fullWidth autoComplete="off" autoFocus - disabled={!!isLoading} + disabled={!!loading} error={!!inputError} label={inputError ? inputError.message : 'Pairing code'} placeholder="wc:" @@ -93,8 +95,8 @@ const WcInput = ({ uri }: { uri: string }) => { endAdornment: isClipboardSupported() ? undefined : ( <InputAdornment position="end"> <Track {...WALLETCONNECT_EVENTS.PASTE_CLICK}> - <Button variant="contained" onClick={onPaste} sx={{ py: 1 }} disabled={!!isLoading}> - {isLoading === WCLoadingState.CONNECT || isLoading === WCLoadingState.APPROVE ? ( + <Button variant="contained" onClick={onPaste} sx={{ py: 1 }} disabled={!!loading}> + {loading === WCLoadingState.CONNECT || loading === WCLoadingState.APPROVE ? ( <CircularProgress size={20} /> ) : ( 'Paste' diff --git a/apps/web/src/features/walletconnect/components/WcLogoHeader/index.test.tsx b/apps/web/src/features/walletconnect/components/WcLogoHeader/index.test.tsx new file mode 100644 index 0000000000..68327d8762 --- /dev/null +++ b/apps/web/src/features/walletconnect/components/WcLogoHeader/index.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react' +import WcLogoHeader from '.' +import { BRAND_NAME } from '@/config/constants' + +describe('WcLogoHeader component tests', () => { + it('Verify default header is rendered correctly', () => { + render(<WcLogoHeader />) + + expect(screen.getByTestId('wc-icon')).toBeVisible() + + const title = screen.getByTestId('wc-title') + expect(title).toBeVisible() + expect(title).toHaveTextContent(`Connect dApps to ${BRAND_NAME}`) + expect(screen.queryByTestId('wc-alert')).not.toBeInTheDocument() + }) + + it('Verify header error state is rendered correctly', () => { + const errorMessage = 'Connection failed' + render(<WcLogoHeader errorMessage={errorMessage} />) + + expect(screen.getByTestId('wc-icon')).toBeVisible() + expect(screen.queryByTestId('wc-alert')).toBeVisible() + const title = screen.getByTestId('wc-title') + expect(title).toBeVisible() + expect(title).toHaveTextContent(errorMessage) + }) +}) diff --git a/apps/web/src/features/walletconnect/components/WcLogoHeader/index.tsx b/apps/web/src/features/walletconnect/components/WcLogoHeader/index.tsx index cd849e04b3..0bac6d09ce 100644 --- a/apps/web/src/features/walletconnect/components/WcLogoHeader/index.tsx +++ b/apps/web/src/features/walletconnect/components/WcLogoHeader/index.tsx @@ -9,11 +9,19 @@ const WcLogoHeader = ({ errorMessage }: { errorMessage?: string }): ReactElement return ( <> <div> - <SvgIcon component={WalletConnect} inheritViewBox className={css.icon} /> - {errorMessage && <SvgIcon component={Alert} inheritViewBox className={css.errorBadge} fontSize="small" />} + <SvgIcon data-testid="wc-icon" component={WalletConnect} inheritViewBox className={css.icon} /> + {errorMessage && ( + <SvgIcon + data-testid="wc-alert" + component={Alert} + inheritViewBox + className={css.errorBadge} + fontSize="small" + /> + )} </div> - <Typography variant="h5" mt={2} mb={0.5} className={css.title}> + <Typography data-testid="wc-title" variant="h5" mt={2} mb={0.5} className={css.title}> {errorMessage || `Connect dApps to ${BRAND_NAME}`} </Typography> </> diff --git a/apps/web/src/features/walletconnect/components/WcProposalForm/CompatibilityWarning.tsx b/apps/web/src/features/walletconnect/components/WcProposalForm/CompatibilityWarning.tsx index 78c7ef7211..7b7a204fb1 100644 --- a/apps/web/src/features/walletconnect/components/WcProposalForm/CompatibilityWarning.tsx +++ b/apps/web/src/features/walletconnect/components/WcProposalForm/CompatibilityWarning.tsx @@ -30,7 +30,7 @@ export const CompatibilityWarning = ({ Supported networks </Typography> - <Stack direction="row"> + <Stack direction="row" className={css.chainContainer}> {chainIds.map((chainId) => ( <ChainIndicator inline chainId={chainId} key={chainId} className={css.chain} /> ))} diff --git a/apps/web/src/features/walletconnect/components/WcProposalForm/index.tsx b/apps/web/src/features/walletconnect/components/WcProposalForm/index.tsx index 41e73e0faf..90dfefa9ca 100644 --- a/apps/web/src/features/walletconnect/components/WcProposalForm/index.tsx +++ b/apps/web/src/features/walletconnect/components/WcProposalForm/index.tsx @@ -1,5 +1,5 @@ import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard' -import { WCLoadingState } from '@/features/walletconnect/components/WalletConnectProvider' +import { WCLoadingState } from '@/features/walletconnect/WalletConnectContext' import { getPeerName, getSupportedChainIds, @@ -13,11 +13,10 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { trackEvent } from '@/services/analytics' import { WALLETCONNECT_EVENTS } from '@/services/analytics/events/walletconnect' -import { asError } from '@/services/exceptions/utils' import { Button, Checkbox, CircularProgress, Divider, FormControlLabel, Typography } from '@mui/material' import type { WalletKitTypes } from '@reown/walletkit' import type { ChangeEvent, ReactElement } from 'react' -import { type Dispatch, type SetStateAction, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { CompatibilityWarning } from './CompatibilityWarning' import ProposalVerification from './ProposalVerification' import css from './styles.module.css' @@ -26,12 +25,12 @@ import BlockedAddress from '@/components/common/BlockedAddress' type ProposalFormProps = { proposal: WalletKitTypes.SessionProposal - setProposal: Dispatch<SetStateAction<WalletKitTypes.SessionProposal | undefined>> - onApprove: () => void + onApprove: () => Promise<void> + onReject: () => Promise<void> } -const WcProposalForm = ({ proposal, setProposal, onApprove }: ProposalFormProps): ReactElement => { - const { walletConnect, setError, setIsLoading, isLoading } = useContext(WalletConnectContext) +const WcProposalForm = ({ proposal, onApprove, onReject }: ProposalFormProps): ReactElement => { + const { loading } = useContext(WalletConnectContext) const { configs } = useChains() const { safeLoaded, safe } = useSafeInfo() @@ -55,29 +54,9 @@ const WcProposalForm = ({ proposal, setProposal, onApprove }: ProposalFormProps) isUnsupportedChain || isBlocked || (isHighRisk && !understandsRisk) || - !!isLoading || + !!loading || (Boolean(sanctionedAddress) && isSafePass) - // On session reject - const onReject = useCallback(async () => { - if (!walletConnect || !proposal) return - - const label = proposal?.params.proposer.metadata.url - trackEvent({ ...WALLETCONNECT_EVENTS.REJECT_CLICK, label }) - - setIsLoading(WCLoadingState.REJECT) - - try { - await walletConnect.rejectSession(proposal) - } catch (e) { - setIsLoading(undefined) - setError(asError(e)) - } - - setIsLoading(undefined) - setProposal(undefined) - }, [walletConnect, proposal, setIsLoading, setProposal, setError]) - const onCheckboxClick = useCallback( (_: ChangeEvent, checked: boolean) => { setUnderstandsRisk(checked) @@ -138,7 +117,7 @@ const WcProposalForm = ({ proposal, setProposal, onApprove }: ProposalFormProps) <CompatibilityWarning proposal={proposal} chainIds={chainIds} /> </div> - {!isBlocked && isHighRisk && ( + {!isBlocked && isHighRisk && !isUnsupportedChain && ( <FormControlLabel className={css.checkbox} control={<Checkbox checked={understandsRisk} onChange={onCheckboxClick} />} @@ -153,12 +132,19 @@ const WcProposalForm = ({ proposal, setProposal, onApprove }: ProposalFormProps) <Divider flexItem className={css.divider} /> <div className={css.buttons}> - <Button variant="danger" onClick={onReject} className={css.button} disabled={!!isLoading}> - {isLoading === WCLoadingState.REJECT ? <CircularProgress size={20} /> : 'Reject'} - </Button> - - <Button variant="contained" onClick={() => onApprove()} className={css.button} disabled={disabled}> - {isLoading === WCLoadingState.APPROVE ? <CircularProgress size={20} /> : 'Approve'} + {!isUnsupportedChain && ( + <Button variant="contained" onClick={onApprove} className={css.button} disabled={disabled}> + {loading === WCLoadingState.APPROVE ? <CircularProgress size={20} /> : 'Approve'} + </Button> + )} + + <Button + variant={isUnsupportedChain ? 'text' : 'danger'} + onClick={onReject} + className={css.button} + disabled={!!loading} + > + {loading === WCLoadingState.REJECT ? <CircularProgress size={20} /> : isUnsupportedChain ? 'Close' : 'Reject'} </Button> </div> </div> diff --git a/apps/web/src/features/walletconnect/components/WcProposalForm/styles.module.css b/apps/web/src/features/walletconnect/components/WcProposalForm/styles.module.css index 149bb5d2bb..d9ba219177 100644 --- a/apps/web/src/features/walletconnect/components/WcProposalForm/styles.module.css +++ b/apps/web/src/features/walletconnect/components/WcProposalForm/styles.module.css @@ -10,7 +10,24 @@ } .chain { - margin: 2px; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.chainContainer { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + width: 100%; + margin: 0 auto; + gap: 10px; + padding: 0; } .origin { @@ -23,6 +40,10 @@ display: flex; flex-direction: column; gap: var(--space-1); + width: 100%; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: normal; } .alert { @@ -43,6 +64,8 @@ width: 100%; display: flex; justify-content: space-between; + /* The button order is reversed to prioritize the primary action on the right for better UX. */ + flex-direction: row-reverse; } .button { diff --git a/apps/web/src/features/walletconnect/components/WcProposalForm/useCompatibilityWarning.ts b/apps/web/src/features/walletconnect/components/WcProposalForm/useCompatibilityWarning.ts index 84bd2e40ba..d502f056be 100644 --- a/apps/web/src/features/walletconnect/components/WcProposalForm/useCompatibilityWarning.ts +++ b/apps/web/src/features/walletconnect/components/WcProposalForm/useCompatibilityWarning.ts @@ -3,7 +3,7 @@ import type { AlertColor } from '@mui/material' import type { WalletKitTypes } from '@reown/walletkit' import useChains from '@/hooks/useChains' import useSafeInfo from '@/hooks/useSafeInfo' -import { capitalize } from '@/utils/formatters' +import { capitalize } from '@safe-global/utils/utils/formatters' import { getPeerName, isBlockedBridge, isWarnedBridge } from '@/features/walletconnect/services/utils' import { BRAND_NAME } from '@/config/constants' diff --git a/apps/web/src/features/walletconnect/components/WcSessionList/index.tsx b/apps/web/src/features/walletconnect/components/WcSessionList/index.tsx index 7d7af63c62..15ccd29d23 100644 --- a/apps/web/src/features/walletconnect/components/WcSessionList/index.tsx +++ b/apps/web/src/features/walletconnect/components/WcSessionList/index.tsx @@ -1,11 +1,10 @@ import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard' -import { WCLoadingState } from '@/features/walletconnect/components/WalletConnectProvider' import { getPeerName } from '@/features/walletconnect/services/utils' -import { WalletConnectContext } from '@/features/walletconnect/WalletConnectContext' +import { WalletConnectContext, WCLoadingState } from '@/features/walletconnect/WalletConnectContext' import useSafeInfo from '@/hooks/useSafeInfo' import { trackEvent } from '@/services/analytics' import { WALLETCONNECT_EVENTS } from '@/services/analytics/events/walletconnect' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { Button, CircularProgress, List, ListItem, ListItemAvatar, ListItemIcon, ListItemText } from '@mui/material' import type { SessionTypes } from '@walletconnect/types' import { useCallback, useContext } from 'react' @@ -17,7 +16,7 @@ type WcSesstionListProps = { } const WcSessionListItem = ({ session }: { session: SessionTypes.Struct }) => { - const { walletConnect, setError, isLoading, setIsLoading } = useContext(WalletConnectContext) + const { walletConnect, setError, loading, setLoading } = useContext(WalletConnectContext) const MAX_NAME_LENGTH = 23 const { safeLoaded } = useSafeInfo() @@ -33,17 +32,17 @@ const WcSessionListItem = ({ session }: { session: SessionTypes.Struct }) => { const label = session.peer.metadata.url trackEvent({ ...WALLETCONNECT_EVENTS.DISCONNECT_CLICK, label }) - setIsLoading(WCLoadingState.DISCONNECT) + setLoading(WCLoadingState.DISCONNECT) try { await walletConnect.disconnectSession(session) } catch (error) { - setIsLoading(undefined) + setLoading(null) setError(asError(error)) } - setIsLoading(undefined) - }, [walletConnect, session, setIsLoading, setError]) + setLoading(null) + }, [walletConnect, session, setLoading, setError]) return ( <ListItem className={css.sessionListItem}> @@ -56,8 +55,8 @@ const WcSessionListItem = ({ session }: { session: SessionTypes.Struct }) => { <ListItemText primary={name} primaryTypographyProps={{ color: safeLoaded ? undefined : 'text.secondary' }} /> <ListItemIcon className={css.sessionListSecondaryAction}> - <Button variant="danger" onClick={onDisconnect} className={css.button} disabled={!!isLoading}> - {isLoading === WCLoadingState.DISCONNECT ? <CircularProgress size={20} /> : 'Disconnect'} + <Button variant="danger" onClick={onDisconnect} className={css.button} disabled={!!loading}> + {loading === WCLoadingState.DISCONNECT ? <CircularProgress size={20} /> : 'Disconnect'} </Button> </ListItemIcon> </ListItem> diff --git a/apps/web/src/features/walletconnect/components/WcSessionManager/index.tsx b/apps/web/src/features/walletconnect/components/WcSessionManager/index.tsx index 5109d7a50e..29793b7b9b 100644 --- a/apps/web/src/features/walletconnect/components/WcSessionManager/index.tsx +++ b/apps/web/src/features/walletconnect/components/WcSessionManager/index.tsx @@ -1,106 +1,56 @@ -import { WCLoadingState } from '@/features/walletconnect/components/WalletConnectProvider' -import useSafeInfo from '@/hooks/useSafeInfo' -import { asError } from '@/services/exceptions/utils' -import useLocalStorage from '@/services/local-storage/useLocalStorage' -import { useCallback, useContext, useEffect, useState } from 'react' -import type { WalletKitTypes } from '@reown/walletkit' -import type { SessionTypes } from '@walletconnect/types' +import { useCallback, useContext, useEffect } from 'react' import { WalletConnectContext } from '@/features/walletconnect/WalletConnectContext' import WcConnectionForm from '../WcConnectionForm' import WcErrorMessage from '../WcErrorMessage' -import WcProposalForm from '../WcProposalForm' import { trackEvent } from '@/services/analytics' import { WALLETCONNECT_EVENTS } from '@/services/analytics/events/walletconnect' import { splitError } from '@/features/walletconnect/services/utils' +import WcProposalForm from '../WcProposalForm' type WcSessionManagerProps = { - sessions: SessionTypes.Struct[] uri: string } -// chainId -> origin -> boolean -type WcAutoApproveProps = Record<string, Record<string, boolean>> +const WcSessionManager = ({ uri }: WcSessionManagerProps) => { + const { sessions, sessionProposal, error, setError, open, approveSession, rejectSession } = + useContext(WalletConnectContext) -const WC_AUTO_APPROVE_KEY = 'wcAutoApprove' + // On session approve + const onApprove = useCallback(async () => { + if (!sessionProposal) return -const WcSessionManager = ({ sessions, uri }: WcSessionManagerProps) => { - const [autoApprove = {}, setAutoApprove] = useLocalStorage<WcAutoApproveProps>(WC_AUTO_APPROVE_KEY) - const { walletConnect, error, setError, open, setOpen, setIsLoading } = useContext(WalletConnectContext) - const { safe, safeAddress } = useSafeInfo() - const { chainId } = safe - const [proposal, setProposal] = useState<WalletKitTypes.SessionProposal>() + const label = sessionProposal.params.proposer.metadata.url + trackEvent({ ...WALLETCONNECT_EVENTS.APPROVE_CLICK, label }) - // On session approve - const onApprove = useCallback( - async (proposalData?: WalletKitTypes.SessionProposal) => { - const sessionProposal = proposalData || proposal - - if (!walletConnect || !chainId || !safeAddress || !sessionProposal) return - - const label = sessionProposal?.params.proposer.metadata.url - trackEvent({ ...WALLETCONNECT_EVENTS.APPROVE_CLICK, label }) - - setIsLoading(WCLoadingState.APPROVE) - - try { - await walletConnect.approveSession(sessionProposal, chainId, safeAddress, { - capabilities: JSON.stringify({ - [safeAddress]: { - [`0x${Number(chainId).toString(16)}`]: { - atomicBatch: { - supported: true, - }, - }, - }, - }), - }) - - // Auto approve future sessions for non-malicious dApps - if ( - sessionProposal.verifyContext.verified.validation !== 'INVALID' && - !sessionProposal.verifyContext.verified.isScam - ) { - setAutoApprove((prev) => ({ - ...prev, - [chainId]: { ...prev?.[chainId], [sessionProposal.verifyContext.verified.origin]: true }, - })) - } - - setOpen(false) - } catch (e) { - setIsLoading(undefined) - setError(asError(e)) - return - } - - trackEvent({ ...WALLETCONNECT_EVENTS.CONNECTED, label }) - setIsLoading(undefined) - setProposal(undefined) - }, - [proposal, walletConnect, chainId, safeAddress, setIsLoading, setOpen, setAutoApprove, setError], - ) + try { + await approveSession() + } catch (e) { + setError(e as Error) + return + } + + trackEvent({ ...WALLETCONNECT_EVENTS.CONNECTED, label }) + }, [sessionProposal, approveSession, setError]) + + // On session reject + const onReject = useCallback(async () => { + if (!sessionProposal) return + + const label = sessionProposal.params.proposer.metadata.url + trackEvent({ ...WALLETCONNECT_EVENTS.REJECT_CLICK, label }) + + try { + await rejectSession() + } catch (e) { + setError(e as Error) + } + }, [sessionProposal, rejectSession, setError]) // Reset error const onErrorReset = useCallback(() => { setError(null) }, [setError]) - // Subscribe to session proposals - useEffect(() => { - if (!walletConnect) return - return walletConnect.onSessionPropose((proposalData) => { - setError(null) - - if (autoApprove[chainId]?.[proposalData.verifyContext.verified.origin]) { - onApprove(proposalData) - return - } - - setProposal(proposalData) - setIsLoading(undefined) - }) - }, [autoApprove, chainId, onApprove, setError, setIsLoading, walletConnect]) - // Track errors useEffect(() => { if (error) { @@ -119,8 +69,8 @@ const WcSessionManager = ({ sessions, uri }: WcSessionManagerProps) => { } // Session proposal - if (proposal) { - return <WcProposalForm proposal={proposal} setProposal={setProposal} onApprove={onApprove} /> + if (sessionProposal) { + return <WcProposalForm proposal={sessionProposal} onApprove={onApprove} onReject={onReject} /> } // Connection form (initial state) diff --git a/apps/web/src/features/walletconnect/components/index.tsx b/apps/web/src/features/walletconnect/components/index.tsx index bc116d2358..2527cfa0ce 100644 --- a/apps/web/src/features/walletconnect/components/index.tsx +++ b/apps/web/src/features/walletconnect/components/index.tsx @@ -1,5 +1,5 @@ import dynamic from 'next/dynamic' -import ExternalStore from '@/services/ExternalStore' +import ExternalStore from '@safe-global/utils/services/ExternalStore' const WalletConnectUi = dynamic(() => import('./WalletConnectUi')) diff --git a/apps/web/src/features/walletconnect/hooks/__tests__/useWalletConnectSessions.test.tsx b/apps/web/src/features/walletconnect/hooks/__tests__/useWalletConnectSessions.test.tsx deleted file mode 100644 index ddaea96b8d..0000000000 --- a/apps/web/src/features/walletconnect/hooks/__tests__/useWalletConnectSessions.test.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { renderHook, waitFor } from '@/tests/test-utils' -import { WalletConnectContext } from '@/features/walletconnect/WalletConnectContext' -import useWalletConnectSessions from '../useWalletConnectSessions' -import type WalletConnectWallet from '../../services/WalletConnectWallet' - -describe('useWalletConnectSessions', () => { - it('should return an array of active sessions', () => { - const sessions = [ - { - topic: 'topic1', - chainId: 1, - key: 'key1', - metadata: { - name: 'session1', - }, - }, - { - topic: 'topic2', - chainId: 1, - key: 'key2', - metadata: { - name: 'session2', - }, - }, - ] - - const mockWalletConnect = { - getActiveSessions: jest.fn().mockReturnValue(sessions), - onSessionAdd: jest.fn(), - onSessionDelete: jest.fn(), - } as unknown as WalletConnectWallet - - const wrapper = ({ children }: any) => ( - <WalletConnectContext.Provider - value={{ - walletConnect: mockWalletConnect, - error: null, - setError: () => {}, - open: false, - setOpen: () => {}, - isLoading: undefined, - setIsLoading: () => {}, - }} - > - {children} - </WalletConnectContext.Provider> - ) - - const { result } = renderHook(() => useWalletConnectSessions(), { wrapper }) - - expect(result.current).toEqual(sessions) - }) - - it('should update sessions when a session is added', async () => { - const mockWalletConnect = { - getActiveSessions: jest.fn().mockReturnValue([]), - onSessionAdd: jest.fn(), - onSessionDelete: jest.fn(), - } as unknown as WalletConnectWallet - - const wrapper = ({ children }: any) => ( - <WalletConnectContext.Provider - value={{ - walletConnect: mockWalletConnect, - error: null, - setError: () => {}, - open: false, - setOpen: () => {}, - isLoading: undefined, - setIsLoading: () => {}, - }} - > - {children} - </WalletConnectContext.Provider> - ) - - const { result } = renderHook(() => useWalletConnectSessions(), { wrapper }) - - expect(result.current).toEqual([]) - - const updateSessions = (mockWalletConnect.onSessionAdd as jest.Mock).mock.calls[0][0] - - ;(mockWalletConnect.getActiveSessions as jest.Mock).mockReturnValue([ - { - topic: 'topic1', - chainId: 1, - key: 'key1', - metadata: { - name: 'session1', - }, - }, - ]) - - updateSessions() - - expect(mockWalletConnect.getActiveSessions).toHaveBeenCalled() - - await waitFor(() => { - expect(result.current).toEqual([ - { - topic: 'topic1', - chainId: 1, - key: 'key1', - metadata: { - name: 'session1', - }, - }, - ]) - }) - }) - - it('should update sessions when a session is deleted', async () => { - const mockWalletConnect = { - getActiveSessions: jest.fn().mockReturnValue([]), - onSessionAdd: jest.fn(), - onSessionDelete: jest.fn(), - } as unknown as WalletConnectWallet - - const wrapper = ({ children }: any) => ( - <WalletConnectContext.Provider - value={{ - walletConnect: mockWalletConnect, - error: null, - setError: () => {}, - open: false, - setOpen: () => {}, - isLoading: undefined, - setIsLoading: () => {}, - }} - > - {children} - </WalletConnectContext.Provider> - ) - - const { result } = renderHook(() => useWalletConnectSessions(), { wrapper }) - - expect(result.current).toEqual([]) - - const updateSessions = (mockWalletConnect.onSessionDelete as jest.Mock).mock.calls[0][0] - - ;(mockWalletConnect.getActiveSessions as jest.Mock).mockReturnValue([ - { - topic: 'topic1', - chainId: 1, - key: 'key1', - metadata: { - name: 'session1', - }, - }, - ]) - - updateSessions() - - expect(mockWalletConnect.getActiveSessions).toHaveBeenCalled() - - await waitFor(() => { - expect(result.current).toEqual([ - { - topic: 'topic1', - chainId: 1, - key: 'key1', - metadata: { - name: 'session1', - }, - }, - ]) - }) - }) -}) diff --git a/apps/web/src/features/walletconnect/hooks/useWalletConnectSessions.ts b/apps/web/src/features/walletconnect/hooks/useWalletConnectSessions.ts deleted file mode 100644 index e51cf193d4..0000000000 --- a/apps/web/src/features/walletconnect/hooks/useWalletConnectSessions.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useContext, useEffect, useCallback, useState } from 'react' -import type { SessionTypes } from '@walletconnect/types' -import { WalletConnectContext } from '@/features/walletconnect/WalletConnectContext' - -function useWalletConnectSessions(): SessionTypes.Struct[] { - const { walletConnect } = useContext(WalletConnectContext) - const [sessions, setSessions] = useState<SessionTypes.Struct[]>([]) - - const updateSessions = useCallback(() => { - if (!walletConnect) return - setSessions(walletConnect.getActiveSessions()) - }, [walletConnect]) - - // Initial sessions - useEffect(updateSessions, [updateSessions]) - - // On session add - useEffect(() => { - if (!walletConnect) return - return walletConnect.onSessionAdd(updateSessions) - }, [walletConnect, updateSessions]) - - // On session delete - useEffect(() => { - if (!walletConnect) return - return walletConnect.onSessionDelete(updateSessions) - }, [walletConnect, updateSessions]) - - return sessions -} - -export default useWalletConnectSessions diff --git a/apps/web/src/features/walletconnect/services/WalletConnectWallet.ts b/apps/web/src/features/walletconnect/services/WalletConnectWallet.ts index 62aef73ce9..b05de6315e 100644 --- a/apps/web/src/features/walletconnect/services/WalletConnectWallet.ts +++ b/apps/web/src/features/walletconnect/services/WalletConnectWallet.ts @@ -1,6 +1,6 @@ import { Core } from '@walletconnect/core' import { WalletKit, type WalletKitTypes } from '@reown/walletkit' -import { buildApprovedNamespaces, getSdkError } from '@walletconnect/utils' +import { buildApprovedNamespaces, buildAuthObject, getSdkError } from '@walletconnect/utils' import type Web3WalletType from '@reown/walletkit' import type { ProposalTypes, SessionTypes } from '@walletconnect/types' import { type JsonRpcResponse } from '@walletconnect/jsonrpc-utils' @@ -8,8 +8,8 @@ import uniq from 'lodash/uniq' import { IS_PRODUCTION, LS_NAMESPACE, WC_PROJECT_ID } from '@/config/constants' import { EIP155, SAFE_COMPATIBLE_EVENTS, SAFE_COMPATIBLE_METHODS, SAFE_WALLET_METADATA } from '../constants' -import { invariant } from '@/utils/helpers' import { getEip155ChainId, stripEip155Prefix } from './utils' +import { invariant } from '@safe-global/utils/utils/helpers' const SESSION_ADD_EVENT = 'session_add' as WalletKitTypes.Event // Workaround: WalletConnect doesn't emit session_add event const SESSION_REJECT_EVENT = 'session_reject' as WalletKitTypes.Event // Workaround: WalletConnect doesn't emit session_reject event @@ -288,6 +288,70 @@ class WalletConnectWallet { return await this.web3Wallet.respondSessionRequest({ topic, response }) } + + /** + * Subscribe to SiWE requests + */ + public onSessionAuth(handler: (e: WalletKitTypes.SessionAuthenticate) => void) { + // Subscribe to the session auth event + this.web3Wallet?.on('session_authenticate', handler) + + // Return the unsubscribe function + return () => { + this.web3Wallet?.off('session_authenticate', handler) + } + } + + /** + * Format SiWE message + */ + public formatAuthMessage( + authPayload: WalletKitTypes.SessionAuthenticate['params']['authPayload'], + chainId: string, + address: string, + ): string { + assertWeb3Wallet(this.web3Wallet) + + return this.web3Wallet.formatAuthMessage({ + request: authPayload, + iss: `${getEip155ChainId(chainId)}:${address}`, + }) + } + + public async approveSessionAuth( + eventId: number, + authPayload: WalletKitTypes.SessionAuthenticate['params']['authPayload'], + signature: string, + chainId: string, + address: string, + ) { + assertWeb3Wallet(this.web3Wallet) + + const auth = buildAuthObject( + authPayload, + { + t: 'eip1271', + s: signature, + }, + `${getEip155ChainId(chainId)}:${address}`, + ) + + const resp = await this.web3Wallet.approveSessionAuthenticate({ + id: eventId, + auths: [auth], + }) + + this.web3Wallet?.events.emit(SESSION_ADD_EVENT, resp.session) + } + + public async rejectSessionAuth(eventId: number) { + assertWeb3Wallet(this.web3Wallet) + + return this.web3Wallet.rejectSessionAuthenticate({ + id: eventId, + reason: getSdkError('USER_REJECTED'), + }) + } } export default WalletConnectWallet diff --git a/apps/web/src/hooks/__tests__/useAsync.test.ts b/apps/web/src/hooks/__tests__/useAsync.test.ts index 1e167ba39c..a46d4203e3 100644 --- a/apps/web/src/hooks/__tests__/useAsync.test.ts +++ b/apps/web/src/hooks/__tests__/useAsync.test.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { act, renderHook } from '@/tests/test-utils' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { waitFor } from '@testing-library/react' // Jest tests for the useAsync hook diff --git a/apps/web/src/hooks/__tests__/useBalances.test.ts b/apps/web/src/hooks/__tests__/useBalances.test.ts deleted file mode 100644 index e8db756186..0000000000 --- a/apps/web/src/hooks/__tests__/useBalances.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { type SafeBalanceResponse, TokenType } from '@safe-global/safe-gateway-typescript-sdk' -import * as store from '@/store' -import { renderHook } from '@/tests/test-utils' -import useBalances from '../useBalances' -import { toBeHex } from 'ethers' - -describe('useBalances', () => { - test('empty balance', () => { - const balance: SafeBalanceResponse = { - fiatTotal: '0', - items: [], - } - jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => - selector({ - balances: { data: balance, error: undefined, loading: false }, - } as store.RootState), - ) - - const { result } = renderHook(() => useBalances()) - - expect(result.current.balances.fiatTotal).toEqual('0') - expect(result.current.balances.items).toHaveLength(0) - }) - - test('return all balances', () => { - const tokenAddress = toBeHex('0x2', 20) - const balance: SafeBalanceResponse = { - fiatTotal: '100', - items: [ - { - balance: '40', - fiatBalance: '40', - fiatConversion: '1', - tokenInfo: { - address: tokenAddress, - decimals: 18, - logoUri: '', - name: 'Hidden Token', - symbol: 'HT', - type: TokenType.ERC20, - }, - }, - { - balance: '60', - fiatBalance: '60', - fiatConversion: '1', - tokenInfo: { - address: tokenAddress, - decimals: 18, - logoUri: '', - name: 'Visible Token', - symbol: 'VT', - type: TokenType.ERC20, - }, - }, - ], - } - - jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => - selector({ - balances: { data: balance, error: undefined, loading: false }, - } as store.RootState), - ) - - const { result } = renderHook(() => useBalances()) - - expect(result.current.balances.fiatTotal).toEqual('100') - expect(result.current.balances.items).toHaveLength(2) - }) -}) diff --git a/apps/web/src/hooks/__tests__/useGasLimit.test.ts b/apps/web/src/hooks/__tests__/useGasLimit.test.ts index c63d061381..c0f00b5192 100644 --- a/apps/web/src/hooks/__tests__/useGasLimit.test.ts +++ b/apps/web/src/hooks/__tests__/useGasLimit.test.ts @@ -12,7 +12,7 @@ import { connectedWalletBuilder } from '@/tests/builders/wallet' import { createMockSafeTransaction } from '@/tests/transactions' import { safeInfoBuilder } from '@/tests/builders/safe' import { type JsonRpcProvider, zeroPadValue } from 'ethers' -import { Gnosis_safe__factory } from '@/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' +import { Gnosis_safe__factory } from '@safe-global/utils/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' import { generatePreValidatedSignature } from '@safe-global/protocol-kit/dist/src/utils' const contractManager = mockContractManager() diff --git a/apps/web/src/hooks/__tests__/useHasUntrustedFallbackHandler.test.ts b/apps/web/src/hooks/__tests__/useHasUntrustedFallbackHandler.test.ts new file mode 100644 index 0000000000..95b98c590b --- /dev/null +++ b/apps/web/src/hooks/__tests__/useHasUntrustedFallbackHandler.test.ts @@ -0,0 +1,112 @@ +import { renderHook } from '@/tests/test-utils' +import { useHasUntrustedFallbackHandler } from '../useHasUntrustedFallbackHandler' +import { useTWAPFallbackHandlerAddress } from '@/features/swap/hooks/useIsTWAPFallbackHandler' +import useSafeInfo from '@/hooks/useSafeInfo' +import { TWAP_FALLBACK_HANDLER } from '@/features/swap/helpers/utils' +import { faker } from '@faker-js/faker' +import { getCompatibilityFallbackHandlerDeployment } from '@safe-global/safe-deployments' +import { safeInfoBuilder } from '@/tests/builders/safe' + +jest.mock('@/hooks/useCompatibilityFallbackHandlerDeployments') +jest.mock('@/hooks/useSafeInfo') +jest.mock('@/features/swap/hooks/useIsTWAPFallbackHandler') + +const fallbackHandlerAddress = getCompatibilityFallbackHandlerDeployment({ + network: '1', + version: '1.4.1', +})?.defaultAddress! + +describe('useHasUntrustedFallbackHandler', () => { + beforeEach(() => { + ;(useSafeInfo as jest.Mock).mockReturnValue({ safe: safeInfoBuilder().with({ chainId: '1' }).build() }) + ;(useTWAPFallbackHandlerAddress as jest.Mock).mockReturnValue(TWAP_FALLBACK_HANDLER) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('should return `false`', () => { + it('if current Safe`s fallback handler is an official one', () => { + ;(useSafeInfo as jest.Mock).mockReturnValue({ + safe: safeInfoBuilder() + .with({ fallbackHandler: { value: fallbackHandlerAddress } }) + .build(), + }) + const { result } = renderHook(() => useHasUntrustedFallbackHandler()) + + expect(result.current).toBe(false) + }) + + it('if the provided fallback handler is an official one', () => { + const { result } = renderHook(() => useHasUntrustedFallbackHandler(fallbackHandlerAddress)) + + expect(result.current).toBe(false) + }) + + it('if the provided fallback handler is the TWAPFallbackHandler', () => { + const { result } = renderHook(() => useHasUntrustedFallbackHandler(TWAP_FALLBACK_HANDLER)) + + expect(result.current).toBe(false) + }) + + it('if all provided fallback handler addresses are trusted', () => { + const { result } = renderHook(() => + useHasUntrustedFallbackHandler([fallbackHandlerAddress, TWAP_FALLBACK_HANDLER]), + ) + + expect(result.current).toBe(false) + }) + + it('if there is no fallback handler address', () => { + ;(useSafeInfo as jest.Mock).mockReturnValue({ safe: { fallbackHandler: { value: undefined }, chainId: '1' } }) + + const { result } = renderHook(() => useHasUntrustedFallbackHandler()) + + expect(result.current).toBe(false) + }) + }) + + describe('should return `true`', () => { + it('if the current Safe`s fallback handler is not an official one', () => { + ;(useSafeInfo as jest.Mock).mockReturnValue({ + safe: { fallbackHandler: { value: faker.finance.ethereumAddress() }, chainId: '1' }, + }) + + const { result } = renderHook(() => useHasUntrustedFallbackHandler()) + + expect(result.current).toBe(true) + }) + + it('if the TWAPFallbackHandler is undefined', () => { + ;(useTWAPFallbackHandlerAddress as jest.Mock).mockReturnValue(undefined) + + const { result } = renderHook(() => useHasUntrustedFallbackHandler(TWAP_FALLBACK_HANDLER)) + + expect(result.current).toBe(true) + }) + + it('if the provided fallback handler is not an official one', () => { + const { result } = renderHook(() => useHasUntrustedFallbackHandler(faker.finance.ethereumAddress())) + + expect(result.current).toBe(true) + }) + + it('if any provided fallback handler addresses is untrusted', () => { + ;(useSafeInfo as jest.Mock).mockReturnValue({ + safe: safeInfoBuilder() + .with({ fallbackHandler: { value: fallbackHandlerAddress } }) + .build(), + }) + const { result } = renderHook(() => + useHasUntrustedFallbackHandler([ + faker.finance.ethereumAddress(), + fallbackHandlerAddress, + TWAP_FALLBACK_HANDLER, + ]), + ) + + expect(result.current).toBe(true) + }) + }) +}) diff --git a/apps/web/src/hooks/__tests__/useIsOfficialFallbackHandler.test.ts b/apps/web/src/hooks/__tests__/useIsOfficialFallbackHandler.test.ts deleted file mode 100644 index e27f2fa27b..0000000000 --- a/apps/web/src/hooks/__tests__/useIsOfficialFallbackHandler.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { renderHook } from '@/tests/test-utils' -import { useIsOfficialFallbackHandler } from '../useIsOfficialFallbackHandler' -import { useCompatibilityFallbackHandlerDeployments } from '@/hooks/useCompatibilityFallbackHandlerDeployments' -import useSafeInfo from '@/hooks/useSafeInfo' - -jest.mock('@/hooks/useCompatibilityFallbackHandlerDeployments') -jest.mock('@/hooks/useSafeInfo') - -describe('useIsOfficialFallbackHandler', () => { - const mockSafeInfo = { safe: { fallbackHandler: { value: '0x123' }, chainId: '1' } } - const mockDeployments = { - networkAddresses: { - '1': ['0x123', '0x456'], - }, - } - - beforeEach(() => { - ;(useSafeInfo as jest.Mock).mockReturnValue(mockSafeInfo) - ;(useCompatibilityFallbackHandlerDeployments as jest.Mock).mockReturnValue(mockDeployments) - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - describe('should return `true`', () => { - it('if current Safe`s fallback handler is an official one', () => { - const { result } = renderHook(() => useIsOfficialFallbackHandler()) - - expect(result.current).toBe(true) - }) - - it('if the provided fallback handler is an official one', () => { - const { result } = renderHook(() => useIsOfficialFallbackHandler('0x456')) - - expect(result.current).toBe(true) - }) - }) - - describe('should return `false`', () => { - it('if the current Safe`s fallback handler is not an official one', () => { - ;(useSafeInfo as jest.Mock).mockReturnValue({ safe: { fallbackHandler: { value: '0x789' }, chainId: '1' } }) - - const { result } = renderHook(() => useIsOfficialFallbackHandler()) - - expect(result.current).toBe(false) - }) - - it('if the provided fallback handler is not an official one', () => { - const { result } = renderHook(() => useIsOfficialFallbackHandler('0x789')) - - expect(result.current).toBe(false) - }) - - it('if there is no fallback handler address', () => { - ;(useSafeInfo as jest.Mock).mockReturnValue({ safe: { fallbackHandler: { value: undefined }, chainId: '1' } }) - - const { result } = renderHook(() => useIsOfficialFallbackHandler()) - - expect(result.current).toBe(false) - }) - - it('if there are no fallback handler deployments', () => { - ;(useCompatibilityFallbackHandlerDeployments as jest.Mock).mockReturnValue(undefined) - - const { result } = renderHook(() => useIsOfficialFallbackHandler()) - - expect(result.current).toBe(false) - }) - }) -}) diff --git a/apps/web/src/hooks/__tests__/useLoadBalances.test.ts b/apps/web/src/hooks/__tests__/useLoadBalances.test.ts index 01fa63f4cc..d40447527e 100644 --- a/apps/web/src/hooks/__tests__/useLoadBalances.test.ts +++ b/apps/web/src/hooks/__tests__/useLoadBalances.test.ts @@ -1,13 +1,13 @@ import * as store from '@/store' -import { defaultSafeInfo } from '@/store/safeInfoSlice' +import { defaultSafeInfo } from '@safe-global/store/slices/SafeInfo/utils' import { act, renderHook, waitFor } from '@/tests/test-utils' import { toBeHex } from 'ethers' import useLoadBalances from '../loadables/useLoadBalances' -import * as SafeGatewaySDK from '@safe-global/safe-gateway-typescript-sdk' import { TokenType } from '@safe-global/safe-apps-sdk' -import { FEATURES } from '@/utils/chains' import * as useChainId from '@/hooks/useChainId' +import * as balancesQueries from '@safe-global/store/gateway/AUTO_GENERATED/balances' import { TOKEN_LISTS } from '@/store/settingsSlice' +import { FEATURES } from '@safe-global/utils/utils/chains' const safeAddress = toBeHex('0x1234', 20) @@ -140,17 +140,12 @@ describe('useLoadBalances', () => { }) test('pass correct currency and reload on currency change', async () => { - const safeAddress = toBeHex('0x1234', 20) - const mockGetBalances = jest - .spyOn(SafeGatewaySDK, 'getBalances') - .mockImplementation(async (chainId, address, currency, query) => { - expect(chainId).toEqual('5') - expect(address).toEqual(safeAddress) - expect(currency).toEqual('EUR') - expect(query).toMatchObject({ trusted: false }) - - return mockBalanceEUR - }) + jest.spyOn(balancesQueries, 'useBalancesGetBalancesV1Query').mockImplementation(() => ({ + currentData: mockBalanceEUR, + isLoading: false, + error: undefined, + refetch: jest.fn(), + })) const mockSelector = jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => selector({ @@ -183,14 +178,12 @@ describe('useLoadBalances', () => { expect(result.current[1]).toBeUndefined() }) - mockGetBalances.mockImplementation(async (chainId, address, currency, query) => { - expect(chainId).toEqual('5') - expect(address).toEqual(safeAddress) - expect(currency).toEqual('USD') - expect(query).toMatchObject({ trusted: false }) - - return mockBalanceUSD - }) + jest.spyOn(balancesQueries, 'useBalancesGetBalancesV1Query').mockImplementation(() => ({ + currentData: mockBalanceUSD, + isLoading: false, + error: undefined, + refetch: jest.fn(), + })) mockSelector.mockImplementation((selector) => selector({ @@ -226,14 +219,12 @@ describe('useLoadBalances', () => { }) test('only use default list if feature is enabled', async () => { - jest.spyOn(SafeGatewaySDK, 'getBalances').mockImplementation(async (chainId, address, currency, query) => { - expect(chainId).toEqual('5') - expect(address).toEqual(safeAddress) - expect(currency).toEqual('EUR') - expect(query).toMatchObject({ trusted: false }) - - return mockBalanceAllTokens - }) + jest.spyOn(balancesQueries, 'useBalancesGetBalancesV1Query').mockImplementation(() => ({ + currentData: mockBalanceAllTokens, + isLoading: false, + error: undefined, + refetch: jest.fn(), + })) jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => selector({ @@ -268,16 +259,12 @@ describe('useLoadBalances', () => { }) test('use trusted filter for default list and reload on settings change', async () => { - const mockGetBalances = jest - .spyOn(SafeGatewaySDK, 'getBalances') - .mockImplementation(async (chainId, address, currency, query) => { - expect(chainId).toEqual('5') - expect(address).toEqual(safeAddress) - expect(currency).toEqual('EUR') - expect(query).toMatchObject({ trusted: true }) - - return mockBalanceDefaultList - }) + jest.spyOn(balancesQueries, 'useBalancesGetBalancesV1Query').mockImplementation(() => ({ + currentData: mockBalanceDefaultList, + isLoading: false, + error: undefined, + refetch: jest.fn(), + })) const mockSelector = jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => selector({ @@ -313,14 +300,12 @@ describe('useLoadBalances', () => { expect(result.current[1]).toBeUndefined() }) - mockGetBalances.mockImplementation(async (chainId, address, currency, query) => { - expect(chainId).toEqual('5') - expect(address).toEqual(safeAddress) - expect(currency).toEqual('EUR') - expect(query).toMatchObject({ trusted: false }) - - return mockBalanceAllTokens - }) + jest.spyOn(balancesQueries, 'useBalancesGetBalancesV1Query').mockImplementation(() => ({ + currentData: mockBalanceAllTokens, + isLoading: false, + error: undefined, + refetch: jest.fn(), + })) mockSelector.mockImplementation((selector) => selector({ diff --git a/apps/web/src/hooks/__tests__/useLoadChains.test.ts b/apps/web/src/hooks/__tests__/useLoadChains.test.ts deleted file mode 100644 index 0b4563cdf0..0000000000 --- a/apps/web/src/hooks/__tests__/useLoadChains.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import useLoadChains from '@/hooks/loadables/useLoadChains' -import { act, renderHook } from '@/tests/test-utils' -import { getConfigs } from '@/hooks/loadables/helpers/config' - -// Mock getChainsConfig -jest.mock('@/hooks/loadables/helpers/config.ts', () => { - return { - getConfigs: jest.fn(() => { - return new Promise((resolve) => { - setTimeout(() => { - resolve([ - { - chainId: '4', - }, - ]) - }, 100) - }) - }), - } -}) - -// Jest tests for the useLoadChains hook -describe('useLoadChains hook', () => { - beforeEach(() => { - jest.clearAllMocks() - jest.useFakeTimers() - }) - - it('should fetch the chains when the hook is called', async () => { - // Render the hook and check that the loading state is true - const { result } = renderHook(() => useLoadChains()) - - await act(async () => { - jest.advanceTimersByTime(10) - }) - - var [data, error, loading] = result.current - - // Check that the loading state is true - expect(loading).toBe(true) - expect(error).toBe(undefined) - expect(data).toEqual(undefined) - - // Check that the loading state is false after the promise resolves - await act(async () => { - jest.advanceTimersByTime(100) - }) - - var [data, error, loading] = result.current - - expect(loading).toBe(false) - expect(error).toBe(undefined) - expect(data).toEqual([ - { - chainId: '4', - }, - ]) - }) - - it('should set the error state when the promise rejects', async () => { - // Change the getChainsConfig mock to reject - ;(getConfigs as jest.Mock).mockImplementation(() => Promise.reject(new Error('Something went wrong'))) - - const { result } = renderHook(() => useLoadChains()) - - await act(async () => Promise.resolve()) - - const [data, error, loading] = result.current - - expect(loading).toBe(false) - expect(error?.message).toBe('Something went wrong') - expect(data).toEqual(undefined) - }) -}) diff --git a/apps/web/src/hooks/__tests__/useLoadSpendingLimits.test.ts b/apps/web/src/hooks/__tests__/useLoadSpendingLimits.test.ts index ecabaff3bd..858cf1321e 100644 --- a/apps/web/src/hooks/__tests__/useLoadSpendingLimits.test.ts +++ b/apps/web/src/hooks/__tests__/useLoadSpendingLimits.test.ts @@ -1,18 +1,15 @@ import * as spendingLimit from '@/services/contracts/spendingLimitContracts' -import { JsonRpcProvider } from 'ethers' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' -import type { AllowanceModule } from '@/types/contracts' -import { ERC20__factory } from '@/types/contracts' -import { - getSpendingLimits, - getTokenAllowanceForDelegate, - getTokensForDelegate, -} from '../loadables/useLoadSpendingLimits' -import * as web3 from '../wallets/web3' -import { keccak256, toUtf8Bytes } from 'ethers' +import type { AllowanceModule } from '@safe-global/utils/types/contracts' +import { AllowanceModule__factory } from '@safe-global/utils/types/contracts' +import { getSpendingLimits, getTokenAllowances, getTokensForDelegates } from '../loadables/useLoadSpendingLimits' import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { mockWeb3Provider } from '@/tests/test-utils' +import { createMockWeb3Provider } from '@safe-global/utils/tests/web3Provider' +import { faker } from '@faker-js/faker' -const mockProvider = new JsonRpcProvider() +const spendingLimitInterface = AllowanceModule__factory.createInterface() +const mockProvider = createMockWeb3Provider([], undefined, '4') const mockModule = { value: '0x1', } @@ -46,6 +43,7 @@ describe('getSpendingLimits', () => { jest.fn(() => { return { getDelegates: getDelegatesMock, + getAddress: jest.fn().mockResolvedValue(faker.finance.ethereumAddress()), } as unknown as AllowanceModule }), ) @@ -60,67 +58,144 @@ describe('getSpendingLimits', () => { }) it('should return a flat list of spending limits', async () => { - const getDelegatesMock = jest.fn(() => ({ results: ['0x2', '0x3'] })) - const getTokensMock = jest.fn(() => ['0x10', '0x11']) - const getTokenAllowanceMock = jest.fn(() => [BigInt(1), BigInt(0), BigInt(0), BigInt(0), BigInt(0)]) + const spendingLimitAddress = faker.finance.ethereumAddress() + const delegate1 = faker.finance.ethereumAddress() + const delegate2 = faker.finance.ethereumAddress() + const token1 = faker.finance.ethereumAddress() + const token2 = faker.finance.ethereumAddress() + const getDelegatesMock = jest.fn(() => ({ results: [delegate1, delegate2] })) + + const web3Provider = createMockWeb3Provider( + [ + { + signature: spendingLimitInterface.getFunction('getTokens')?.selector!, + returnType: 'address[]', + returnValue: [token1, token2], + }, + { + signature: spendingLimitInterface.getFunction('getTokenAllowance')?.selector!, + returnType: 'uint256[5]', + returnValue: [BigInt(1), BigInt(0), BigInt(0), BigInt(0), BigInt(0)], + }, + ], + undefined, + '4', + ) jest.spyOn(spendingLimit, 'getDeployedSpendingLimitModuleAddress').mockReturnValue('0x1') jest.spyOn(spendingLimit, 'getSpendingLimitContract').mockImplementation( jest.fn(() => { return { + getAddress: jest.fn().mockResolvedValue(spendingLimitAddress), getDelegates: getDelegatesMock, - getTokens: getTokensMock, - getTokenAllowance: getTokenAllowanceMock, + interface: spendingLimitInterface, } as unknown as AllowanceModule }), ) - const result = await getSpendingLimits(mockProvider, [mockModule], ZERO_ADDRESS, '4', []) + const result = await getSpendingLimits(web3Provider, [mockModule], ZERO_ADDRESS, '4', []) expect(result?.length).toBe(4) + + // the requests should be optimized through using multicall: + expect(web3Provider.call).toHaveBeenCalledTimes(2) }) it('should filter out empty allowances', async () => { - const getDelegatesMock = jest.fn(() => ({ results: ['0x2', '0x3'] })) - const getTokensMock = jest.fn(() => ['0x10', '0x11']) - const getTokenAllowanceMock = jest.fn(() => [BigInt(0), BigInt(0), BigInt(0), BigInt(0), BigInt(0)]) + const spendingLimitAddress = faker.finance.ethereumAddress() + const delegate1 = faker.finance.ethereumAddress() + const delegate2 = faker.finance.ethereumAddress() + const token1 = faker.finance.ethereumAddress() + const token2 = faker.finance.ethereumAddress() + const getDelegatesMock = jest.fn(() => ({ results: [delegate1, delegate2] })) + + const web3Provider = createMockWeb3Provider( + [ + { + signature: spendingLimitInterface.getFunction('getTokens')?.selector!, + returnType: 'address[]', + returnValue: [token1, token2], + }, + { + signature: spendingLimitInterface.getFunction('getTokenAllowance')?.selector!, + returnType: 'uint256[5]', + returnValue: [BigInt(0), BigInt(0), BigInt(0), BigInt(0), BigInt(0)], + }, + ], + undefined, + '4', + ) jest.spyOn(spendingLimit, 'getDeployedSpendingLimitModuleAddress').mockReturnValue('0x1') jest.spyOn(spendingLimit, 'getSpendingLimitContract').mockImplementation( jest.fn(() => { return { + getAddress: jest.fn().mockResolvedValue(spendingLimitAddress), getDelegates: getDelegatesMock, - getTokens: getTokensMock, - getTokenAllowance: getTokenAllowanceMock, + interface: spendingLimitInterface, } as unknown as AllowanceModule }), ) - const result = await getSpendingLimits(mockProvider, [mockModule], ZERO_ADDRESS, '4', []) + const result = await getSpendingLimits(web3Provider, [mockModule], ZERO_ADDRESS, '4', []) expect(result?.length).toBe(0) }) }) -describe('getTokensForDelegate', () => { +describe('getTokensForDelegates', () => { it('should fetch tokens for a given delegate', async () => { - const getTokensMock = jest.fn(() => []) - const mockContract = { getTokens: getTokensMock } as unknown as AllowanceModule - - await getTokensForDelegate(mockContract, ZERO_ADDRESS, '0x1', []) - - expect(getTokensMock).toHaveBeenCalledWith(ZERO_ADDRESS, '0x1') + const delegate = faker.finance.ethereumAddress() + const token1 = faker.finance.ethereumAddress() + const token2 = faker.finance.ethereumAddress() + const mockContract = { + interface: spendingLimitInterface, + getAddress: jest.fn().mockResolvedValue(faker.finance.ethereumAddress()), + } as unknown as AllowanceModule + + const mockProvider = createMockWeb3Provider( + [ + { + signature: spendingLimitInterface.getFunction('getTokens')?.selector!, + returnType: 'address[]', + returnValue: [token1, token2], + }, + { + signature: spendingLimitInterface.getFunction('getTokenAllowance')?.selector!, + returnType: 'uint256[5]', + returnValue: [BigInt(1), BigInt(0), BigInt(0), BigInt(0), BigInt(0)], + }, + ], + undefined, + '5', + ) + const spendingLimits = await getTokensForDelegates(mockContract, mockProvider, ZERO_ADDRESS, [delegate], []) + expect(spendingLimits.length).toBe(2) }) }) describe('getTokenAllowanceForDelegate', () => { it('should return contract values as strings', async () => { - const getTokenAllowanceMock = jest.fn(() => [BigInt(0), BigInt(0), BigInt(0), BigInt(0), BigInt(0)]) - const mockContract = { getTokenAllowance: getTokenAllowanceMock } as unknown as AllowanceModule - - const result = await getTokenAllowanceForDelegate(mockContract, ZERO_ADDRESS, '0x1', '0x10', []) + const mockContract = { + getAddress: jest.fn().mockResolvedValue(faker.finance.ethereumAddress()), + interface: spendingLimitInterface, + } as unknown as AllowanceModule + const delegate = faker.finance.ethereumAddress() + const token = faker.finance.ethereumAddress() + const mockProvider = createMockWeb3Provider( + [ + { + signature: spendingLimitInterface.getFunction('getTokenAllowance')?.selector!, + returnType: 'uint256[5]', + returnValue: [BigInt(0), BigInt(0), BigInt(0), BigInt(0), BigInt(0)], + }, + ], + undefined, + '5', + ) + const result = (await getTokenAllowances(mockContract, mockProvider, ZERO_ADDRESS, [{ delegate, token }], []))[0] - expect(result.beneficiary).toBe('0x1') + expect(result.beneficiary).toBe(delegate) expect(result.nonce).toBe('0') expect(result.amount).toBe('0') expect(result.spent).toBe('0') @@ -129,12 +204,27 @@ describe('getTokenAllowanceForDelegate', () => { }) it('should return tokenInfo from balance', async () => { - const getTokenAllowanceMock = jest.fn(() => [BigInt(0), BigInt(0), BigInt(0), BigInt(0), BigInt(0)]) - const mockContract = { getTokenAllowance: getTokenAllowanceMock } as unknown as AllowanceModule + const mockContract = { + getAddress: jest.fn().mockResolvedValue(faker.finance.ethereumAddress()), + interface: spendingLimitInterface, + } as unknown as AllowanceModule + const delegate = faker.finance.ethereumAddress() + const token = faker.finance.ethereumAddress() + const mockProvider = createMockWeb3Provider( + [ + { + signature: spendingLimitInterface.getFunction('getTokenAllowance')?.selector!, + returnType: 'uint256[5]', + returnValue: [BigInt(0), BigInt(0), BigInt(0), BigInt(0), BigInt(0)], + }, + ], + undefined, + '5', + ) const mockTokenInfoFromBalances = [ { - address: '0x10', + address: token, name: 'Test', type: TokenType.ERC20, symbol: 'TST', @@ -143,49 +233,54 @@ describe('getTokenAllowanceForDelegate', () => { }, ] - const result = await getTokenAllowanceForDelegate( - mockContract, - ZERO_ADDRESS, - '0x1', - '0x10', - mockTokenInfoFromBalances, - ) - - expect(result.token.address).toBe('0x10') + const result = ( + await getTokenAllowances( + mockContract, + mockProvider, + ZERO_ADDRESS, + [{ delegate, token }], + mockTokenInfoFromBalances, + ) + )[0] + + expect(result.token.address).toBe(token) expect(result.token.decimals).toBe(10) expect(result.token.symbol).toBe('TST') expect(result.token.logoUri).toBe('https://mock.images/0x10.png') }) it('should return tokenInfo from on-chain if not in balance', async () => { - const getTokenAllowanceMock = jest.fn(() => [BigInt(0), BigInt(0), BigInt(0), BigInt(0), BigInt(0)]) - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (tx: { data: string; to: string }) => { - { - const decimalsSigHash = keccak256(toUtf8Bytes('decimals()')).slice(0, 10) - const symbolSigHash = keccak256(toUtf8Bytes('symbol()')).slice(0, 10) - - if (tx.data.startsWith(decimalsSigHash)) { - return ERC20__factory.createInterface().encodeFunctionResult('decimals', [10]) - } - if (tx.data.startsWith(symbolSigHash)) { - return ERC20__factory.createInterface().encodeFunctionResult('symbol', ['TST']) - } - } - }, - _isProvider: true, - resolveName: (name: string) => name, - }) as any, + const mockContract = { + getAddress: jest.fn().mockResolvedValue(faker.finance.ethereumAddress()), + interface: spendingLimitInterface, + } as unknown as AllowanceModule + const delegate = faker.finance.ethereumAddress() + const token = faker.finance.ethereumAddress() + const mockProvider = mockWeb3Provider( + [ + { + signature: spendingLimitInterface.getFunction('getTokenAllowance')?.selector!, + returnType: 'uint256[5]', + returnValue: [BigInt(0), BigInt(0), BigInt(0), BigInt(0), BigInt(0)], + }, + { + signature: 'decimals()', + returnType: 'uint8', + returnValue: 10, + }, + { + signature: 'symbol()', + returnType: 'string', + returnValue: 'TST', + }, + ], + undefined, + '5', ) - const mockContract = { getTokenAllowance: getTokenAllowanceMock } as unknown as AllowanceModule - - const result = await getTokenAllowanceForDelegate(mockContract, ZERO_ADDRESS, '0x1', '0x10', []) + const result = (await getTokenAllowances(mockContract, mockProvider, ZERO_ADDRESS, [{ delegate, token }], []))[0] - expect(result.token.address).toBe('0x10') + expect(result.token.address).toBe(token) expect(result.token.decimals).toBe(10) expect(result.token.symbol).toBe('TST') expect(result.token.logoUri).toBe(undefined) diff --git a/apps/web/src/hooks/__tests__/useOnceVisible.test.ts b/apps/web/src/hooks/__tests__/useOnceVisible.test.ts new file mode 100644 index 0000000000..f3f6b35f38 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useOnceVisible.test.ts @@ -0,0 +1,105 @@ +import { act } from 'react' +import { renderHook } from '@testing-library/react' +import useOnceVisible from '../useOnceVisible' + +describe('useOnceVisible hook', () => { + let observeMock: jest.Mock + let unobserveMock: jest.Mock + let disconnectMock: jest.Mock + let intersectionObserverMock: jest.Mock + let intersectionCallback: IntersectionObserverCallback + let mockObserverInstance: IntersectionObserver + + beforeEach(() => { + observeMock = jest.fn() + unobserveMock = jest.fn() + disconnectMock = jest.fn() + + // Mock factory for IntersectionObserver: + intersectionObserverMock = jest.fn((callback: IntersectionObserverCallback) => { + // Save the callback so we can trigger it later + intersectionCallback = callback + + mockObserverInstance = { + observe: observeMock, + unobserve: unobserveMock, + disconnect: disconnectMock, + takeRecords: jest.fn(), + root: null, + rootMargin: '', + thresholds: [], + } + + return mockObserverInstance + }) + + // Override the global IntersectionObserver + Object.defineProperty(window, 'IntersectionObserver', { + writable: true, + configurable: true, + value: intersectionObserverMock, + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('returns false initially', () => { + const ref = { current: document.createElement('div') } + + const { result } = renderHook(() => useOnceVisible(ref)) + expect(result.current).toBe(false) + }) + + it('calls observe on mount', () => { + const ref = { current: document.createElement('div') } + renderHook(() => useOnceVisible(ref)) + + expect(observeMock).toHaveBeenCalledTimes(1) + expect(observeMock).toHaveBeenCalledWith(ref.current) + }) + + it('updates to true when the element becomes visible', async () => { + const ref = { current: document.createElement('div') } + const { result } = renderHook(() => useOnceVisible(ref)) + + expect(result.current).toBe(false) + + act(() => { + intersectionCallback( + [ + { + isIntersecting: true, + target: ref.current!, + intersectionRatio: 1, + boundingClientRect: {} as DOMRectReadOnly, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + time: 0, + }, + ], + mockObserverInstance, + ) + }) + + expect(result.current).toBe(true) + }) + + it('disconnects observer on unmount', () => { + const ref = { current: document.createElement('div') } + const { unmount } = renderHook(() => useOnceVisible(ref)) + + unmount() + expect(disconnectMock).toHaveBeenCalledTimes(1) + }) + + it('does nothing if ref.current is null', () => { + const ref = { current: null } + + const { unmount } = renderHook(() => useOnceVisible(ref)) + expect(intersectionObserverMock).not.toHaveBeenCalled() + + unmount() + }) +}) diff --git a/apps/web/src/hooks/__tests__/usePredictSafeAddressFromTxDetails.test.ts b/apps/web/src/hooks/__tests__/usePredictSafeAddressFromTxDetails.test.ts new file mode 100644 index 0000000000..31a0d90c38 --- /dev/null +++ b/apps/web/src/hooks/__tests__/usePredictSafeAddressFromTxDetails.test.ts @@ -0,0 +1,295 @@ +import { renderHook } from '@testing-library/react' +import type { DataDecoded, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' + +import { _getSetupFromDataDecoded, usePredictSafeAddressFromTxDetails } from '../usePredictSafeAddressFromTxDetails' + +// @see https://safe-client.safe.global/v1/chains/11155111/transactions/multisig_0x57c26D4d117c926A872814fa46C179691f580e84_0xd0d519d3ebd6efac7a9d7c591c0a311193b6acbbf5db970fab6a51e1d3509e72 +const createProxyWithNonce = { + safeAddress: '0x57c26D4d117c926A872814fa46C179691f580e84', + txId: 'multisig_0x57c26D4d117c926A872814fa46C179691f580e84_0xd0d519d3ebd6efac7a9d7c591c0a311193b6acbbf5db970fab6a51e1d3509e72', + executedAt: 1732816512000, + txStatus: 'SUCCESS', + txInfo: { + type: 'Custom', + humanDescription: null, + to: { + value: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + name: 'SafeProxyFactory 1.4.1', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67.png', + }, + dataSize: '580', + value: '0', + methodName: 'createProxyWithNonce', + actionCount: null, + isCancellation: false, + }, + txData: { + hexData: + '0x1688f0b900000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000b00000000000000000000000000000000000000000000000000000000000001a4b63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c7620000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + dataDecoded: { + method: 'createProxyWithNonce', + parameters: [ + { + name: '_singleton', + type: 'address', + value: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + valueDecoded: null, + }, + { + name: 'initializer', + type: 'bytes', + value: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + valueDecoded: null, + }, + { name: 'saltNonce', type: 'uint256', value: '11', valueDecoded: null }, + ], + }, + to: { + value: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + name: 'SafeProxyFactory 1.4.1', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67.png', + }, + value: '0', + operation: 0, + trustedDelegateCallTarget: null, + addressInfoIndex: { + '0x41675C099F32341bf84BFc5382aF534df5C7461a': { + value: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + name: 'Safe 1.4.1', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x41675C099F32341bf84BFc5382aF534df5C7461a.png', + }, + }, + }, + txHash: '0xef0ae869f2aa8ef5c60aa7e47cfb1dc463d25f41b1c9322d822bc96b529c7e60', + detailedExecutionInfo: { + type: 'MULTISIG', + submittedAt: 1732816512000, + nonce: 30, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { value: '0x0000000000000000000000000000000000000000', name: 'MetaMultiSigWallet', logoUri: null }, + safeTxHash: '0xd0d519d3ebd6efac7a9d7c591c0a311193b6acbbf5db970fab6a51e1d3509e72', + executor: { value: '0xbbeedB6d8e56e23f5812e59d1b6602F15957271F', name: null, logoUri: null }, + signers: [{ value: '0xbbeedB6d8e56e23f5812e59d1b6602F15957271F', name: null, logoUri: null }], + confirmationsRequired: 1, + confirmations: [ + { + signer: { value: '0xbbeedB6d8e56e23f5812e59d1b6602F15957271F', name: null, logoUri: null }, + signature: + '0x000000000000000000000000bbeedb6d8e56e23f5812e59d1b6602f15957271f000000000000000000000000000000000000000000000000000000000000000001', + submittedAt: 1732816512000, + }, + ], + rejectors: [], + gasTokenInfo: null, + trusted: true, + proposer: null, + proposedByDelegate: null, + }, + safeAppInfo: null, +} + +// @see https://safe-client.safe.global/v1/chains/11155111/transactions/multisig_0x57c26D4d117c926A872814fa46C179691f580e84_0x1bfa1753ff85b19b9b455a7bf6b5f491e75fef451b01d31dac5236966aa82dbb +const createProxyWithNonceThenFund = { + safeAddress: '0x57c26D4d117c926A872814fa46C179691f580e84', + txId: 'multisig_0x57c26D4d117c926A872814fa46C179691f580e84_0x1bfa1753ff85b19b9b455a7bf6b5f491e75fef451b01d31dac5236966aa82dbb', + executedAt: 1732815948000, + txStatus: 'SUCCESS', + txInfo: { + type: 'Custom', + humanDescription: null, + to: { + value: '0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B', + name: 'Safe: MultiSendCallOnly 1.3.0', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B.png', + }, + dataSize: '836', + value: '0', + methodName: 'multiSend', + actionCount: 2, + isCancellation: false, + }, + txData: { + hexData: + '0x8d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000002ee004e1dcf7ad4e460cfd30791ccc4f9c8a4f820ec67000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002441688f0b900000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000900000000000000000000000000000000000000000000000000000000000001a4b63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c762000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a1eeb7cc56e9ff272fd2da70cbe18c9c500fc478000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + dataDecoded: { + method: 'multiSend', + parameters: [ + { + name: 'transactions', + type: 'bytes', + value: + '0x004e1dcf7ad4e460cfd30791ccc4f9c8a4f820ec67000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002441688f0b900000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000900000000000000000000000000000000000000000000000000000000000001a4b63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c762000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a1eeb7cc56e9ff272fd2da70cbe18c9c500fc478000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000000000000', + valueDecoded: [ + { + operation: 0, + to: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + value: '0', + data: '0x1688f0b900000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000900000000000000000000000000000000000000000000000000000000000001a4b63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c7620000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + dataDecoded: { + method: 'createProxyWithNonce', + parameters: [ + { name: '_singleton', type: 'address', value: '0x41675C099F32341bf84BFc5382aF534df5C7461a' }, + { + name: 'initializer', + type: 'bytes', + value: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + }, + { name: 'saltNonce', type: 'uint256', value: '9' }, + ], + }, + }, + { + operation: 0, + to: '0xA1eEB7CC56e9FF272Fd2Da70CBE18c9C500FC478', + value: '100000000000000000', + data: null, + dataDecoded: null, + }, + ], + }, + ], + }, + to: { + value: '0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B', + name: 'Safe: MultiSendCallOnly 1.3.0', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B.png', + }, + value: '0', + operation: 1, + trustedDelegateCallTarget: true, + addressInfoIndex: { + '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67': { + value: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + name: 'SafeProxyFactory 1.4.1', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67.png', + }, + '0x41675C099F32341bf84BFc5382aF534df5C7461a': { + value: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + name: 'Safe 1.4.1', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x41675C099F32341bf84BFc5382aF534df5C7461a.png', + }, + '0xA1eEB7CC56e9FF272Fd2Da70CBE18c9C500FC478': { + value: '0xA1eEB7CC56e9FF272Fd2Da70CBE18c9C500FC478', + name: 'SafeProxy', + logoUri: null, + }, + }, + }, + txHash: '0x08d3c281a136d43346433453125afee79f9455bf5810deec3dd3806a42de41b1', + detailedExecutionInfo: { + type: 'MULTISIG', + submittedAt: 1732815948000, + nonce: 28, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { value: '0x0000000000000000000000000000000000000000', name: 'MetaMultiSigWallet', logoUri: null }, + safeTxHash: '0x1bfa1753ff85b19b9b455a7bf6b5f491e75fef451b01d31dac5236966aa82dbb', + executor: { value: '0xbbeedB6d8e56e23f5812e59d1b6602F15957271F', name: null, logoUri: null }, + signers: [{ value: '0xbbeedB6d8e56e23f5812e59d1b6602F15957271F', name: null, logoUri: null }], + confirmationsRequired: 1, + confirmations: [ + { + signer: { value: '0xbbeedB6d8e56e23f5812e59d1b6602F15957271F', name: null, logoUri: null }, + signature: + '0x000000000000000000000000bbeedb6d8e56e23f5812e59d1b6602f15957271f000000000000000000000000000000000000000000000000000000000000000001', + submittedAt: 1732815948000, + }, + ], + rejectors: [], + gasTokenInfo: null, + trusted: true, + proposer: null, + proposedByDelegate: null, + }, + safeAppInfo: null, +} + +describe('getSetupFromDataDecoded', () => { + it('should return undefined if no createProxyWithNonce method is found', () => { + const dataDecoded = { + method: 'notCreateProxyWithNonce', + } + expect(_getSetupFromDataDecoded(dataDecoded)).toBeUndefined() + }) + + it('should return direct createProxyWithNonce calls', () => { + expect(_getSetupFromDataDecoded(createProxyWithNonce.txData.dataDecoded as unknown as DataDecoded)).toEqual({ + singleton: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + initializer: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + saltNonce: '11', + }) + }) + + it.each([ + ['_singleton', 0], + ['initializer', 1], + ['saltNonce', 2], + ])('should return undefined if %s is not a string', (_, argIndex) => { + const dataDecoded = JSON.parse(JSON.stringify(createProxyWithNonce.txData.dataDecoded)) as DataDecoded + // @ts-expect-error value is a string + dataDecoded.parameters[argIndex].value = 1 + expect(_getSetupFromDataDecoded(dataDecoded)).toBeUndefined() + }) +}) + +jest.mock('@/features/multichain/utils/utils', () => ({ + __esModule: true, + predictSafeAddress: jest.fn(), +})) +jest.mock('@/hooks/wallets/web3', () => ({ + __esModule: true, + useWeb3ReadOnly: () => { + return 'Mock provider' + }, +})) + +describe('usePredictSafeAddressFromTxDetails', () => { + it('should pass the correct arguments to predictSafeAddress from a createProxyWithNonce call', () => { + const mockPredictSafeAddress = jest.spyOn(require('@/features/multichain/utils/utils'), 'predictSafeAddress') + + renderHook(() => usePredictSafeAddressFromTxDetails(createProxyWithNonce as unknown as TransactionDetails)) + + expect(mockPredictSafeAddress).toHaveBeenCalledWith( + { + singleton: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + initializer: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + saltNonce: '11', + }, + '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + 'Mock provider', + ) + }) + + it('should pass the correct arguments to predictSafeAddress from a multiSend, containing a createProxyWithNonce call', () => { + const mockPredictSafeAddress = jest.spyOn(require('@/features/multichain/utils/utils'), 'predictSafeAddress') + + renderHook(() => usePredictSafeAddressFromTxDetails(createProxyWithNonceThenFund as unknown as TransactionDetails)) + + expect(mockPredictSafeAddress).toHaveBeenCalledWith( + { + singleton: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + initializer: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + saltNonce: '9', + }, + '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + 'Mock provider', + ) + }) +}) diff --git a/apps/web/src/hooks/__tests__/useRemainingRelays.test.ts b/apps/web/src/hooks/__tests__/useRemainingRelays.test.ts index 87b3c1fddd..992069d856 100644 --- a/apps/web/src/hooks/__tests__/useRemainingRelays.test.ts +++ b/apps/web/src/hooks/__tests__/useRemainingRelays.test.ts @@ -3,8 +3,8 @@ import { useLeastRemainingRelays, useRelaysBySafe } from '@/hooks/useRemainingRe import * as useSafeInfo from '@/hooks/useSafeInfo' import * as useChains from '@/hooks/useChains' import { chainBuilder } from '@/tests/builders/chains' -import { FEATURES } from '@/utils/chains' import * as gateway from '@safe-global/safe-gateway-typescript-sdk' +import { FEATURES } from '@safe-global/utils/utils/chains' const SAFE_ADDRESS = '0x0000000000000000000000000000000000000001' diff --git a/apps/web/src/hooks/__tests__/useRemoteSafeApps.test.ts b/apps/web/src/hooks/__tests__/useRemoteSafeApps.test.ts index da77750c1f..4bda407021 100644 --- a/apps/web/src/hooks/__tests__/useRemoteSafeApps.test.ts +++ b/apps/web/src/hooks/__tests__/useRemoteSafeApps.test.ts @@ -47,7 +47,7 @@ describe('useRemoteSafeApps', () => { ]) }) it('should alphabetically return the remote safe apps filtered by tag', async () => { - const { result } = renderHook(() => useRemoteSafeApps('test' as SafeAppsTag)) + const { result } = renderHook(() => useRemoteSafeApps({ tag: 'test' as SafeAppsTag })) var [data, error, loading] = result.current diff --git a/apps/web/src/hooks/__tests__/useSafeAddress.test.ts b/apps/web/src/hooks/__tests__/useSafeAddressFromUrl.test.ts similarity index 79% rename from apps/web/src/hooks/__tests__/useSafeAddress.test.ts rename to apps/web/src/hooks/__tests__/useSafeAddressFromUrl.test.ts index 05f0df4813..5c1cc2b578 100644 --- a/apps/web/src/hooks/__tests__/useSafeAddress.test.ts +++ b/apps/web/src/hooks/__tests__/useSafeAddressFromUrl.test.ts @@ -1,6 +1,6 @@ import { useRouter } from 'next/router' import { renderHook } from '@/tests/test-utils' -import useSafeAddress from '@/hooks/useSafeAddress' +import { useSafeAddressFromUrl } from '@/hooks/useSafeAddressFromUrl' // Mock useRouter jest.mock('next/router', () => ({ @@ -15,7 +15,7 @@ jest.mock('next/router', () => ({ // Tests for the useSafeAddress hook describe('useSafeAddress hook', () => { it('should return the safe address', () => { - const { result } = renderHook(() => useSafeAddress()) + const { result } = renderHook(() => useSafeAddressFromUrl()) expect(result.current).toBe('0x0000000000000000000000000000000000000001') }) @@ -27,7 +27,7 @@ describe('useSafeAddress hook', () => { }, })) - const { result } = renderHook(() => useSafeAddress()) + const { result } = renderHook(() => useSafeAddressFromUrl()) expect(result.current).toBe('') }) @@ -39,7 +39,7 @@ describe('useSafeAddress hook', () => { }, })) - const { result } = renderHook(() => useSafeAddress()) + const { result } = renderHook(() => useSafeAddressFromUrl()) expect(result.current).toBe('0x220866B1A2219f40e72f5c628B65D54268cA3A9D') }) @@ -49,7 +49,7 @@ describe('useSafeAddress hook', () => { query: {}, })) - const { result } = renderHook(() => useSafeAddress()) + const { result } = renderHook(() => useSafeAddressFromUrl()) expect(result.current).toBe('') }) }) diff --git a/apps/web/src/hooks/__tests__/useSafeNotifications.test.ts b/apps/web/src/hooks/__tests__/useSafeNotifications.test.ts index 1f28a42fc0..bfd5381ab0 100644 --- a/apps/web/src/hooks/__tests__/useSafeNotifications.test.ts +++ b/apps/web/src/hooks/__tests__/useSafeNotifications.test.ts @@ -38,13 +38,10 @@ describe('useSafeNotifications', () => { // mock useSafeInfo to return a SafeInfo with an outdated version ;(useSafeInfo as jest.Mock).mockReturnValue({ safe: { - implementation: { value: '0x123' }, + implementation: { value: '0x234' }, implementationVersionState: 'OUTDATED', version: '1.1.1', - address: { - value: '0x1', - }, - chainId: '5', + address: { value: '0x123' }, }, safeAddress: '0x123', }) @@ -73,13 +70,10 @@ describe('useSafeNotifications', () => { // mock useSafeInfo to return a SafeInfo with an outdated version ;(useSafeInfo as jest.Mock).mockReturnValue({ safe: { - implementation: { value: '0x123' }, + implementation: { value: '0x234' }, implementationVersionState: 'OUTDATED', version: '0.0.1', - address: { - value: '0x1', - }, - chainId: '5', + address: { value: '0x123' }, }, safeAddress: '0x123', }) @@ -104,13 +98,10 @@ describe('useSafeNotifications', () => { it('should not show a notification when the Safe version is up to date', () => { ;(useSafeInfo as jest.Mock).mockReturnValue({ safe: { - implementation: { value: '0x123' }, + implementation: { value: '0x234' }, implementationVersionState: 'UP_TO_DATE', version: '1.3.0', - address: { - value: '0x1', - }, - chainId: '5', + address: { value: '0x123' }, }, }) @@ -127,14 +118,10 @@ describe('useSafeNotifications', () => { it('should show a notification when the mastercopy is invalid', () => { ;(useSafeInfo as jest.Mock).mockReturnValue({ safe: { - implementation: { value: '0x123' }, + implementation: { value: '0x234' }, implementationVersionState: 'UNKNOWN', version: '1.3.0', - nonce: 1, - address: { - value: '0x1', - }, - chainId: '10', + address: { value: '0x123' }, }, }) @@ -155,17 +142,13 @@ describe('useSafeNotifications', () => { }, }) }) - it('should show a notification when the mastercopy is invalid but can be migrated', () => { + it('should not show a notification when the mastercopy is valid', async () => { ;(useSafeInfo as jest.Mock).mockReturnValue({ safe: { - implementation: { value: '0x123' }, - implementationVersionState: 'UNKNOWN', + implementation: { value: '0x456' }, + implementationVersionState: 'UP_TO_DATE', version: '1.3.0', - nonce: 0, - address: { - value: '0x1', - }, - chainId: '10', + address: { value: '0x123' }, }, }) @@ -174,20 +157,16 @@ describe('useSafeNotifications', () => { // check that the notification was shown expect(result.current).toBeUndefined() - expect(showNotification).toHaveBeenCalledWith({ - variant: 'info', - message: `This Safe Account was created with an unsupported base contract. - It is possible to migrate it to a compatible base contract. This migration will be automatically included with your first transaction.`, - groupKey: 'invalid-mastercopy', - link: undefined, - }) + expect(showNotification).not.toHaveBeenCalled() }) - it('should not show a notification when the mastercopy is valid', async () => { + + it('should show a notification when the mastercopy is invalid but can be migrated', () => { ;(useSafeInfo as jest.Mock).mockReturnValue({ safe: { - implementation: { value: '0x456' }, - implementationVersionState: 'UP_TO_DATE', + implementation: { value: '0x123' }, + implementationVersionState: 'UNKNOWN', version: '1.3.0', + nonce: 0, address: { value: '0x1', }, @@ -200,7 +179,13 @@ describe('useSafeNotifications', () => { // check that the notification was shown expect(result.current).toBeUndefined() - expect(showNotification).not.toHaveBeenCalled() + expect(showNotification).toHaveBeenCalledWith({ + variant: 'info', + message: `This Safe Account was created with an unsupported base contract. + It is possible to migrate it to a compatible base contract. You can migrate it to a compatible contract on the Home screen.`, + groupKey: 'invalid-mastercopy', + link: undefined, + }) }) }) }) diff --git a/apps/web/src/hooks/__tests__/useSafeTokenAllocation.test.ts b/apps/web/src/hooks/__tests__/useSafeTokenAllocation.test.ts index f333eea706..813f80746e 100644 --- a/apps/web/src/hooks/__tests__/useSafeTokenAllocation.test.ts +++ b/apps/web/src/hooks/__tests__/useSafeTokenAllocation.test.ts @@ -1,12 +1,6 @@ -import { renderHook, waitFor } from '@/tests/test-utils' -import { type JsonRpcProvider, keccak256, parseEther, toUtf8Bytes, toBeHex, AbiCoder } from 'ethers' -import useSafeTokenAllocation, { - type VestingData, - _getRedeemDeadline, - useSafeVotingPower, - type Vesting, -} from '../useSafeTokenAllocation' -import * as web3 from '../wallets/web3' +import { mockWeb3Provider, renderHook, waitFor } from '@/tests/test-utils' +import { parseEther, toBeHex, AbiCoder } from 'ethers' +import useSafeTokenAllocation, { useSafeVotingPower, type Vesting } from '../useSafeTokenAllocation' import * as useSafeInfoHook from '@/hooks/useSafeInfo' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' @@ -20,44 +14,6 @@ const setupFetchStub = }) } -describe('_getRedeemDeadline', () => { - const mockProvider = { - call: jest.fn(), - } as unknown as JsonRpcProvider - - beforeEach(() => { - // Clear memoization cache - _getRedeemDeadline.cache.clear?.() - - jest.clearAllMocks() - }) - - it('should only call the provider once per address on a chain', async () => { - for (let i = 0; i < 10; i++) { - await _getRedeemDeadline({ chainId: 1, contract: toBeHex('0x1', 20) } as VestingData, mockProvider) - } - - expect(mockProvider.call).toHaveBeenCalledTimes(1) - }) - - it('should not memoize different addresses on the same chain', async () => { - const chainId = 1 - - await _getRedeemDeadline({ chainId, contract: toBeHex('0x1', 20) } as VestingData, mockProvider) - await _getRedeemDeadline({ chainId, contract: toBeHex('0x2', 20) } as VestingData, mockProvider) - - expect(mockProvider.call).toHaveBeenCalledTimes(2) - }) - - it('should not memoize the same address on difference chains', async () => { - for await (const i of Array.from({ length: 10 }, (_, i) => i + 1)) { - await _getRedeemDeadline({ chainId: i, contract: toBeHex('0x1', 20) } as VestingData, mockProvider) - } - - expect(mockProvider.call).toHaveBeenCalledTimes(10) - }) -}) - const originalGlobalFetch = global.fetch describe('Allocations', () => { afterAll(() => { @@ -66,9 +22,6 @@ describe('Allocations', () => { beforeEach(() => { jest.resetAllMocks() - // Clear memoization cache - _getRedeemDeadline.cache.clear?.() - jest.spyOn(useSafeInfoHook, 'default').mockImplementation( () => ({ @@ -102,20 +55,12 @@ describe('Allocations', () => { }) }) - it('should return an empty array without web3Provider', async () => { - global.fetch = jest.fn().mockImplementation(setupFetchStub('', 404)) - const { result } = renderHook(() => useSafeTokenAllocation()) - - await waitFor(() => { - expect(result.current[1]).toBeFalsy() - expect(result.current[0]).toStrictEqual([]) - }) - }) - it('should return an empty array if no allocations exist', async () => { global.fetch = jest.fn().mockImplementation(setupFetchStub('', 404)) const mockFetch = jest.spyOn(global, 'fetch') + mockWeb3Provider([]) + const { result } = renderHook(() => useSafeTokenAllocation()) await waitFor(() => { @@ -144,29 +89,16 @@ describe('Allocations', () => { global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocations, 200)) const mockFetch = jest.spyOn(global, 'fetch') - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any) => { - const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) - const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) - - if (transaction.data?.startsWith(vestingsSigHash)) { - return Promise.resolve( - AbiCoder.defaultAbiCoder().encode( - ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], - [ZERO_ADDRESS, '0x1', false, 208, 1657231200, 2000, 0, 0, false], - ), - ) - } - if (transaction.data?.startsWith(redeemDeadlineSigHash)) { - // 30th Nov 2022 - return Promise.resolve(AbiCoder.defaultAbiCoder().encode(['uint64'], [1669766400])) - } - return Promise.resolve('0x') - }, - }) as any, - ) + mockWeb3Provider([ + { + signature: 'vestings(bytes32)', + returnType: 'raw', + returnValue: AbiCoder.defaultAbiCoder().encode( + ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], + [ZERO_ADDRESS, '0x1', false, 208, 1657231200, 2000, 0, 0, false], + ), + }, + ]) const { result } = renderHook(() => useSafeTokenAllocation()) @@ -203,29 +135,16 @@ describe('Allocations', () => { global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocation, 200)) const mockFetch = jest.spyOn(global, 'fetch') - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any) => { - const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) - const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) - - if (transaction.data?.startsWith(vestingsSigHash)) { - return Promise.resolve( - AbiCoder.defaultAbiCoder().encode( - ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], - [toBeHex('0x2', 20), '0x1', false, 208, 1657231200, 2000, 0, 0, false], - ), - ) - } - if (transaction.data?.startsWith(redeemDeadlineSigHash)) { - // 08.Dec 2200 - return Promise.resolve(AbiCoder.defaultAbiCoder().encode(['uint64'], [7287610110])) - } - return Promise.resolve('0x') - }, - }) as any, - ) + mockWeb3Provider([ + { + signature: 'vestings(bytes32)', + returnType: 'raw', + returnValue: AbiCoder.defaultAbiCoder().encode( + ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], + [toBeHex('0x2', 20), '0x1', false, 208, 1657231200, 2000, 0, 0, false], + ), + }, + ]) const { result } = renderHook(() => useSafeTokenAllocation()) @@ -275,49 +194,39 @@ describe('Allocations', () => { }) it('should return total balance of tokens held and tokens in locking contract if no allocation exists', async () => { - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any) => { - const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - const lockingBalanceSigHash = keccak256(toUtf8Bytes('getUserTokenBalance(address)')).slice(0, 10) - - if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve('0x' + parseEther('100').toString(16)) - } - if (transaction.data?.startsWith(lockingBalanceSigHash)) { - return Promise.resolve('0x' + parseEther('100').toString(16)) - } - return Promise.resolve('0x') - }, - }) as any, - ) + mockWeb3Provider([ + { + signature: 'balanceOf(address)', + returnType: 'uint256', + returnValue: parseEther('100'), + }, + { + signature: 'getUserTokenBalance(address)', + returnType: 'uint256', + returnValue: parseEther('100'), + }, + ]) const { result } = renderHook(() => useSafeVotingPower()) await waitFor(() => { - expect(result.current[0] === parseEther('200')).toBeTruthy() + expect(result.current[0]).toBe(parseEther('200')) expect(result.current[1]).toBeFalsy() }) }) test('formula: allocation - claimed + token balance + locking balance', async () => { - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any) => { - const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - const lockingBalanceSigHash = keccak256(toUtf8Bytes('getUserTokenBalance(address)')).slice(0, 10) - - if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve('0x' + BigInt('400').toString(16)) - } - if (transaction.data?.startsWith(lockingBalanceSigHash)) { - return Promise.resolve('0x' + BigInt('200').toString(16)) - } - return Promise.resolve('0x') - }, - }) as any, - ) + mockWeb3Provider([ + { + signature: 'balanceOf(address)', + returnType: 'uint256', + returnValue: '400', + }, + { + signature: 'getUserTokenBalance(address)', + returnType: 'uint256', + returnValue: '200', + }, + ]) const mockAllocation: Vesting[] = [ { @@ -346,23 +255,18 @@ describe('Allocations', () => { }) test('formula: allocation - claimed + token balance + locking balance, everything claimed and no balance', async () => { - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any) => { - const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - const lockingBalanceSigHash = keccak256(toUtf8Bytes('getUserTokenBalance(address)')).slice(0, 10) - - if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve('0x' + BigInt('0').toString(16)) - } - if (transaction.data?.startsWith(lockingBalanceSigHash)) { - return Promise.resolve('0x' + BigInt('0').toString(16)) - } - return Promise.resolve('0x') - }, - }) as any, - ) + mockWeb3Provider([ + { + signature: 'balanceOf(address)', + returnType: 'uint256', + returnValue: parseEther('0'), + }, + { + signature: 'getUserTokenBalance(address)', + returnType: 'uint256', + returnValue: parseEther('0'), + }, + ]) const mockAllocation: Vesting[] = [ { diff --git a/apps/web/src/hooks/__tests__/useVisibleBalances.test.ts b/apps/web/src/hooks/__tests__/useVisibleBalances.test.ts index c26873236b..6f69a6d40e 100644 --- a/apps/web/src/hooks/__tests__/useVisibleBalances.test.ts +++ b/apps/web/src/hooks/__tests__/useVisibleBalances.test.ts @@ -1,21 +1,28 @@ -import { type SafeBalanceResponse, TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' import * as store from '@/store' +import * as useBalancesHooks from '@/hooks/useBalances' import { renderHook } from '@/tests/test-utils' import { toBeHex } from 'ethers' import { useVisibleBalances } from '../useVisibleBalances' +import { type Balances } from '@safe-global/store/gateway/AUTO_GENERATED/balances' describe('useVisibleBalances', () => { const hiddenTokenAddress = toBeHex('0x2', 20) const visibleTokenAddress = toBeHex('0x3', 20) test('empty balance', () => { - const balance: SafeBalanceResponse = { + const balance: Balances = { fiatTotal: '0', items: [], } + jest.spyOn(useBalancesHooks, 'default').mockImplementation(() => ({ + balances: balance, + error: undefined, + loading: false, + })) + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => selector({ - balances: { data: balance, error: undefined, loading: false }, settings: { currency: 'USD', shortName: { @@ -39,7 +46,7 @@ describe('useVisibleBalances', () => { }) test('return only visible balance', () => { - const balance: SafeBalanceResponse = { + const balance: Balances = { fiatTotal: '100', items: [ { @@ -71,9 +78,14 @@ describe('useVisibleBalances', () => { ], } + jest.spyOn(useBalancesHooks, 'default').mockImplementation(() => ({ + balances: balance, + error: undefined, + loading: false, + })) + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => selector({ - balances: { data: balance, error: undefined, loading: false }, settings: { currency: 'USD', shortName: { @@ -97,7 +109,7 @@ describe('useVisibleBalances', () => { }) test('computation works for high precision numbers', () => { - const balance: SafeBalanceResponse = { + const balance: Balances = { fiatTotal: '200.01234567890123456789', items: [ { @@ -142,6 +154,12 @@ describe('useVisibleBalances', () => { ], } + jest.spyOn(useBalancesHooks, 'default').mockImplementation(() => ({ + balances: balance, + error: undefined, + loading: false, + })) + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => selector({ balances: { data: balance, error: undefined, loading: false }, @@ -168,7 +186,7 @@ describe('useVisibleBalances', () => { }) test('computation works for high USD values', () => { - const balance: SafeBalanceResponse = { + const balance: Balances = { // Current total USD value of all Safes on mainnet * 1 million fiatTotal: '28303710905000100.0123456789', items: [ @@ -201,9 +219,14 @@ describe('useVisibleBalances', () => { ], } + jest.spyOn(useBalancesHooks, 'default').mockImplementation(() => ({ + balances: balance, + error: undefined, + loading: false, + })) + jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => selector({ - balances: { data: balance, error: undefined, loading: false }, settings: { currency: 'USD', shortName: { diff --git a/apps/web/src/hooks/coreSDK/__tests__/safeCoreSDK.test.ts b/apps/web/src/hooks/coreSDK/__tests__/safeCoreSDK.test.ts index 0e77d1088c..6f3893258e 100644 --- a/apps/web/src/hooks/coreSDK/__tests__/safeCoreSDK.test.ts +++ b/apps/web/src/hooks/coreSDK/__tests__/safeCoreSDK.test.ts @@ -1,12 +1,13 @@ -import { Gnosis_safe__factory } from '@/types/contracts' +import { Gnosis_safe__factory } from '@safe-global/utils/types/contracts' import { JsonRpcProvider, toBeHex } from 'ethers' import Safe from '@safe-global/protocol-kit' import { - getProxyFactoryContract, getSafeContract, + getSafeProxyFactoryContract, } from '@safe-global/protocol-kit/dist/src/contracts/safeDeploymentContracts' import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' -import { initSafeSDK, isValidSafeVersion } from '../safeCoreSDK' +import { initSafeSDK } from '../safeCoreSDK' +import { isValidSafeVersion } from '@safe-global/utils/services/contracts/utils' jest.mock('@/services/contracts/safeContracts', () => { return { @@ -17,10 +18,10 @@ jest.mock('@/services/contracts/safeContracts', () => { jest.mock('@safe-global/protocol-kit/dist/src/contracts/safeDeploymentContracts') -jest.mock('@/types/contracts', () => { +jest.mock('@safe-global/utils/types/contracts', () => { return { __esModule: true, - ...jest.requireActual('@/types/contracts'), + ...jest.requireActual('@safe-global/utils/types/contracts'), } }) @@ -34,9 +35,9 @@ jest.mock('@safe-global/protocol-kit', () => { } }) -jest.mock('@/types/contracts', () => { +jest.mock('@safe-global/utils/types/contracts', () => { return { - ...jest.requireActual('@/types/contracts'), + ...jest.requireActual('@safe-global/utils/types/contracts'), _esModule: true, Gnosis_safe__factory: { connect: jest.fn().mockReturnValue({ VERSION: jest.fn() }), @@ -74,7 +75,7 @@ describe('safeCoreSDK', () => { const MAINNET_MASTER_COPY = '0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552' // L1 const POLYGON_MASTER_COPY = '0x3E5c63644E683549055b9Be8653de26E0B4CD36E' // L2 - ;(getProxyFactoryContract as jest.Mock).mockImplementation(async () => { + ;(getSafeProxyFactoryContract as jest.Mock).mockImplementation(async () => { return await Promise.resolve({ getAddress: jest.fn(), proxyCreationCode: jest.fn(), diff --git a/apps/web/src/hooks/coreSDK/__tests__/useInitSafeCoreSDK.test.ts b/apps/web/src/hooks/coreSDK/__tests__/useInitSafeCoreSDK.test.ts index c47cd38ca3..c186653999 100644 --- a/apps/web/src/hooks/coreSDK/__tests__/useInitSafeCoreSDK.test.ts +++ b/apps/web/src/hooks/coreSDK/__tests__/useInitSafeCoreSDK.test.ts @@ -1,4 +1,4 @@ -import type { ExtendedSafeInfo } from '@/store/safeInfoSlice' +import type { ExtendedSafeInfo } from '@safe-global/store/slices/SafeInfo/types' import { renderHook } from '@/tests/test-utils' import { useInitSafeCoreSDK } from '@/hooks/coreSDK/useInitSafeCoreSDK' import * as web3 from '@/hooks/wallets/web3' diff --git a/apps/web/src/hooks/coreSDK/safeCoreSDK.ts b/apps/web/src/hooks/coreSDK/safeCoreSDK.ts index 4ab7d540c8..f4a586b23e 100644 --- a/apps/web/src/hooks/coreSDK/safeCoreSDK.ts +++ b/apps/web/src/hooks/coreSDK/safeCoreSDK.ts @@ -1,49 +1,13 @@ -import chains from '@/config/chains' -import type { UndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' -import { getSafeSingletonDeployments, getSafeL2SingletonDeployments } from '@safe-global/safe-deployments' -import ExternalStore from '@/services/ExternalStore' -import { Gnosis_safe__factory } from '@/types/contracts' -import { invariant } from '@/utils/helpers' -import type { JsonRpcProvider } from 'ethers' +import chains from '@safe-global/utils/config/chains' +import { getSafeL2SingletonDeployments, getSafeSingletonDeployments } from '@safe-global/safe-deployments' +import ExternalStore from '@safe-global/utils/services/ExternalStore' +import { Gnosis_safe__factory } from '@safe-global/utils/types/contracts' import Safe from '@safe-global/protocol-kit' -import type { SafeVersion } from '@safe-global/safe-core-sdk-types' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import semverSatisfies from 'semver/functions/satisfies' -import { isValidMasterCopy } from '@/services/contracts/safeContracts' -import { sameAddress } from '@/utils/addresses' +import { isValidMasterCopy } from '@safe-global/utils/services/contracts/safeContracts' import { isPredictedSafeProps, isReplayedSafeProps } from '@/features/counterfactual/utils' - -export const isLegacyVersion = (safeVersion: string): boolean => { - const LEGACY_VERSION = '<1.3.0' - return semverSatisfies(safeVersion, LEGACY_VERSION) -} - -export const isValidSafeVersion = (safeVersion?: SafeInfo['version']): safeVersion is SafeVersion => { - const SAFE_VERSIONS: SafeVersion[] = ['1.4.1', '1.3.0', '1.2.0', '1.1.1', '1.0.0'] - return !!safeVersion && SAFE_VERSIONS.some((version) => semverSatisfies(safeVersion, version)) -} - -// `assert` does not work with arrow functions -export function assertValidSafeVersion<T extends SafeInfo['version']>(safeVersion?: T): asserts safeVersion { - return invariant(isValidSafeVersion(safeVersion), `${safeVersion} is not a valid Safe Account version`) -} - -type SafeCoreSDKProps = { - provider: JsonRpcProvider - chainId: SafeInfo['chainId'] - address: SafeInfo['address']['value'] - version: SafeInfo['version'] - implementationVersionState: SafeInfo['implementationVersionState'] - implementation: SafeInfo['implementation']['value'] - undeployedSafe?: UndeployedSafe -} - -const isInDeployments = (address: string, deployments: string | string[] | undefined): boolean => { - if (Array.isArray(deployments)) { - return deployments.some((deployment) => sameAddress(deployment, address)) - } - return sameAddress(address, deployments) -} +import { isLegacyVersion } from '@safe-global/utils/services/contracts/utils' +import { isInDeployments } from '@safe-global/utils/hooks/coreSDK/utils' +import type { SafeCoreSDKProps } from '@safe-global/utils/hooks/coreSDK/types' // Safe Core SDK export const initSafeSDK = async ({ diff --git a/apps/web/src/hooks/coreSDK/useInitSafeCoreSDK.ts b/apps/web/src/hooks/coreSDK/useInitSafeCoreSDK.ts index 0ca0a8fdd2..3ae51d06ca 100644 --- a/apps/web/src/hooks/coreSDK/useInitSafeCoreSDK.ts +++ b/apps/web/src/hooks/coreSDK/useInitSafeCoreSDK.ts @@ -4,12 +4,12 @@ import { useRouter } from 'next/router' import useSafeInfo from '@/hooks/useSafeInfo' import { initSafeSDK, setSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' import { trackError } from '@/services/exceptions' -import ErrorCodes from '@/services/exceptions/ErrorCodes' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' import { useAppDispatch, useAppSelector } from '@/store' import { showNotification } from '@/store/notificationsSlice' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' -import { parsePrefixedAddress, sameAddress } from '@/utils/addresses' -import { asError } from '@/services/exceptions/utils' +import { parsePrefixedAddress, sameAddress } from '@safe-global/utils/utils/addresses' +import { asError } from '@safe-global/utils/services/exceptions/utils' export const useInitSafeCoreSDK = () => { const { safe, safeLoaded } = useSafeInfo() diff --git a/apps/web/src/hooks/loadables/helpers/__tests__/config.test.ts b/apps/web/src/hooks/loadables/helpers/__tests__/config.test.ts deleted file mode 100644 index b92becf49a..0000000000 --- a/apps/web/src/hooks/loadables/helpers/__tests__/config.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { getConfigs } from '@/hooks/loadables/helpers/config' - -jest.mock('@/config/gateway', () => ({ - GATEWAY_URL: 'https://safe-client.safe.global', - __esModule: true, -})) - -jest.mock('@/pages/_app', () => ({ - ...jest.requireActual('@/pages/_app'), - __esModule: true, -})) -import { chainBuilder } from '@/tests/builders/chains' - -// Mock data for testing -const mockDataPage1 = { - results: [chainBuilder().build(), chainBuilder().build()], - next: 'https://safe-client.safe.global/v1/chains?cursor=limit%3D2%26offset%3D20', -} - -const mockDataPage2 = { - results: [chainBuilder().build(), chainBuilder().build()], - next: null, -} - -describe('getConfigs', () => { - beforeEach(() => { - // Clear all instances and calls to fetch - jest.clearAllMocks() - }) - - it('should fetch data from the API and concatenate results', async () => { - // Mock fetch responses for each call - global.fetch = jest - .fn() - // First call returns mockDataPage1 - .mockResolvedValueOnce({ - ok: true, - json: async () => mockDataPage1, - }) - // Second call returns mockDataPage2 - .mockResolvedValueOnce({ - ok: true, - json: async () => mockDataPage2, - }) - - const results = await getConfigs() - - expect(results).toEqual([...mockDataPage1.results, ...mockDataPage2.results]) - - // Ensure fetch was called twice - expect(fetch).toHaveBeenCalledTimes(2) - - // Check that fetch was called with the correct URLs - expect(fetch).toHaveBeenCalledWith('https://safe-client.safe.global/v1/chains') - expect(fetch).toHaveBeenCalledWith('https://safe-client.safe.global/v1/chains?cursor=limit%3D2%26offset%3D20') - }) - - it('should handle a single page of results', async () => { - // Mock fetch response - global.fetch = jest.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({ - results: [mockDataPage1.results[0]], - next: null, - }), - }) - - const results = await getConfigs() - - expect(results).toEqual([mockDataPage1.results[0]]) - - // Ensure fetch was called once - expect(fetch).toHaveBeenCalledTimes(1) - - // Check that fetch was called with the correct URL - expect(fetch).toHaveBeenCalledWith('https://safe-client.safe.global/v1/chains') - }) - - it('should handle network errors', async () => { - // Mock fetch to throw an error - global.fetch = jest.fn().mockImplementation(() => { - throw new Error('Network error') - }) - - let error: Error | undefined = undefined - try { - await getConfigs() - } catch (e) { - error = e as Error - } - - expect(error).toBeDefined() - expect(error!.message).toBe('Network error') - - // Ensure fetch was called once - expect(fetch).toHaveBeenCalledTimes(1) - }) - - it('should handle HTTP errors (non-200 responses)', async () => { - // Mock fetch to return a non-OK response - global.fetch = jest.fn().mockResolvedValueOnce({ - ok: false, - status: 500, - }) - - let error: Error | undefined = undefined - try { - await getConfigs() - } catch (e) { - error = e as Error - } - - expect(error).toBeDefined() - expect(error!.message).toBe('HTTP error! status: 500') - - // Ensure fetch was called once - expect(fetch).toHaveBeenCalledTimes(1) - }) - - it('should handle empty results', async () => { - // Mock fetch response with empty results - global.fetch = jest.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({ - results: [], - next: null, - }), - }) - - const results = await getConfigs() - - expect(results).toEqual([]) - - // Ensure fetch was called once - expect(fetch).toHaveBeenCalledTimes(1) - }) - - it('should handle missing next property', async () => { - // Mock fetch response without next property - global.fetch = jest.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({ - results: [mockDataPage1.results[0]], - }), - }) - - const results = await getConfigs() - - expect(results).toEqual([mockDataPage1.results[0]]) - - // Ensure fetch was called once - expect(fetch).toHaveBeenCalledTimes(1) - }) -}) diff --git a/apps/web/src/hooks/loadables/helpers/config.ts b/apps/web/src/hooks/loadables/helpers/config.ts deleted file mode 100644 index 4f724f7719..0000000000 --- a/apps/web/src/hooks/loadables/helpers/config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' - -import { GATEWAY_URL } from '@/config/gateway' - -export const getConfigs = async (): Promise<ChainInfo[]> => { - let allResults: ChainInfo[] = [] - let url = `${GATEWAY_URL}/v1/chains` - - while (url) { - const response = await fetch(url) - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - const data = await response.json() - const results = data.results || [] - allResults = allResults.concat(results) - url = data.next - } - - return allResults -} diff --git a/apps/web/src/hooks/loadables/useLoadBalances.ts b/apps/web/src/hooks/loadables/useLoadBalances.ts index 40324c8b11..b9a6ce7493 100644 --- a/apps/web/src/hooks/loadables/useLoadBalances.ts +++ b/apps/web/src/hooks/loadables/useLoadBalances.ts @@ -1,16 +1,13 @@ -import { getCounterfactualBalance } from '@/features/counterfactual/utils' -import { useWeb3 } from '@/hooks/wallets/web3' -import { useEffect, useMemo } from 'react' -import { getBalances, type SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' +import { useMemo } from 'react' +import { type Balances, useBalancesGetBalancesV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/balances' import { useAppSelector } from '@/store' -import useAsync, { type AsyncResult } from '../useAsync' -import { Errors, logError } from '@/services/exceptions' +import { type AsyncResult } from '@safe-global/utils/hooks/useAsync' import { selectCurrency, selectSettings, TOKEN_LISTS } from '@/store/settingsSlice' import { useCurrentChain } from '../useChains' -import { FEATURES, hasFeature } from '@/utils/chains' -import { POLLING_INTERVAL } from '@/config/constants' -import useIntervalCounter from '../useIntervalCounter' import useSafeInfo from '../useSafeInfo' +import { POLLING_INTERVAL } from '@/config/constants' +import { useCounterfactualBalances } from '@/features/counterfactual/useCounterfactualBalances' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' export const useTokenListSetting = (): boolean | undefined => { const chain = useCurrentChain() @@ -24,46 +21,44 @@ export const useTokenListSetting = (): boolean | undefined => { return isTrustedTokenList } -export const useLoadBalances = (): AsyncResult<SafeBalanceResponse> => { - const [pollCount, resetPolling] = useIntervalCounter(POLLING_INTERVAL) +const useLoadBalances = () => { const currency = useAppSelector(selectCurrency) const isTrustedTokenList = useTokenListSetting() const { safe, safeAddress } = useSafeInfo() - const web3 = useWeb3() - const chain = useCurrentChain() - const chainId = safe.chainId - - // Re-fetch assets when the entire SafeInfo updates - const [data, error, loading] = useAsync<SafeBalanceResponse | undefined>( - () => { - if (!chainId || !safeAddress || isTrustedTokenList === undefined) return + const isReady = safeAddress && safe.deployed && isTrustedTokenList !== undefined + const isCounterfactual = !safe.deployed - if (!safe.deployed) { - return getCounterfactualBalance(safeAddress, web3, chain) - } - - return getBalances(chainId, safeAddress, currency, { - trusted: isTrustedTokenList, - }) + let { + currentData: balances, + isLoading: loading, + error: errorStr, + } = useBalancesGetBalancesV1Query( + { + chainId: safe.chainId, + safeAddress, + fiatCode: currency, + trusted: isTrustedTokenList, + }, + { + skip: !isReady, + pollingInterval: POLLING_INTERVAL, + skipPollingIfUnfocused: true, + refetchOnFocus: true, }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [safeAddress, chainId, currency, isTrustedTokenList, pollCount, safe.deployed, web3, chain], - false, // don't clear data between polls ) - // Reset the counter when safe address/chainId changes - useEffect(() => { - resetPolling() - }, [resetPolling, safeAddress, chainId]) + // Counterfactual balances + const [cfData, cfError, cfLoading] = useCounterfactualBalances(safe) + + let error = useMemo(() => (errorStr ? new Error(errorStr.toString()) : undefined), [errorStr]) - // Log errors - useEffect(() => { - if (error) { - logError(Errors._601, error.message) - } - }, [error]) + if (isCounterfactual) { + balances = cfData as unknown as Balances + loading = cfLoading + error = cfError + } - return [data, error, loading] + return useMemo(() => [balances, error, loading], [balances, error, loading]) as AsyncResult<Balances> } export default useLoadBalances diff --git a/apps/web/src/hooks/loadables/useLoadChains.ts b/apps/web/src/hooks/loadables/useLoadChains.ts index 7784578cf6..68c274aa15 100644 --- a/apps/web/src/hooks/loadables/useLoadChains.ts +++ b/apps/web/src/hooks/loadables/useLoadChains.ts @@ -1,20 +1,22 @@ import { useEffect } from 'react' -import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -import useAsync, { type AsyncResult } from '../useAsync' +import { useChainsGetChainsV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/chains' import { Errors, logError } from '@/services/exceptions' -import { getConfigs } from '@/hooks/loadables/helpers/config' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { AsyncResult } from '@safe-global/utils/hooks/useAsync' -export const useLoadChains = (): AsyncResult<ChainInfo[]> => { - const [data, error, loading] = useAsync<ChainInfo[]>(getConfigs, []) +const MAX_CHAINS = 40 + +export const useLoadChains = () => { + const { data, isLoading, error } = useChainsGetChainsV1Query({ cursor: `limit=${MAX_CHAINS}` }) // Log errors useEffect(() => { if (error) { - logError(Errors._620, error.message) + logError(Errors._620, error.toString()) } }, [error]) - return [data, error, loading] + return [data?.results, error, isLoading] as AsyncResult<ChainInfo[]> } export default useLoadChains diff --git a/apps/web/src/hooks/loadables/useLoadSafeInfo.ts b/apps/web/src/hooks/loadables/useLoadSafeInfo.ts index 2f0d5c75ff..842b340143 100644 --- a/apps/web/src/hooks/loadables/useLoadSafeInfo.ts +++ b/apps/web/src/hooks/loadables/useLoadSafeInfo.ts @@ -1,40 +1,42 @@ import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' import { getUndeployedSafeInfo } from '@/features/counterfactual/utils' import { useAppSelector } from '@/store' -import { useEffect } from 'react' -import { getSafeInfo, type SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import useAsync, { type AsyncResult } from '../useAsync' -import useSafeAddress from '../useSafeAddress' +import { useEffect, useMemo } from 'react' +import { getSafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import useAsync, { type AsyncResult } from '@safe-global/utils/hooks/useAsync' import { useChainId } from '../useChainId' import useIntervalCounter from '../useIntervalCounter' import useSafeInfo from '../useSafeInfo' import { Errors, logError } from '@/services/exceptions' import { POLLING_INTERVAL } from '@/config/constants' import { useCurrentChain } from '../useChains' +import { useSafeAddressFromUrl } from '../useSafeAddressFromUrl' -export const useLoadSafeInfo = (): AsyncResult<SafeInfo> => { - const address = useSafeAddress() +export const useLoadSafeInfo = (): AsyncResult<SafeState> => { + const address = useSafeAddressFromUrl() const chainId = useChainId() const chain = useCurrentChain() const [pollCount, resetPolling] = useIntervalCounter(POLLING_INTERVAL) const { safe } = useSafeInfo() const isStoredSafeValid = safe.chainId === chainId && safe.address.value === address + const cache = isStoredSafeValid ? safe : undefined const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chainId, address)) - const [data, error, loading] = useAsync<SafeInfo | undefined>(async () => { - if (!chainId || !address || !chain) return - + const [undeployedData, undeployedError] = useAsync<SafeState | undefined>(async () => { + if (!undeployedSafe || !chain) return /** * This is the one place where we can't check for `safe.deployed` as we want to update that value * when the local storage is cleared, so we have to check undeployedSafe */ - if (undeployedSafe) return getUndeployedSafeInfo(undeployedSafe, address, chain) + return getUndeployedSafeInfo(undeployedSafe, address, chain) + }, [undeployedSafe, address, chain]) + const [cgwData, cgwError, cgwLoading] = useAsync<SafeState | undefined>(async () => { + if (!chainId || !address || pollCount === undefined) return const safeInfo = await getSafeInfo(chainId, address) - return { ...safeInfo, deployed: true } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chainId, address, pollCount, undeployedSafe, chain]) + }, [chainId, address, pollCount]) // Reset the counter when safe address/chainId changes useEffect(() => { @@ -43,17 +45,17 @@ export const useLoadSafeInfo = (): AsyncResult<SafeInfo> => { // Log errors useEffect(() => { - if (error) { - logError(Errors._600, error.message) + if (cgwError) { + logError(Errors._600, cgwError.message) } - }, [error]) - - return [ - // Return stored SafeInfo between polls - data ?? (isStoredSafeValid ? safe : data), - error, - loading, - ] + }, [cgwError]) + + // Return stored SafeInfo between polls + const safeData = cgwData ?? undeployedData ?? cache + const error = cgwError ?? (undeployedSafe ? undeployedError : undefined) + const loading = cgwLoading + + return useMemo(() => [safeData, error, loading], [safeData, error, loading]) } export default useLoadSafeInfo diff --git a/apps/web/src/hooks/loadables/useLoadSafeMessages.ts b/apps/web/src/hooks/loadables/useLoadSafeMessages.ts index 867da23dcc..d68d8878f1 100644 --- a/apps/web/src/hooks/loadables/useLoadSafeMessages.ts +++ b/apps/web/src/hooks/loadables/useLoadSafeMessages.ts @@ -1,21 +1,21 @@ +import type { MessagePage } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { useEffect } from 'react' import { getSafeMessages } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeMessageListPage } from '@safe-global/safe-gateway-typescript-sdk' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { logError, Errors } from '@/services/exceptions' import useSafeInfo from '@/hooks/useSafeInfo' -import type { AsyncResult } from '@/hooks/useAsync' +import type { AsyncResult } from '@safe-global/utils/hooks/useAsync' -export const useLoadSafeMessages = (): AsyncResult<SafeMessageListPage> => { +export const useLoadSafeMessages = (): AsyncResult<MessagePage> => { const { safe, safeAddress, safeLoaded } = useSafeInfo() - const [data, error, loading] = useAsync<SafeMessageListPage>( + const [data, error, loading] = useAsync<MessagePage>( () => { if (!safeLoaded) return if (!safe.deployed) return Promise.resolve({ results: [] }) - return getSafeMessages(safe.chainId, safeAddress) + return getSafeMessages(safe.chainId, safeAddress) as Promise<MessagePage> }, // eslint-disable-next-line react-hooks/exhaustive-deps [safeLoaded, safe.chainId, safeAddress, safe.messagesTag, safe.deployed], diff --git a/apps/web/src/hooks/loadables/useLoadSpendingLimits.ts b/apps/web/src/hooks/loadables/useLoadSpendingLimits.ts index 20cc73443b..fcbf60a441 100644 --- a/apps/web/src/hooks/loadables/useLoadSpendingLimits.ts +++ b/apps/web/src/hooks/loadables/useLoadSpendingLimits.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import useAsync, { type AsyncResult } from '../useAsync' +import useAsync, { type AsyncResult } from '@safe-global/utils/hooks/useAsync' import useSafeInfo from '../useSafeInfo' import { Errors, logError } from '@/services/exceptions' import type { SpendingLimitState } from '@/store/spendingLimitsSlice' @@ -7,14 +7,17 @@ import useChainId from '@/hooks/useChainId' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import type { JsonRpcProvider } from 'ethers' import { getSpendingLimitContract } from '@/services/contracts/spendingLimitContracts' -import type { AddressEx, TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { type AllowanceModule } from '@/types/contracts' +import type { TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { type AllowanceModule } from '@safe-global/utils/types/contracts' import { getERC20TokenInfoOnChain } from '@/utils/tokens' import { sameString } from '@safe-global/protocol-kit/dist/src/utils' import { useAppSelector } from '@/store' import { selectTokens } from '@/store/balancesSlice' import isEqual from 'lodash/isEqual' +import { multicall } from '@safe-global/utils/utils/multicall' +import { sameAddress } from '@safe-global/utils/utils/addresses' const DEFAULT_TOKEN_INFO = { decimals: 18, @@ -27,45 +30,81 @@ const discardZeroAllowance = (spendingLimit: SpendingLimitState): boolean => const getTokenInfoFromBalances = (tokenInfoFromBalances: TokenInfo[], address: string): TokenInfo | undefined => tokenInfoFromBalances.find((token) => token.address === address) -export const getTokenAllowanceForDelegate = async ( +export const getTokenAllowances = async ( contract: AllowanceModule, + provider: JsonRpcProvider, safeAddress: string, - delegate: string, - token: string, + allowanceRequests: { delegate: string; token: string }[], tokenInfoFromBalances: TokenInfo[], -): Promise<SpendingLimitState> => { - const tokenAllowance = await contract.getTokenAllowance(safeAddress, delegate, token) - const [amount, spent, resetTimeMin, lastResetMin, nonce] = tokenAllowance - return { - beneficiary: delegate, - token: getTokenInfoFromBalances(tokenInfoFromBalances, token) || - (await getERC20TokenInfoOnChain(token)) || { ...DEFAULT_TOKEN_INFO, address: token }, - amount: amount.toString(), - spent: spent.toString(), - resetTimeMin: resetTimeMin.toString(), - lastResetMin: lastResetMin.toString(), - nonce: nonce.toString(), - } +): Promise<SpendingLimitState[]> => { + const moduleAddress = await contract.getAddress() + const calls = allowanceRequests.map(({ delegate, token }) => ({ + to: moduleAddress, + data: contract.interface.encodeFunctionData('getTokenAllowance', [safeAddress, delegate, token]), + })) + const results = await multicall(provider, calls) + + const tokenAllowances = results.map( + (result) => contract.interface.decodeFunctionResult('getTokenAllowance', result.returnData)[0], + ) + + const missingTokenAddresses = tokenAllowances + .map((_, index) => allowanceRequests[index].token) + .filter((tokenAddress) => !getTokenInfoFromBalances(tokenInfoFromBalances, tokenAddress)) + + const missingTokenInfos = await getERC20TokenInfoOnChain(missingTokenAddresses) + + return tokenAllowances.map((tokenAllowance, index) => { + const { delegate, token } = allowanceRequests[index] + const [amount, spent, resetTimeMin, lastResetMin, nonce] = tokenAllowance + return { + beneficiary: delegate, + token: getTokenInfoFromBalances(tokenInfoFromBalances, token) || + missingTokenInfos?.find((tokenInfo) => sameAddress(tokenInfo.address, token)) || { + ...DEFAULT_TOKEN_INFO, + address: token, + }, + amount: amount.toString(), + spent: spent.toString(), + resetTimeMin: resetTimeMin.toString(), + lastResetMin: lastResetMin.toString(), + nonce: nonce.toString(), + } + }) } -export const getTokensForDelegate = async ( +export const getTokensForDelegates = async ( contract: AllowanceModule, + provider: JsonRpcProvider, safeAddress: string, - delegate: string, + delegates: string[], tokenInfoFromBalances: TokenInfo[], ) => { - const tokens = await contract.getTokens(safeAddress, delegate) + const allowanceAddress = await contract.getAddress() + const calls = delegates.map((delegate) => ({ + to: allowanceAddress, + data: contract.interface.encodeFunctionData('getTokens', [safeAddress, delegate]), + })) - return Promise.all( - tokens.map(async (token) => - getTokenAllowanceForDelegate(contract, safeAddress, delegate, token, tokenInfoFromBalances), - ), + const results = await multicall(provider, calls) + const tokens = results.map( + (result) => contract.interface.decodeFunctionResult('getTokens', result.returnData)[0] as string[], ) + + const spendingLimitRequests = delegates.flatMap((delegate, idx) => { + const tokensForDelegate = tokens[idx] + return tokensForDelegate.map((token) => ({ + delegate, + token, + })) + }) + + return getTokenAllowances(contract, provider, safeAddress, spendingLimitRequests, tokenInfoFromBalances) } export const getSpendingLimits = async ( provider: JsonRpcProvider, - safeModules: AddressEx[], + safeModules: AddressInfo[], safeAddress: string, chainId: string, tokenInfoFromBalances: TokenInfo[], @@ -78,11 +117,14 @@ export const getSpendingLimits = async ( } const delegates = await contract.getDelegates(safeAddress, 0, 100) - const spendingLimits = await Promise.all( - delegates.results.map(async (delegate) => - getTokensForDelegate(contract, safeAddress, delegate, tokenInfoFromBalances), - ), + const spendingLimits = await getTokensForDelegates( + contract, + provider, + safeAddress, + delegates.results, + tokenInfoFromBalances, ) + return spendingLimits.flat().filter(discardZeroAllowance) } diff --git a/apps/web/src/hooks/loadables/useLoadTxHistory.ts b/apps/web/src/hooks/loadables/useLoadTxHistory.ts index cf2892ed3f..ebdb7494c6 100644 --- a/apps/web/src/hooks/loadables/useLoadTxHistory.ts +++ b/apps/web/src/hooks/loadables/useLoadTxHistory.ts @@ -1,13 +1,14 @@ import { useEffect } from 'react' import { type TransactionListPage } from '@safe-global/safe-gateway-typescript-sdk' -import useAsync, { type AsyncResult } from '../useAsync' +import useAsync, { type AsyncResult } from '@safe-global/utils/hooks/useAsync' import { Errors, logError } from '@/services/exceptions' import useSafeInfo from '../useSafeInfo' import { getTxHistory } from '@/services/transactions' import { useAppSelector } from '@/store' import { selectSettings } from '@/store/settingsSlice' import { useHasFeature } from '../useChains' -import { FEATURES } from '@/utils/chains' + +import { FEATURES } from '@safe-global/utils/utils/chains' export const useLoadTxHistory = (): AsyncResult<TransactionListPage> => { const { safe, safeAddress, safeLoaded } = useSafeInfo() diff --git a/apps/web/src/hooks/loadables/useLoadTxQueue.ts b/apps/web/src/hooks/loadables/useLoadTxQueue.ts index 5f1aee69f1..deaebedb16 100644 --- a/apps/web/src/hooks/loadables/useLoadTxQueue.ts +++ b/apps/web/src/hooks/loadables/useLoadTxQueue.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { getTransactionQueue, type TransactionListPage } from '@safe-global/safe-gateway-typescript-sdk' -import useAsync, { type AsyncResult } from '../useAsync' +import useAsync, { type AsyncResult } from '@safe-global/utils/hooks/useAsync' import useSafeInfo from '../useSafeInfo' import { Errors, logError } from '@/services/exceptions' import { TxEvent, txSubscribe } from '@/services/tx/txEvents' diff --git a/apps/web/src/hooks/messages/__tests__/useIsSafeMessageSignableBy.test.ts b/apps/web/src/hooks/messages/__tests__/useIsSafeMessageSignableBy.test.ts index 81348292f2..ebfdc7c0b1 100644 --- a/apps/web/src/hooks/messages/__tests__/useIsSafeMessageSignableBy.test.ts +++ b/apps/web/src/hooks/messages/__tests__/useIsSafeMessageSignableBy.test.ts @@ -1,4 +1,4 @@ -import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { renderHook } from '@/tests/test-utils' import * as useIsSafeOwnerHook from '@/hooks/useIsSafeOwner' @@ -16,7 +16,7 @@ describe('useIsSafeMessageSignableBy', () => { }, }, ], - } as SafeMessage + } as MessageItem const address = '0x456' @@ -35,7 +35,7 @@ describe('useIsSafeMessageSignableBy', () => { }, }, ], - } as SafeMessage + } as MessageItem const address = '0x456' @@ -53,7 +53,7 @@ describe('useIsSafeMessageSignableBy', () => { }, }, ], - } as SafeMessage + } as MessageItem const address = '0x123' diff --git a/apps/web/src/hooks/messages/__tests__/useSafeMessageNotifications.test.ts b/apps/web/src/hooks/messages/__tests__/useSafeMessageNotifications.test.ts index a71df5781e..e819e3e725 100644 --- a/apps/web/src/hooks/messages/__tests__/useSafeMessageNotifications.test.ts +++ b/apps/web/src/hooks/messages/__tests__/useSafeMessageNotifications.test.ts @@ -1,6 +1,5 @@ +import type { SafeMessageListItem } from '@safe-global/store/gateway/types' import { toBeHex } from 'ethers' -import { SafeMessageStatus, SafeMessageListItemType } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeMessageListItem } from '@safe-global/safe-gateway-typescript-sdk' import { safeMsgDispatch, SafeMsgEvent } from '@/services/safe-messages/safeMsgEvents' import { showNotification } from '@/store/notificationsSlice' @@ -21,8 +20,8 @@ describe('useSafeMessageNotifications', () => { it('should return all SafeMessages awaiting confirmation of the current wallet', () => { const items: SafeMessageListItem[] = [ { - type: SafeMessageListItemType.MESSAGE, - status: SafeMessageStatus.NEEDS_CONFIRMATION, + type: 'MESSAGE', + status: 'NEEDS_CONFIRMATION', messageHash: '0x123', confirmations: [], } as unknown as SafeMessageListItem, @@ -32,8 +31,8 @@ describe('useSafeMessageNotifications', () => { expect(messages).toStrictEqual([ { - type: SafeMessageListItemType.MESSAGE, - status: SafeMessageStatus.NEEDS_CONFIRMATION, + type: 'MESSAGE', + status: 'NEEDS_CONFIRMATION', messageHash: '0x123', confirmations: [], }, @@ -43,7 +42,7 @@ describe('useSafeMessageNotifications', () => { it('should filter DATE_LABELs', () => { const items = [ { - type: SafeMessageListItemType.DATE_LABEL, + type: 'DATE_LABEL' as const, } as SafeMessageListItem, ] @@ -55,8 +54,8 @@ describe('useSafeMessageNotifications', () => { it('should filter pending messages', () => { const items: SafeMessageListItem[] = [ { - type: SafeMessageListItemType.MESSAGE, - status: SafeMessageStatus.NEEDS_CONFIRMATION, + type: 'MESSAGE', + status: 'NEEDS_CONFIRMATION', messageHash: '0x123', confirmations: [ { @@ -81,8 +80,8 @@ describe('useSafeMessageNotifications', () => { it('should filter messages already confirmed by the connected wallet', () => { const items: SafeMessageListItem[] = [ { - type: SafeMessageListItemType.MESSAGE, - status: SafeMessageStatus.NEEDS_CONFIRMATION, + type: 'MESSAGE', + status: 'NEEDS_CONFIRMATION', messageHash: '0x123', confirmations: [ { diff --git a/apps/web/src/hooks/messages/__tests__/useSafeMessageStatus.test.ts b/apps/web/src/hooks/messages/__tests__/useSafeMessageStatus.test.ts index bc0a390e07..4207c53619 100644 --- a/apps/web/src/hooks/messages/__tests__/useSafeMessageStatus.test.ts +++ b/apps/web/src/hooks/messages/__tests__/useSafeMessageStatus.test.ts @@ -1,4 +1,5 @@ -import type { SafeMessage, SafeMessageStatus } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeMessageStatus } from '@safe-global/store/gateway/types' +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { renderHook } from '@/tests/test-utils' import * as useIsSafeMessagePendingHook from '@/hooks/messages/useIsSafeMessagePending' @@ -10,7 +11,7 @@ describe('useSafeMessageStatus', () => { it('should return "Confirming" if the message is pending', () => { jest.spyOn(useIsSafeMessagePendingHook, 'default').mockImplementation(() => true) - const message = {} as SafeMessage + const message = {} as MessageItem const { result } = renderHook(() => useSafeMessageStatus(message)) expect(result.current).toBe('Confirming') @@ -18,12 +19,17 @@ describe('useSafeMessageStatus', () => { it('should return "Awaiting confirmations" if the message is not pending, the wallet has signed it but it is not fully signed', () => { jest.spyOn(useIsSafeMessagePendingHook, 'default').mockImplementation(() => false) - jest.spyOn(useWalletHook, 'default').mockImplementation(() => ({ address: '0x123' }) as ConnectedWallet) + jest.spyOn(useWalletHook, 'default').mockImplementation( + () => + ({ + address: '0x123', + }) as ConnectedWallet, + ) const message = { confirmations: [{ owner: { value: '0x123' } }], status: 'NEEDS_CONFIRMATION' as SafeMessageStatus, - } as SafeMessage + } as MessageItem const { result } = renderHook(() => useSafeMessageStatus(message)) expect(result.current).toBe('Awaiting confirmations') @@ -31,12 +37,17 @@ describe('useSafeMessageStatus', () => { it('should return the message status if the message is not pending and the wallet has not signed the message', () => { jest.spyOn(useIsSafeMessagePendingHook, 'default').mockImplementation(() => false) - jest.spyOn(useWalletHook, 'default').mockImplementation(() => ({ address: '0x123' }) as ConnectedWallet) + jest.spyOn(useWalletHook, 'default').mockImplementation( + () => + ({ + address: '0x123', + }) as ConnectedWallet, + ) const message = { - confirmations: [{ owner: { value: '0x456' } }] as SafeMessage['confirmations'], + confirmations: [{ owner: { value: '0x456' } }] as MessageItem['confirmations'], status: 'NEEDS_CONFIRMATION' as SafeMessageStatus, - } as SafeMessage + } as MessageItem const { result } = renderHook(() => useSafeMessageStatus(message)) expect(result.current).toBe('Needs confirmation') @@ -44,12 +55,17 @@ describe('useSafeMessageStatus', () => { it('should return the message status if the message is not pending and it is fully signed', () => { jest.spyOn(useIsSafeMessagePendingHook, 'default').mockImplementation(() => false) - jest.spyOn(useWalletHook, 'default').mockImplementation(() => ({ address: '0x123' }) as ConnectedWallet) + jest.spyOn(useWalletHook, 'default').mockImplementation( + () => + ({ + address: '0x123', + }) as ConnectedWallet, + ) const message = { - confirmations: [{ owner: { value: '0x123' } }] as SafeMessage['confirmations'], + confirmations: [{ owner: { value: '0x123' } }] as MessageItem['confirmations'], status: 'CONFIRMED' as SafeMessageStatus, - } as SafeMessage + } as MessageItem const { result } = renderHook(() => useSafeMessageStatus(message)) expect(result.current).toBe('Confirmed') diff --git a/apps/web/src/hooks/messages/useDecodedSafeMessage.ts b/apps/web/src/hooks/messages/useDecodedSafeMessage.ts index b619701ed8..d2f4eb733c 100644 --- a/apps/web/src/hooks/messages/useDecodedSafeMessage.ts +++ b/apps/web/src/hooks/messages/useDecodedSafeMessage.ts @@ -1,6 +1,7 @@ +import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { getDecodedMessage } from '@/components/safe-apps/utils' -import { generateSafeMessageMessage, generateSafeMessageHash } from '@/utils/safe-messages' -import type { EIP712TypedData, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { generateSafeMessageMessage, generateSafeMessageHash } from '@safe-global/utils/utils/safe-messages' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { useMemo } from 'react' /** @@ -16,9 +17,9 @@ import { useMemo } from 'react' * }` */ const useDecodedSafeMessage = ( - message: string | EIP712TypedData, - safe: SafeInfo, -): { decodedMessage: string | EIP712TypedData; safeMessageMessage: string; safeMessageHash: string } => { + message: string | TypedData, + safe: SafeState, +): { decodedMessage: string | TypedData; safeMessageMessage: string; safeMessageHash: string } => { // Decode message if UTF-8 encoded const decodedMessage = useMemo(() => { return typeof message === 'string' ? getDecodedMessage(message) : message diff --git a/apps/web/src/hooks/messages/useIsSafeMessageSignableBy.ts b/apps/web/src/hooks/messages/useIsSafeMessageSignableBy.ts index 35f0f87be6..450c37ace8 100644 --- a/apps/web/src/hooks/messages/useIsSafeMessageSignableBy.ts +++ b/apps/web/src/hooks/messages/useIsSafeMessageSignableBy.ts @@ -1,8 +1,8 @@ -import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import useIsSafeOwner from '@/hooks/useIsSafeOwner' -const useIsSafeMessageSignableBy = (message: SafeMessage, walletAddress: string): boolean => { +const useIsSafeMessageSignableBy = (message: MessageItem, walletAddress: string): boolean => { const isSafeOwner = useIsSafeOwner() return isSafeOwner && message.confirmations.every(({ owner }) => owner.value !== walletAddress) } diff --git a/apps/web/src/hooks/messages/useSafeMessage.ts b/apps/web/src/hooks/messages/useSafeMessage.ts index e1ae7a4b0c..65899344f4 100644 --- a/apps/web/src/hooks/messages/useSafeMessage.ts +++ b/apps/web/src/hooks/messages/useSafeMessage.ts @@ -1,13 +1,13 @@ import { isSafeMessageListItem } from '@/utils/safe-message-guards' -import { type SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import { useState, useEffect } from 'react' import useSafeMessages from './useSafeMessages' -import useAsync from '../useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useSafeInfo from '../useSafeInfo' import { fetchSafeMessage } from './useSyncSafeMessageSigner' +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' const useSafeMessage = (safeMessageHash: string | undefined) => { - const [safeMessage, setSafeMessage] = useState<SafeMessage | undefined>() + const [safeMessage, setSafeMessage] = useState<MessageItem | undefined>() const { safe } = useSafeInfo() diff --git a/apps/web/src/hooks/messages/useSafeMessageNotifications.ts b/apps/web/src/hooks/messages/useSafeMessageNotifications.ts index 0a7fc76151..67d2c0bba4 100644 --- a/apps/web/src/hooks/messages/useSafeMessageNotifications.ts +++ b/apps/web/src/hooks/messages/useSafeMessageNotifications.ts @@ -1,11 +1,10 @@ +import type { SafeMessageListItem } from '@safe-global/store/gateway/types' import { useEffect, useMemo, useRef } from 'react' -import { SafeMessageStatus } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeMessageListItem } from '@safe-global/safe-gateway-typescript-sdk' import { SafeMsgEvent, safeMsgSubscribe } from '@/services/safe-messages/safeMsgEvents' import { useAppDispatch, useAppSelector } from '@/store' import { selectNotifications, showNotification } from '@/store/notificationsSlice' -import { formatError } from '@/utils/formatters' +import { formatError } from '@safe-global/utils/utils/formatters' import { isSafeMessageListItem } from '@/utils/safe-message-guards' import useSafeMessages from '@/hooks/messages/useSafeMessages' import { selectPendingSafeMessages } from '@/store/pendingSafeMessagesSlice' @@ -31,7 +30,7 @@ export const _getSafeMessagesAwaitingConfirmations = ( walletAddress: string, ) => { return items.filter(isSafeMessageListItem).filter((message) => { - const needsConfirmation = message.status === SafeMessageStatus.NEEDS_CONFIRMATION + const needsConfirmation = message.status === 'NEEDS_CONFIRMATION' const isPending = !!pendingMsgs[message.messageHash] const canSign = message.confirmations.every(({ owner }) => owner.value !== walletAddress) return needsConfirmation && !isPending && canSign diff --git a/apps/web/src/hooks/messages/useSafeMessageStatus.ts b/apps/web/src/hooks/messages/useSafeMessageStatus.ts index c68f48eda5..60a2384318 100644 --- a/apps/web/src/hooks/messages/useSafeMessageStatus.ts +++ b/apps/web/src/hooks/messages/useSafeMessageStatus.ts @@ -1,22 +1,28 @@ -import { SafeMessageStatus } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' +import type { SafeMessageStatus } from '@safe-global/store/gateway/types' import useIsSafeMessagePending from './useIsSafeMessagePending' import useWallet from '../wallets/useWallet' const ConfirmingStatus = 'CONFIRMING' const AwaitingConfirmationsStatus = 'AWAITING_CONFIRMATIONS' - -type SafeMessageLocalStatus = SafeMessageStatus | typeof ConfirmingStatus | typeof AwaitingConfirmationsStatus +const ConfirmedStatus = 'CONFIRMED' +const NeedsConfirmationStatus = 'NEEDS_CONFIRMATION' +type SafeMessageLocalStatus = + | SafeMessageStatus + | typeof ConfirmingStatus + | typeof AwaitingConfirmationsStatus + | typeof ConfirmedStatus + | typeof NeedsConfirmationStatus const STATUS_LABELS: { [_key in SafeMessageLocalStatus]: string } = { [ConfirmingStatus]: 'Confirming', [AwaitingConfirmationsStatus]: 'Awaiting confirmations', - [SafeMessageStatus.CONFIRMED]: 'Confirmed', - [SafeMessageStatus.NEEDS_CONFIRMATION]: 'Needs confirmation', + [ConfirmedStatus]: 'Confirmed', + [NeedsConfirmationStatus]: 'Needs confirmation', } -const useSafeMessageStatus = (msg: SafeMessage) => { +const useSafeMessageStatus = (msg: MessageItem) => { const isPending = useIsSafeMessagePending(msg.messageHash) const wallet = useWallet() @@ -25,7 +31,7 @@ const useSafeMessageStatus = (msg: SafeMessage) => { } const hasWalletSigned = wallet && msg.confirmations.some(({ owner }) => owner.value === wallet.address) - const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED + const isConfirmed = msg.status === ConfirmedStatus if (hasWalletSigned && !isConfirmed) { return STATUS_LABELS[AwaitingConfirmationsStatus] } diff --git a/apps/web/src/hooks/messages/useSafeMessages.ts b/apps/web/src/hooks/messages/useSafeMessages.ts index 771c23679d..5dc4a45f5d 100644 --- a/apps/web/src/hooks/messages/useSafeMessages.ts +++ b/apps/web/src/hooks/messages/useSafeMessages.ts @@ -1,27 +1,27 @@ +import type { MessagePage } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { getSafeMessages } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeMessageListPage } from '@safe-global/safe-gateway-typescript-sdk' import { useAppSelector } from '@/store' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useSafeInfo from '@/hooks/useSafeInfo' import { selectSafeMessages } from '@/store/safeMessagesSlice' const useSafeMessages = ( pageUrl?: string, ): { - page?: SafeMessageListPage + page?: MessagePage error?: string loading: boolean } => { const { safe, safeAddress, safeLoaded } = useSafeInfo() // If pageUrl is passed, load a new messages page from the API - const [page, error, loading] = useAsync<SafeMessageListPage>( + const [page, error, loading] = useAsync<MessagePage>( () => { if (!safeLoaded || !pageUrl) { return } - return getSafeMessages(safe.chainId, safeAddress, pageUrl) + return getSafeMessages(safe.chainId, safeAddress, pageUrl) as Promise<MessagePage> }, [safe.chainId, safeAddress, safeLoaded, pageUrl], false, diff --git a/apps/web/src/hooks/messages/useSyncSafeMessageSigner.ts b/apps/web/src/hooks/messages/useSyncSafeMessageSigner.ts index 191bcdaa4c..b685e78094 100644 --- a/apps/web/src/hooks/messages/useSyncSafeMessageSigner.ts +++ b/apps/web/src/hooks/messages/useSyncSafeMessageSigner.ts @@ -1,25 +1,22 @@ +import type { TypedData, MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import useWallet from '@/hooks/wallets/useWallet' import { Errors, logError } from '@/services/exceptions' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { dispatchPreparedSignature } from '@/services/safe-messages/safeMsgNotifications' import { dispatchSafeMsgProposal, dispatchSafeMsgConfirmation } from '@/services/safe-messages/safeMsgSender' -import { - getSafeMessage, - SafeMessageListItemType, - type EIP712TypedData, - type SafeMessage, -} from '@safe-global/safe-gateway-typescript-sdk' +import { getSafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import { useEffect, useCallback, useState } from 'react' import useSafeInfo from '../useSafeInfo' const HIDE_DELAY = 3000 -export const fetchSafeMessage = async (safeMessageHash: string, chainId: string) => { - let message: SafeMessage | undefined +export const fetchSafeMessage = async (safeMessageHash: string, chainId: string): Promise<MessageItem | undefined> => { + let message: MessageItem | undefined try { // fetchedMessage does not have a type because it is explicitly a message const fetchedMessage = await getSafeMessage(chainId, safeMessageHash) - message = { ...fetchedMessage, type: SafeMessageListItemType.MESSAGE } + // @ts-expect-error - the getSafeMessage type from the safe-gateway-typescript-sdk is wrong. The gateway returns a MessageItem + message = { ...fetchedMessage, type: 'MESSAGE' } } catch (err) { logError(Errors._613, err) throw err @@ -29,8 +26,8 @@ export const fetchSafeMessage = async (safeMessageHash: string, chainId: string) } const useSyncSafeMessageSigner = ( - message: SafeMessage | undefined, - decodedMessage: string | EIP712TypedData, + message: MessageItem | undefined, + decodedMessage: string | TypedData, safeMessageHash: string, requestId: string | undefined, origin: string | undefined, @@ -66,7 +63,7 @@ const useSyncSafeMessageSigner = ( const updatedMsg = await fetchSafeMessage(safeMessageHash, safe.chainId) // If threshold 1, we do not want to wait for polling - if (safe.threshold === 1) { + if (safe.threshold === 1 && updatedMsg) { setTimeout(() => dispatchPreparedSignature(updatedMsg, safeMessageHash, onClose, requestId), HIDE_DELAY) } return updatedMsg @@ -80,7 +77,9 @@ const useSyncSafeMessageSigner = ( } const updatedMsg = await fetchSafeMessage(safeMessageHash, safe.chainId) - setTimeout(() => dispatchPreparedSignature(updatedMsg, safeMessageHash, onClose, requestId), HIDE_DELAY) + if (updatedMsg) { + setTimeout(() => dispatchPreparedSignature(updatedMsg, safeMessageHash, onClose, requestId), HIDE_DELAY) + } return updatedMsg } } catch (e) { diff --git a/apps/web/src/hooks/safe-apps/permissions/index.ts b/apps/web/src/hooks/safe-apps/permissions/index.ts index 5532409db6..ce1460ede0 100644 --- a/apps/web/src/hooks/safe-apps/permissions/index.ts +++ b/apps/web/src/hooks/safe-apps/permissions/index.ts @@ -1,6 +1,6 @@ import { RestrictedMethods } from '@safe-global/safe-apps-sdk' import type { AllowedFeatures } from '@/components/safe-apps/types' -import { capitalize } from '@/utils/formatters' +import { capitalize } from '@safe-global/utils/utils/formatters' type PermissionsDisplayType = { displayName: string diff --git a/apps/web/src/hooks/safe-apps/useCustomAppCommunicator.tsx b/apps/web/src/hooks/safe-apps/useCustomAppCommunicator.tsx index 5367dd0d93..d360280d1a 100644 --- a/apps/web/src/hooks/safe-apps/useCustomAppCommunicator.tsx +++ b/apps/web/src/hooks/safe-apps/useCustomAppCommunicator.tsx @@ -10,28 +10,25 @@ import { type SendTransactionRequestParams, } from '@safe-global/safe-apps-sdk' import { SafeAppsTxFlow, SignMessageFlow, SignMessageOnChainFlow } from '@/components/tx-flow/flows' -import { isOffchainEIP1271Supported } from '@/utils/safe-messages' -import { - getBalances, - getSafeMessage, - getTransactionDetails, - type SafeAppData, -} from '@safe-global/safe-gateway-typescript-sdk' +import { isOffchainEIP1271Supported } from '@safe-global/utils/utils/safe-messages' +import { getSafeMessage, getTransactionDetails, type SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' import useGetSafeInfo from '@/components/safe-apps/AppFrame/useGetSafeInfo' -import { FEATURES, hasFeature } from '@/utils/chains' import { isSafeMessageListItem } from '@/utils/safe-message-guards' import { TxModalContext } from '@/components/tx-flow' import { selectOnChainSigning, selectTokenList, TOKEN_LISTS } from '@/store/settingsSlice' import { useAppSelector } from '@/store' import useSafeInfo from '@/hooks/useSafeInfo' import { selectSafeMessages } from '@/store/safeMessagesSlice' -import { trackSafeAppEvent, SAFE_APPS_EVENTS, trackEvent } from '@/services/analytics' +import { trackSafeAppEvent, SAFE_APPS_EVENTS } from '@/services/analytics' import { safeMsgSubscribe, SafeMsgEvent } from '@/services/safe-messages/safeMsgEvents' import { txSubscribe, TxEvent } from '@/services/tx/txEvents' import type { ChainInfo as WebCoreChainInfo } from '@safe-global/safe-gateway-typescript-sdk/dist/types/chains' import useChainId from '@/hooks/useChainId' import type AppCommunicator from '@/services/safe-apps/AppCommunicator' import useBalances from '@/hooks/useBalances' +import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' +import { useLazyBalancesGetBalancesV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/balances' export const useCustomAppCommunicator = ( iframeRef: MutableRefObject<HTMLIFrameElement | null>, @@ -60,6 +57,7 @@ export const useCustomAppCommunicator = ( const tokenlist = useAppSelector(selectTokenList) const chainId = useChainId() const { balances } = useBalances() + const [getBalances] = useLazyBalancesGetBalancesV1Query() const communicator = useAppCommunicator(iframeRef, appData, chain, { onConfirmTransactions: (txs: BaseTransaction[], requestId: RequestId, params?: SendTransactionRequestParams) => { @@ -72,7 +70,7 @@ export const useCustomAppCommunicator = ( } setCurrentRequestId(requestId) - trackEvent({ ...SAFE_APPS_EVENTS.OPEN_TRANSACTION_MODAL, label: appData.name }) + trackSafeAppEvent({ ...SAFE_APPS_EVENTS.OPEN_TRANSACTION_MODAL, label: appData.name }) setTxFlow(<SafeAppsTxFlow data={data} />, onTxFlowClose) }, onSignMessage: ( @@ -91,7 +89,7 @@ export const useCustomAppCommunicator = ( <SignMessageFlow logoUri={appData?.iconUrl || ''} name={appData?.name || ''} - message={message} + message={message as string | TypedData} origin={appData?.url} requestId={requestId} />, @@ -120,12 +118,14 @@ export const useCustomAppCommunicator = ( onGetSafeInfo: useGetSafeInfo(), onGetSafeBalances: (currency) => { const isDefaultTokenlistSupported = chain && hasFeature(chain, FEATURES.DEFAULT_TOKENLIST) - return safe.deployed - ? getBalances(chainId, safeAddress, currency, { - exclude_spam: true, + ? getBalances({ + chainId, + safeAddress, + fiatCode: currency, + excludeSpam: true, trusted: isDefaultTokenlistSupported && TOKEN_LISTS.TRUSTED === tokenlist, - }) + }).then((res) => res.data ?? balances) : Promise.resolve(balances) }, onGetChainInfo: () => { @@ -157,14 +157,14 @@ export const useCustomAppCommunicator = ( ?.find((item) => item.messageHash === messageHash) if (safeMessage) { - return safeMessage.preparedSignature + return safeMessage.preparedSignature || undefined } try { const { preparedSignature } = await getSafeMessage(chainId, messageHash) - return preparedSignature + return preparedSignature || undefined } catch { - return '' + return undefined } }, ...overrideHandlers, diff --git a/apps/web/src/hooks/safe-apps/useRemoteSafeApps.ts b/apps/web/src/hooks/safe-apps/useRemoteSafeApps.ts index 0cd9bd4d2a..8b12b8a048 100644 --- a/apps/web/src/hooks/safe-apps/useRemoteSafeApps.ts +++ b/apps/web/src/hooks/safe-apps/useRemoteSafeApps.ts @@ -1,11 +1,11 @@ -import { useEffect, useMemo } from 'react' +import type { SafeAppsName, SafeAppsTag } from '@/config/constants' +import useChainId from '@/hooks/useChainId' +import { Errors, logError } from '@/services/exceptions' import type { SafeAppsResponse } from '@safe-global/safe-gateway-typescript-sdk' import { getSafeApps } from '@safe-global/safe-gateway-typescript-sdk' -import { Errors, logError } from '@/services/exceptions' -import useChainId from '@/hooks/useChainId' -import type { AsyncResult } from '../useAsync' -import useAsync from '../useAsync' -import type { SafeAppsTag } from '@/config/constants' +import { useEffect, useMemo } from 'react' +import type { AsyncResult } from '@safe-global/utils/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' // To avoid multiple simultaneous requests (e.g. the Dashboard and the SAFE header widget), // cache the request promise for 100ms @@ -25,7 +25,12 @@ const cachedGetSafeApps = (chainId: string): ReturnType<typeof getSafeApps> | un return cache[chainId] } -const useRemoteSafeApps = (tag?: SafeAppsTag): AsyncResult<SafeAppsResponse> => { +type UseRemoteSafeAppsProps = + | { tag: SafeAppsTag; name?: never } + | { name: SafeAppsName; tag?: never } + | { name?: never; tag?: never } + +const useRemoteSafeApps = ({ tag, name }: UseRemoteSafeAppsProps = {}): AsyncResult<SafeAppsResponse> => { const chainId = useChainId() const [remoteApps, error, loading] = useAsync<SafeAppsResponse>(() => { @@ -40,9 +45,15 @@ const useRemoteSafeApps = (tag?: SafeAppsTag): AsyncResult<SafeAppsResponse> => }, [error]) const apps = useMemo(() => { - if (!remoteApps || !tag) return remoteApps - return remoteApps.filter((app) => app.tags.includes(tag)) - }, [remoteApps, tag]) + if (!remoteApps) return remoteApps + if (tag) { + return remoteApps.filter((app) => app.tags.includes(tag)) + } + if (name) { + return remoteApps.filter((app) => app.name === name) + } + return remoteApps + }, [remoteApps, tag, name]) const sortedApps = useMemo(() => { return apps?.sort((a, b) => a.name.localeCompare(b.name)) diff --git a/apps/web/src/hooks/safe-apps/useSafeAppFromBackend.ts b/apps/web/src/hooks/safe-apps/useSafeAppFromBackend.ts index fec7531df5..5eba147a01 100644 --- a/apps/web/src/hooks/safe-apps/useSafeAppFromBackend.ts +++ b/apps/web/src/hooks/safe-apps/useSafeAppFromBackend.ts @@ -2,8 +2,8 @@ import { useEffect } from 'react' import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' import { getSafeApps } from '@safe-global/safe-gateway-typescript-sdk' import { Errors, logError } from '@/services/exceptions' -import type { AsyncResult } from '../useAsync' -import useAsync from '../useAsync' +import type { AsyncResult } from '@safe-global/utils/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { trimTrailingSlash } from '@/utils/url' const useSafeAppFromBackend = (url: string, chainId: string): AsyncResult<SafeAppData> => { diff --git a/apps/web/src/hooks/safe-apps/useSafeAppFromManifest.ts b/apps/web/src/hooks/safe-apps/useSafeAppFromManifest.ts index 4a67b92260..8b68414b1b 100644 --- a/apps/web/src/hooks/safe-apps/useSafeAppFromManifest.ts +++ b/apps/web/src/hooks/safe-apps/useSafeAppFromManifest.ts @@ -2,10 +2,10 @@ import { useEffect, useMemo } from 'react' import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' import { Errors, logError } from '@/services/exceptions' import { fetchSafeAppFromManifest } from '@/services/safe-apps/manifest' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { getEmptySafeApp } from '@/components/safe-apps/utils' import type { SafeAppDataWithPermissions } from '@/components/safe-apps/types' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' type UseSafeAppFromManifestReturnType = { safeApp: SafeAppDataWithPermissions diff --git a/apps/web/src/hooks/safe-apps/useTxBuilderApp.ts b/apps/web/src/hooks/safe-apps/useTxBuilderApp.ts index 84b54331fa..f20dd0b018 100644 --- a/apps/web/src/hooks/safe-apps/useTxBuilderApp.ts +++ b/apps/web/src/hooks/safe-apps/useTxBuilderApp.ts @@ -7,7 +7,7 @@ import { AppRoutes } from '@/config/routes' import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' export const useTxBuilderApp = (): { app?: SafeAppData; link: UrlObject } | undefined => { - const [matchingApps] = useRemoteSafeApps(SafeAppsTag.TX_BUILDER) + const [matchingApps] = useRemoteSafeApps({ tag: SafeAppsTag.TX_BUILDER }) const router = useRouter() const app = matchingApps?.[0] diff --git a/apps/web/src/hooks/useAddressBook.ts b/apps/web/src/hooks/useAddressBook.ts index 9b835f0275..c99d01290f 100644 --- a/apps/web/src/hooks/useAddressBook.ts +++ b/apps/web/src/hooks/useAddressBook.ts @@ -2,9 +2,9 @@ import { useAppSelector } from '@/store' import { selectAddressBookByChain } from '@/store/addressBookSlice' import useChainId from './useChainId' -const useAddressBook = () => { - const chainId = useChainId() - return useAppSelector((state) => selectAddressBookByChain(state, chainId)) +const useAddressBook = (chainId?: string) => { + const currentChainId = useChainId() + return useAppSelector((state) => selectAddressBookByChain(state, chainId ?? currentChainId)) } export default useAddressBook diff --git a/apps/web/src/hooks/useAddressResolver.ts b/apps/web/src/hooks/useAddressResolver.ts index 2a44464c7a..1c62e2387d 100644 --- a/apps/web/src/hooks/useAddressResolver.ts +++ b/apps/web/src/hooks/useAddressResolver.ts @@ -1,11 +1,11 @@ import useAddressBook from '@/hooks/useAddressBook' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { lookupAddress } from '@/services/ens' -import { FEATURES } from '@/utils/chains' import { useMemo } from 'react' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useDebounce from './useDebounce' import { useHasFeature } from './useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' export const useAddressResolver = (address: string) => { const addressBook = useAddressBook() diff --git a/apps/web/src/hooks/useBalances.ts b/apps/web/src/hooks/useBalances.ts index e076382f2e..daa0229bd6 100644 --- a/apps/web/src/hooks/useBalances.ts +++ b/apps/web/src/hooks/useBalances.ts @@ -1,11 +1,11 @@ import { useMemo } from 'react' import isEqual from 'lodash/isEqual' -import { type SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' import { useAppSelector } from '@/store' -import { initialBalancesState, selectBalances } from '@/store/balancesSlice' +import { selectBalances } from '@/store/balancesSlice' +import type { Balances } from '@safe-global/store/gateway/AUTO_GENERATED/balances' const useBalances = (): { - balances: SafeBalanceResponse + balances: Balances loading: boolean error?: string } => { @@ -16,7 +16,7 @@ const useBalances = (): { () => ({ balances: data, error, - loading: loading || initialBalancesState === data, + loading, }), [data, error, loading], ) diff --git a/apps/web/src/hooks/useBlockedAddress.ts b/apps/web/src/hooks/useBlockedAddress.ts new file mode 100644 index 0000000000..174df42bb8 --- /dev/null +++ b/apps/web/src/hooks/useBlockedAddress.ts @@ -0,0 +1,25 @@ +import useSafeInfo from '@/hooks/useSafeInfo' +import useWallet from '@/hooks/wallets/useWallet' +import { useGetIsSanctionedQuery } from '@/store/api/ofac' +import { skipToken } from '@reduxjs/toolkit/query/react' +import { getKeyWithTrueValue } from '@/utils/helpers' + +/** + * Checks the connected wallet and current safe address + * against OFAC and returns either address if on the list + */ +const useBlockedAddress = () => { + const { safeAddress } = useSafeInfo() + const wallet = useWallet() + + const { data: isSafeAddressBlocked } = useGetIsSanctionedQuery(safeAddress || skipToken) + const { data: isWalletAddressBlocked } = useGetIsSanctionedQuery(wallet?.address || skipToken) + const blockedAddresses = { + [safeAddress]: !!isSafeAddressBlocked, + [wallet?.address || '']: !!isWalletAddressBlocked, + } + + return getKeyWithTrueValue(blockedAddresses) +} + +export default useBlockedAddress diff --git a/apps/web/src/hooks/useChainId.ts b/apps/web/src/hooks/useChainId.ts index 86f0074845..2033cc9810 100644 --- a/apps/web/src/hooks/useChainId.ts +++ b/apps/web/src/hooks/useChainId.ts @@ -1,13 +1,11 @@ import { useParams } from 'next/navigation' import { parse, type ParsedUrlQuery } from 'querystring' -import { IS_PRODUCTION } from '@/config/constants' +import { DEFAULT_CHAIN_ID } from '@/config/constants' import chains from '@/config/chains' -import { parsePrefixedAddress } from '@/utils/addresses' +import { parsePrefixedAddress } from '@safe-global/utils/utils/addresses' import useWallet from './wallets/useWallet' import useChains from './useChains' -const defaultChainId = IS_PRODUCTION ? chains.Boba : chains.Boba - // Use the location object directly because Next.js's router.query is available only on mount const getLocationQuery = (): ParsedUrlQuery => { if (typeof location === 'undefined') return {} @@ -44,7 +42,7 @@ export const useChainId = (): string => { const urlChainId = useUrlChainId() const walletChainId = useWalletChainId() - return urlChainId || walletChainId || defaultChainId + return urlChainId || walletChainId || String(DEFAULT_CHAIN_ID) } export default useChainId diff --git a/apps/web/src/hooks/useChains.ts b/apps/web/src/hooks/useChains.ts index c04e848e01..f5f2ac6fb0 100644 --- a/apps/web/src/hooks/useChains.ts +++ b/apps/web/src/hooks/useChains.ts @@ -4,7 +4,8 @@ import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { useAppSelector } from '@/store' import { selectChainById, selectChains } from '@/store/chainsSlice' import { useChainId } from './useChainId' -import { type FEATURES, hasFeature } from '@/utils/chains' +import type { FEATURES } from '@safe-global/utils/utils/chains' +import { hasFeature } from '@safe-global/utils/utils/chains' const useChains = (): { configs: ChainInfo[]; error?: string; loading?: boolean } => { const state = useAppSelector(selectChains, isEqual) diff --git a/apps/web/src/hooks/useCollectibles.ts b/apps/web/src/hooks/useCollectibles.ts index ee1601ce08..2f6fcf8ade 100644 --- a/apps/web/src/hooks/useCollectibles.ts +++ b/apps/web/src/hooks/useCollectibles.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { getCollectiblesPage, type SafeCollectiblesPage } from '@safe-global/safe-gateway-typescript-sdk' -import useAsync, { type AsyncResult } from './useAsync' +import useAsync, { type AsyncResult } from '@safe-global/utils/hooks/useAsync' import { Errors, logError } from '@/services/exceptions' import useSafeInfo from './useSafeInfo' diff --git a/apps/web/src/hooks/useConsent.ts b/apps/web/src/hooks/useConsent.ts new file mode 100644 index 0000000000..aa606a9561 --- /dev/null +++ b/apps/web/src/hooks/useConsent.ts @@ -0,0 +1,17 @@ +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import { useCallback } from 'react' + +const useConsent = (storageKey: string) => { + const [isConsentAccepted = false, setIsConsentAccepted] = useLocalStorage<boolean>(storageKey) + + const onAccept = useCallback(() => { + setIsConsentAccepted(true) + }, [setIsConsentAccepted]) + + return { + isConsentAccepted, + onAccept, + } +} + +export default useConsent diff --git a/apps/web/src/hooks/useGasLimit.ts b/apps/web/src/hooks/useGasLimit.ts index 73fcde6b83..880b12af3f 100644 --- a/apps/web/src/hooks/useGasLimit.ts +++ b/apps/web/src/hooks/useGasLimit.ts @@ -2,8 +2,8 @@ import { SafeProvider } from '@safe-global/protocol-kit' import { useEffect } from 'react' import type Safe from '@safe-global/protocol-kit' import { encodeSignatures } from '@/services/tx/encodeSignatures' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import useAsync from '@/hooks/useAsync' +import type { SafeTransaction } from '@safe-global/types-kit' +import useAsync from '@safe-global/utils/hooks/useAsync' import useChainId from '@/hooks/useChainId' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import chains from '@/config/chains' @@ -18,7 +18,7 @@ import { getSimulateTxAccessorContract, } from '@safe-global/protocol-kit/dist/src/contracts/safeDeploymentContracts' import { type JsonRpcProvider } from 'ethers' -import { type ExtendedSafeInfo } from '@/store/safeInfoSlice' +import type { ExtendedSafeInfo } from '@safe-global/store/slices/SafeInfo/types' const getEncodedSafeTx = ( safeSDK: Safe, @@ -88,7 +88,7 @@ const getGasLimitForZkSync = async ( // https://github.com/zkSync-Community-Hub/zksync-developers/discussions/144 const fakeEOAFromAddress = '0x330d9F4906EDA1f73f668660d1946bea71f48827' const customContracts = safeSDK.getContractManager().contractNetworks?.[safe.chainId] - const safeVersion = await safeSDK.getContractVersion() + const safeVersion = safeSDK.getContractVersion() const safeProvider = new SafeProvider({ provider: web3._getConnection().url }) const fallbackHandlerContract = await getCompatibilityFallbackHandlerContract({ safeProvider, @@ -107,13 +107,13 @@ const getGasLimitForZkSync = async ( safeTx.data.to, // @ts-ignore safeTx.data.value, - safeTx.data.data, + safeTx.data.data as `0x${string}`, safeTx.data.operation, ]) const safeFunctionToEstimate: string = fallbackHandlerContract.encode('simulate', [ - await simulateTxAccessorContract.getAddress(), - transactionDataToEstimate, + simulateTxAccessorContract.getAddress(), + transactionDataToEstimate as `0x${string}`, ]) const gas = await web3.estimateGas({ @@ -161,7 +161,11 @@ const useGasLimit = ( ) // if we are dealing with zksync and the walletAddress is a Safe, we have to do some magic - if (safe.chainId === chains.zksync && (await web3ReadOnly.getCode(walletAddress)) !== '0x') { + // FIXME a new check to indicate ZKsync chain will be added to the config service and available under ChainInfo + if ( + (safe.chainId === chains.zksync || safe.chainId === chains.lens) && + (await web3ReadOnly.getCode(walletAddress)) !== '0x' + ) { return getGasLimitForZkSync(safe, web3ReadOnly, safeSDK, safeTx) } diff --git a/apps/web/src/hooks/useGasPrice.ts b/apps/web/src/hooks/useGasPrice.ts index 9436610a51..9bcdc75a82 100644 --- a/apps/web/src/hooks/useGasPrice.ts +++ b/apps/web/src/hooks/useGasPrice.ts @@ -1,4 +1,4 @@ -import { formatVisualAmount } from '@/utils/formatters' +import { formatVisualAmount } from '@safe-global/utils/utils/formatters' import { type FeeData } from 'ethers' import type { ChainInfo, @@ -8,13 +8,13 @@ import type { GasPriceOracle, } from '@safe-global/safe-gateway-typescript-sdk' import { GAS_PRICE_TYPE } from '@safe-global/safe-gateway-typescript-sdk' -import useAsync, { type AsyncResult } from '@/hooks/useAsync' +import useAsync, { type AsyncResult } from '@safe-global/utils/hooks/useAsync' import { useCurrentChain } from './useChains' import useIntervalCounter from './useIntervalCounter' import { useWeb3ReadOnly } from '../hooks/wallets/web3' import { Errors, logError } from '@/services/exceptions' -import { FEATURES, hasFeature } from '@/utils/chains' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' type EstimatedGasPrice = | { diff --git a/apps/web/src/hooks/useHasUntrustedFallbackHandler.ts b/apps/web/src/hooks/useHasUntrustedFallbackHandler.ts new file mode 100644 index 0000000000..f9d90f9133 --- /dev/null +++ b/apps/web/src/hooks/useHasUntrustedFallbackHandler.ts @@ -0,0 +1,42 @@ +import { useCallback, useMemo } from 'react' +import useSafeInfo from '@/hooks/useSafeInfo' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import { useTWAPFallbackHandlerAddress } from '@/features/swap/hooks/useIsTWAPFallbackHandler' +import { hasMatchingDeployment } from '@safe-global/utils/services/contracts/deployments' +import { getCompatibilityFallbackHandlerDeployments } from '@safe-global/safe-deployments' + +/** + * Hook to check if the Safe's fallback handler (or optionally provided addresses) contain a non-official one. + * @param fallbackHandler Optional fallback handler address(es) (if not provided, it will be taken from the Safe info) + * @returns Boolean indicating if an untrusted fallback handler is set or if the provided address(es) contain an untrusted one + */ +export const useHasUntrustedFallbackHandler = (fallbackHandler?: string | string[]) => { + const { safe } = useSafeInfo() + const twapFallbackHandler = useTWAPFallbackHandlerAddress() + + const fallbackHandlerAddresses = useMemo(() => { + if (!fallbackHandler) { + return safe.fallbackHandler?.value ? [safe.fallbackHandler?.value] : [] + } + + return Array.isArray(fallbackHandler) ? fallbackHandler : [fallbackHandler] + }, [fallbackHandler, safe.fallbackHandler?.value]) + + const isFallbackHandlerUntrusted = useCallback( + (fallbackHandlerAddress: string) => { + return ( + !sameAddress(fallbackHandlerAddress, twapFallbackHandler) && + !hasMatchingDeployment(getCompatibilityFallbackHandlerDeployments, fallbackHandlerAddress, safe.chainId, [ + '1.3.0', + '1.4.1', + ]) + ) + }, + [safe.chainId, twapFallbackHandler], + ) + + return useMemo( + () => fallbackHandlerAddresses.length > 0 && fallbackHandlerAddresses.some(isFallbackHandlerUntrusted), + [fallbackHandlerAddresses, isFallbackHandlerUntrusted], + ) +} diff --git a/apps/web/src/hooks/useIsGeoblockedFeatureEnabled.ts b/apps/web/src/hooks/useIsGeoblockedFeatureEnabled.ts index ce97243684..d9c4324754 100644 --- a/apps/web/src/hooks/useIsGeoblockedFeatureEnabled.ts +++ b/apps/web/src/hooks/useIsGeoblockedFeatureEnabled.ts @@ -2,7 +2,8 @@ import { useContext } from 'react' import { GeoblockingContext } from '@/components/common/GeoblockingProvider' import { useHasFeature } from '@/hooks/useChains' -import type { FEATURES } from '@/utils/chains' + +import type { FEATURES } from '@safe-global/utils/utils/chains' // TODO: Refactor useIsStakingFeatureEnabled/useIsStakingFeatureEnabled to use this export function useIsGeoblockedFeatureEnabled(feature: FEATURES): boolean | undefined { diff --git a/apps/web/src/hooks/useIsOfficialFallbackHandler.ts b/apps/web/src/hooks/useIsOfficialFallbackHandler.ts deleted file mode 100644 index 7a32ff8525..0000000000 --- a/apps/web/src/hooks/useIsOfficialFallbackHandler.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useMemo } from 'react' -import useSafeInfo from '@/hooks/useSafeInfo' -import { useCompatibilityFallbackHandlerDeployments } from '@/hooks/useCompatibilityFallbackHandlerDeployments' - -/** - * Hook to check if the Safe's fallback handler (or optionally a provided address) is an official one. - * @param fallbackHandler Optional fallback handler address (if not provided, it will be taken from the Safe info) - * @returns Boolean indicating if the provided fallback handler is an official fallback handler - */ -export const useIsOfficialFallbackHandler = (fallbackHandler?: string) => { - const fallbackHandlerDeployments = useCompatibilityFallbackHandlerDeployments() - const { safe } = useSafeInfo() - - const fallbackHandlerAddress = fallbackHandler || safe.fallbackHandler?.value - - return useMemo( - () => - !!fallbackHandlerAddress && - !!fallbackHandlerDeployments?.networkAddresses[safe.chainId].includes(fallbackHandlerAddress), - [fallbackHandlerAddress, safe.chainId, fallbackHandlerDeployments], - ) -} diff --git a/apps/web/src/hooks/useIsOfficialHost.ts b/apps/web/src/hooks/useIsOfficialHost.ts index 680f049f09..5cd6f74dcc 100644 --- a/apps/web/src/hooks/useIsOfficialHost.ts +++ b/apps/web/src/hooks/useIsOfficialHost.ts @@ -1,9 +1,39 @@ import { useMemo } from 'react' -import { IS_OFFICIAL_HOST, OFFICIAL_HOSTS } from '@/config/constants' +import { IPFS_HOSTS, IS_OFFICIAL_HOST, OFFICIAL_HOSTS } from '@/config/constants' +import packageJson from '../../package.json' +import useAsync from '@safe-global/utils/hooks/useAsync' + +const GITHUB_API_URL = 'https://api.github.com/repos/5afe/safe-wallet-ipfs/releases/tags' + +async function getGithubRelease(version: string) { + const resp = await fetch(`${GITHUB_API_URL}/v${version}`, { + headers: { + Accept: 'application/vnd.github.v3+json', + }, + }) + if (!resp.ok) return false + return await resp.json() +} + +async function isOfficialIpfs(): Promise<boolean> { + const data = await getGithubRelease(packageJson.version) + return data.body.includes(window.location.host) +} + +function isIpfs() { + return IPFS_HOSTS.test(window.location.host) +} export const useIsOfficialHost = (): boolean => { - return useMemo( + const isOfficialHost = useMemo( () => IS_OFFICIAL_HOST && (typeof window === 'undefined' || OFFICIAL_HOSTS.test(window.location.host)), [], ) + + const [isTrustedIpfs = false] = useAsync<boolean>(() => { + if (isOfficialHost || !isIpfs()) return + return isOfficialIpfs() + }, [isOfficialHost]) + + return isOfficialHost || isTrustedIpfs } diff --git a/apps/web/src/hooks/useIsOnlySpendingLimitBeneficiary.tsx b/apps/web/src/hooks/useIsOnlySpendingLimitBeneficiary.tsx index d691549c88..bd88fcbb41 100644 --- a/apps/web/src/hooks/useIsOnlySpendingLimitBeneficiary.tsx +++ b/apps/web/src/hooks/useIsOnlySpendingLimitBeneficiary.tsx @@ -1,10 +1,10 @@ import { useIsWalletProposer } from '@/hooks/useProposers' -import { FEATURES } from '@/utils/chains' import { useAppSelector } from '@/store' import { selectSpendingLimits } from '@/store/spendingLimitsSlice' import useWallet from '@/hooks/wallets/useWallet' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import { useHasFeature } from './useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' export const useIsSpendingLimitBeneficiary = (): boolean => { const isEnabled = useHasFeature(FEATURES.SPENDING_LIMIT) diff --git a/apps/web/src/hooks/useIsSidebarRoute.ts b/apps/web/src/hooks/useIsSidebarRoute.ts index 280eb6fa9b..1aee77449d 100644 --- a/apps/web/src/hooks/useIsSidebarRoute.ts +++ b/apps/web/src/hooks/useIsSidebarRoute.ts @@ -1,6 +1,8 @@ import { AppRoutes } from '@/config/routes' +import { useIsSpaceRoute } from '@/hooks/useIsSpaceRoute' import { usePathname } from 'next/navigation' import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' const NO_SIDEBAR_ROUTES = [ AppRoutes.share.safeApp, @@ -9,6 +11,7 @@ const NO_SIDEBAR_ROUTES = [ AppRoutes.index, AppRoutes.welcome.index, AppRoutes.welcome.accounts, + AppRoutes.welcome.spaces, AppRoutes.imprint, AppRoutes.privacy, AppRoutes.cookie, @@ -24,11 +27,22 @@ const TOGGLE_SIDEBAR_ROUTES = [AppRoutes.apps.open] * @returns A tuple with the first value indicating if the sidebar should be displayed and the second value indicating if the sidebar can be toggled */ export function useIsSidebarRoute(pathname?: string): [boolean, boolean] { + const router = useRouter() const clientPathname = usePathname() + const isSpaceRoute = useIsSpaceRoute() + const [hasSafe, setHasSafe] = useState(false) + const route = pathname || clientPathname || '' - const noSidebar = NO_SIDEBAR_ROUTES.includes(route) - const toggledSidebar = TOGGLE_SIDEBAR_ROUTES.includes(route) - const router = useRouter() - const hasSafe = !router.isReady || !!router.query.safe - return [!noSidebar && hasSafe, toggledSidebar] + const sidebarQuery = router.query.sidebar === 'true' + const noSidebar = NO_SIDEBAR_ROUTES.includes(route) && !sidebarQuery + const toggledSidebar = TOGGLE_SIDEBAR_ROUTES.includes(route) && !sidebarQuery + + useEffect(() => { + if (!router.isReady) return + setHasSafe(!!router.query.safe) + }, [router.isReady, router.query.safe]) + + const displaySidebar = (!noSidebar && hasSafe) || isSpaceRoute + + return [displaySidebar, toggledSidebar] } diff --git a/apps/web/src/hooks/useIsSpaceRoute.ts b/apps/web/src/hooks/useIsSpaceRoute.ts new file mode 100644 index 0000000000..114f2bd691 --- /dev/null +++ b/apps/web/src/hooks/useIsSpaceRoute.ts @@ -0,0 +1,18 @@ +import { usePathname } from 'next/navigation' +import { AppRoutes } from '@/config/routes' +import { useCurrentSpaceId } from '@/features/spaces/hooks/useCurrentSpaceId' + +const SPACES_ROUTES = [ + AppRoutes.spaces.index, + AppRoutes.spaces.settings, + AppRoutes.spaces.members, + AppRoutes.spaces.safeAccounts, +] + +export const useIsSpaceRoute = (): boolean => { + const clientPathname = usePathname() + const route = clientPathname || '' + const spaceId = useCurrentSpaceId() + + return SPACES_ROUTES.includes(route) && !!spaceId +} diff --git a/apps/web/src/hooks/useIsValidExecution.ts b/apps/web/src/hooks/useIsValidExecution.ts index 0548d88c08..354b903a7a 100644 --- a/apps/web/src/hooks/useIsValidExecution.ts +++ b/apps/web/src/hooks/useIsValidExecution.ts @@ -1,7 +1,7 @@ -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction } from '@safe-global/types-kit' import type { EthersError } from '@/utils/ethers-utils' -import useAsync from './useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import ContractErrorCodes from '@/services/contracts/ContractErrorCodes' import { type SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { createWeb3, useWeb3ReadOnly } from '@/hooks/wallets/web3' @@ -10,8 +10,6 @@ import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' import { getCurrentGnosisSafeContract } from '@/services/contracts/safeContracts' import useSafeInfo from '@/hooks/useSafeInfo' import { useSigner } from '@/hooks/wallets/useWallet' -import { encodeSignatures } from '@/services/tx/encodeSignatures' -import useIsSafeOwner from '@/hooks/useIsSafeOwner' import { type NestedWallet } from '@/utils/nested-safe-wallet' import { assertProvider } from '@/utils/helpers' @@ -64,8 +62,6 @@ const useIsValidExecution = ( const wallet = useSigner() const { safe } = useSafeInfo() const readOnlyProvider = useWeb3ReadOnly() - const isOwner = useIsSafeOwner() - const threshold = safe.threshold const [isValidExecution, executionValidationError, isValidExecutionLoading] = useAsync(async () => { if (!safeTx || !wallet || gasLimit === undefined || !readOnlyProvider) { @@ -81,20 +77,7 @@ const useIsValidExecution = ( * @see https://github.com/safe-global/safe-core-sdk/blob/main/packages/safe-ethers-lib/src/contracts/GnosisSafe/GnosisSafeContractEthers.ts#L126 * This also fixes the over-fetching issue of the monkey patched provider. */ - - return safeContract.contract.execTransaction.staticCall( - safeTx.data.to, - safeTx.data.value, - safeTx.data.data, - safeTx.data.operation, - safeTx.data.safeTxGas, - safeTx.data.baseGas, - safeTx.data.gasPrice, - safeTx.data.gasToken, - safeTx.data.refundReceiver, - encodeSignatures(safeTx, isOwner ? wallet.address : undefined, safeTx.signatures.size < threshold), - { from: wallet.address, gasLimit: gasLimit.toString() }, - ) + return safeContract.isValidTransaction(safeTx, { from: wallet.address, gasLimit: gasLimit.toString() }) } catch (_err) { const err = _err as EthersError @@ -105,7 +88,7 @@ const useIsValidExecution = ( throw err } - }, [safeTx, wallet, gasLimit, safe, readOnlyProvider, isOwner, threshold]) + }, [safeTx, wallet, gasLimit, safe, readOnlyProvider]) return { isValidExecution, executionValidationError, isValidExecutionLoading } } diff --git a/apps/web/src/hooks/useLoadableStores.ts b/apps/web/src/hooks/useLoadableStores.ts index 0a0ed24fda..a9a4e7daf2 100644 --- a/apps/web/src/hooks/useLoadableStores.ts +++ b/apps/web/src/hooks/useLoadableStores.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { type Slice } from '@reduxjs/toolkit' import { useAppDispatch } from '@/store' -import { type AsyncResult } from './useAsync' +import { type AsyncResult } from '@safe-global/utils/hooks/useAsync' // Import all the loadable hooks import useLoadChains from './loadables/useLoadChains' diff --git a/apps/web/src/hooks/useMasterCopies.ts b/apps/web/src/hooks/useMasterCopies.ts index aa69def055..e54eb79b08 100644 --- a/apps/web/src/hooks/useMasterCopies.ts +++ b/apps/web/src/hooks/useMasterCopies.ts @@ -1,4 +1,4 @@ -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useChainId from '@/hooks/useChainId' import { Errors, logError } from '@/services/exceptions' import type { MasterCopyReponse } from '@safe-global/safe-gateway-typescript-sdk' diff --git a/apps/web/src/hooks/useMnemonicName/index.ts b/apps/web/src/hooks/useMnemonicName/index.ts index 4caf08a442..19c4a29b7f 100644 --- a/apps/web/src/hooks/useMnemonicName/index.ts +++ b/apps/web/src/hooks/useMnemonicName/index.ts @@ -14,8 +14,12 @@ export const getRandomAdjective = (): string => { return capitalize(getRandomItem<string>(adjectives)) } -export const useMnemonicSafeName = (multiChain?: boolean): string => { +export function useMnemonicPrefixedSafeName(prefix?: string): string { const currentNetwork = useCurrentChain()?.chainName const adjective = useMemo(() => getRandomAdjective(), []) - return `${adjective} ${multiChain ? 'Multi-Chain' : currentNetwork} Safe` + return `${adjective} ${prefix ?? currentNetwork} Safe` +} + +export const useMnemonicSafeName = (multiChain?: boolean): string => { + return useMnemonicPrefixedSafeName(multiChain ? 'Multi-Chain' : undefined) } diff --git a/apps/web/src/hooks/useNativeTokenInfo.ts b/apps/web/src/hooks/useNativeTokenInfo.ts new file mode 100644 index 0000000000..8b1d997968 --- /dev/null +++ b/apps/web/src/hooks/useNativeTokenInfo.ts @@ -0,0 +1,16 @@ +import { type NativeToken } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useCurrentChain } from './useChains' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' + +export const useNativeTokenInfo = (): NativeToken => { + const chain = useCurrentChain() + + return { + type: 'NATIVE_TOKEN', + address: ZERO_ADDRESS, + symbol: chain?.nativeCurrency.symbol ?? 'ETH', + decimals: chain?.nativeCurrency.decimals ?? 18, + logoUri: chain?.nativeCurrency.logoUri ?? '', + name: chain?.nativeCurrency.name ?? 'Ether', + } +} diff --git a/apps/web/src/hooks/useOnceVisible.ts b/apps/web/src/hooks/useOnceVisible.ts index e7dc3e527f..2de939f8e1 100644 --- a/apps/web/src/hooks/useOnceVisible.ts +++ b/apps/web/src/hooks/useOnceVisible.ts @@ -1,44 +1,25 @@ -import { useEffect, useMemo, useState } from 'react' -import type { MutableRefObject } from 'react' +import { type RefObject, useEffect, useState } from 'react' // A hook to detect when an element is visible in the viewport for the first time -const useOnceVisible = (element: MutableRefObject<HTMLElement | null>): boolean => { +const useOnceVisible = (element: RefObject<HTMLElement | null>): boolean => { const [onceVisible, setOnceVisible] = useState<boolean>(false) - // Create and memoize an instance of IntersectionObserver - const observer = useMemo(() => { - if (typeof IntersectionObserver === 'undefined') return + useEffect(() => { + if (!element.current) return - return new IntersectionObserver((entries) => { - const intersectingEntry = entries.find((entry) => entry.isIntersecting) - if (intersectingEntry) { + const observer = new IntersectionObserver(([entry], obs) => { + if (entry.isIntersecting) { setOnceVisible(true) - observer?.unobserve(intersectingEntry.target) + obs.unobserve(entry.target) } }) - }, []) - // Disconnect the observer on unmount - useEffect(() => { - return () => { - observer?.disconnect() - } - }, [observer]) - - // Observe the target element - useEffect(() => { - const target = element.current - - if (target) { - observer?.observe(target) - } + observer.observe(element.current) return () => { - if (target) { - observer?.unobserve(target) - } + observer.disconnect() } - }, [observer, element]) + }, [element]) return onceVisible } diff --git a/apps/web/src/hooks/useOwnedSafes.ts b/apps/web/src/hooks/useOwnedSafes.ts index 9a0f778ab1..c63dd025e9 100644 --- a/apps/web/src/hooks/useOwnedSafes.ts +++ b/apps/web/src/hooks/useOwnedSafes.ts @@ -12,11 +12,14 @@ type OwnedSafesCache = { } } -const useOwnedSafes = (): OwnedSafesCache['walletAddress'] => { - const chainId = useChainId() +const useOwnedSafes = (customChainId?: string): OwnedSafesCache['walletAddress'] => { + const currentChainId = useChainId() + const chainId = customChainId ?? currentChainId const { address: walletAddress } = useWallet() || {} - const { data: ownedSafes } = useGetOwnedSafesQuery(walletAddress ? { chainId, walletAddress } : skipToken) + const { data: ownedSafes } = useGetOwnedSafesQuery( + walletAddress ? { chainId, ownerAddress: walletAddress } : skipToken, + ) const result = useMemo(() => ({ [chainId]: ownedSafes?.safes ?? [] }), [chainId, ownedSafes]) diff --git a/apps/web/src/hooks/useParentSafe.ts b/apps/web/src/hooks/useParentSafe.ts new file mode 100644 index 0000000000..7632f1619e --- /dev/null +++ b/apps/web/src/hooks/useParentSafe.ts @@ -0,0 +1,28 @@ +import { useGetSafeQuery } from '@/store/slices' +import { skipToken } from '@reduxjs/toolkit/query' +import useSafeInfo from './useSafeInfo' +import type { getSafe } from '@safe-global/safe-client-gateway-sdk' +import { useHasFeature } from '@/hooks/useChains' + +import { FEATURES } from '@safe-global/utils/utils/chains' + +export function useParentSafe(): getSafe | undefined { + const isEnabled = useHasFeature(FEATURES.NESTED_SAFES) + const { safe } = useSafeInfo() + + // Nested Safes are deployed by a single owner + const maybeParent = safe.owners.length === 1 ? safe.owners[0].value : undefined + + const { data: parentSafe } = useGetSafeQuery( + isEnabled && maybeParent + ? { + chainId: safe.chainId, + safeAddress: maybeParent, + } + : skipToken, + ) + + if (parentSafe?.address.value === maybeParent) { + return parentSafe + } +} diff --git a/apps/web/src/hooks/usePendingActions.ts b/apps/web/src/hooks/usePendingActions.ts index dc5ec9285b..96f557a198 100644 --- a/apps/web/src/hooks/usePendingActions.ts +++ b/apps/web/src/hooks/usePendingActions.ts @@ -3,7 +3,7 @@ import { isSignableBy } from '@/utils/transaction-guards' import type { TransactionListPage } from '@safe-global/safe-gateway-typescript-sdk' import { getTransactionQueue } from '@safe-global/safe-gateway-typescript-sdk' import { useMemo } from 'react' -import useAsync from './useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useSafeInfo from './useSafeInfo' import useTxQueue from './useTxQueue' import useWallet from './wallets/useWallet' diff --git a/apps/web/src/hooks/usePendingTxs.ts b/apps/web/src/hooks/usePendingTxs.ts index 8b03379315..908c93580b 100644 --- a/apps/web/src/hooks/usePendingTxs.ts +++ b/apps/web/src/hooks/usePendingTxs.ts @@ -7,7 +7,7 @@ import { } from '@safe-global/safe-gateway-typescript-sdk' import { useAppSelector } from '@/store' import { selectPendingTxIdsBySafe } from '@/store/pendingTxsSlice' -import useAsync from './useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { isConflictHeaderListItem, isLabelListItem, @@ -17,7 +17,7 @@ import { import useSafeInfo from './useSafeInfo' import { shallowEqual } from 'react-redux' -const usePendingTxIds = (): Array<TransactionSummary['id']> => { +export const usePendingTxIds = (): Array<TransactionSummary['id']> => { const { safe, safeAddress } = useSafeInfo() const { chainId } = safe return useAppSelector((state) => selectPendingTxIdsBySafe(state, chainId, safeAddress), shallowEqual) diff --git a/apps/web/src/hooks/usePredictSafeAddressFromTxDetails.ts b/apps/web/src/hooks/usePredictSafeAddressFromTxDetails.ts new file mode 100644 index 0000000000..fdcd66c52f --- /dev/null +++ b/apps/web/src/hooks/usePredictSafeAddressFromTxDetails.ts @@ -0,0 +1,61 @@ +import type { DataDecoded, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' + +import { predictSafeAddress } from '@/features/multichain/utils/utils' +import useAsync from '@safe-global/utils/hooks/useAsync' +import { useWeb3ReadOnly } from './wallets/web3' + +export function _getSetupFromDataDecoded(dataDecoded: DataDecoded) { + if (dataDecoded?.method !== 'createProxyWithNonce') { + return + } + + const singleton = dataDecoded?.parameters?.[0]?.value + const initializer = dataDecoded?.parameters?.[1]?.value + const saltNonce = dataDecoded?.parameters?.[2]?.value + + if (typeof singleton !== 'string' || typeof initializer !== 'string' || typeof saltNonce !== 'string') { + return + } + + return { + singleton, + initializer, + saltNonce, + } +} + +function isCreateProxyWithNonce(dataDecoded?: DataDecoded) { + return dataDecoded?.method === 'createProxyWithNonce' +} + +export function usePredictSafeAddressFromTxDetails(txDetails: TransactionDetails | undefined) { + const web3 = useWeb3ReadOnly() + + return useAsync(() => { + const txData = txDetails?.txData + if (!web3 || !txData) { + return + } + + const isMultiSend = txData?.dataDecoded?.method === 'multiSend' + + const dataDecoded = isMultiSend + ? txData?.dataDecoded?.parameters?.[0]?.valueDecoded?.find((tx) => isCreateProxyWithNonce(tx?.dataDecoded)) + ?.dataDecoded + : txData?.dataDecoded + const factoryAddress = isMultiSend + ? txData?.dataDecoded?.parameters?.[0]?.valueDecoded?.find((tx) => isCreateProxyWithNonce(tx?.dataDecoded))?.to + : txData?.to?.value + + if (!dataDecoded || !isCreateProxyWithNonce(dataDecoded) || !factoryAddress) { + return + } + + const setup = _getSetupFromDataDecoded(dataDecoded) + if (!setup) { + return + } + + return predictSafeAddress(setup, factoryAddress, web3) + }, [txDetails?.txData, web3]) +} diff --git a/apps/web/src/hooks/usePreventNavigation.ts b/apps/web/src/hooks/usePreventNavigation.ts new file mode 100644 index 0000000000..6d5e4f9f75 --- /dev/null +++ b/apps/web/src/hooks/usePreventNavigation.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef } from 'react' +import { useRouter } from 'next/router' + +export function usePreventNavigation(onNavigate?: () => boolean): void { + const router = useRouter() + const currentPathRef = useRef(router.asPath) + + // Sync current path ref with router + useEffect(() => { + const delay = setTimeout(() => { + currentPathRef.current = router.asPath + }, 300) + return () => { + clearTimeout(delay) + } + }, [router.asPath]) + + useEffect(() => { + if (!onNavigate) return + + const onLinkClick = (e: MouseEvent) => { + const target = e.target as HTMLElement + const link = target.closest('a') + const href = link?.getAttribute('href') + const targetAttr = link?.getAttribute('target') + + if (!link || !href || targetAttr?.toLowerCase() === '_blank') return + + const isAllowedToNavigate = onNavigate() + if (isAllowedToNavigate) { + router.push(href) + } else { + e.preventDefault() + e.stopImmediatePropagation() + e.stopPropagation() + } + } + + document.addEventListener('mousedown', onLinkClick) + + return () => { + document.removeEventListener('mousedown', onLinkClick) + } + }, [router, onNavigate]) + + // Prevent Back/Forward navigation + useEffect(() => { + router.beforePopState(() => { + const prevUrl = currentPathRef.current + if (onNavigate) { + const isAllowedToNavigate = onNavigate() + + if (!isAllowedToNavigate) { + // Cancel navigation and reset the URL back + router.replace(prevUrl) + return false + } + } + return true + }) + + return () => router.beforePopState(() => true) + }, [router, onNavigate]) +} diff --git a/apps/web/src/hooks/useRemainingRelays.ts b/apps/web/src/hooks/useRemainingRelays.ts index b7b4fa519c..268fed1b6a 100644 --- a/apps/web/src/hooks/useRemainingRelays.ts +++ b/apps/web/src/hooks/useRemainingRelays.ts @@ -1,8 +1,8 @@ -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useSafeInfo from './useSafeInfo' -import { FEATURES, hasFeature } from '@/utils/chains' import { useCurrentChain } from '@/hooks/useChains' import { getRelayCount } from '@safe-global/safe-gateway-typescript-sdk' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' export const MAX_DAY_RELAYS = 5 diff --git a/apps/web/src/hooks/useSafeAddress.ts b/apps/web/src/hooks/useSafeAddress.ts index 78eb61d463..cc3a9010ec 100644 --- a/apps/web/src/hooks/useSafeAddress.ts +++ b/apps/web/src/hooks/useSafeAddress.ts @@ -1,19 +1,8 @@ -import { useMemo } from 'react' -import { useRouter } from 'next/router' -import { parsePrefixedAddress } from '@/utils/addresses' +import useSafeInfo from '@/hooks/useSafeInfo' const useSafeAddress = (): string => { - const router = useRouter() - const { safe = '' } = router.query - const fullAddress = Array.isArray(safe) ? safe[0] : safe - - const checksummedAddress = useMemo(() => { - if (!fullAddress) return '' - const { address } = parsePrefixedAddress(fullAddress) - return address - }, [fullAddress]) - - return checksummedAddress + const { safeAddress } = useSafeInfo() + return safeAddress } export default useSafeAddress diff --git a/apps/web/src/hooks/useSafeAddressFromUrl.ts b/apps/web/src/hooks/useSafeAddressFromUrl.ts new file mode 100644 index 0000000000..db088a7f3e --- /dev/null +++ b/apps/web/src/hooks/useSafeAddressFromUrl.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react' +import { useRouter } from 'next/router' +import { parsePrefixedAddress } from '@safe-global/utils/utils/addresses' + +export const useSafeAddressFromUrl = (): string => { + const router = useRouter() + const { safe = '' } = router.query + const fullAddress = Array.isArray(safe) ? safe[0] : safe + + const checksummedAddress = useMemo(() => { + if (!fullAddress) return '' + const { address } = parsePrefixedAddress(fullAddress) + return address + }, [fullAddress]) + + return checksummedAddress +} diff --git a/apps/web/src/hooks/useSafeInfo.ts b/apps/web/src/hooks/useSafeInfo.ts index ea22d5b197..9e98a1b05b 100644 --- a/apps/web/src/hooks/useSafeInfo.ts +++ b/apps/web/src/hooks/useSafeInfo.ts @@ -1,7 +1,9 @@ import { useMemo } from 'react' import isEqual from 'lodash/isEqual' import { useAppSelector } from '@/store' -import { defaultSafeInfo, type ExtendedSafeInfo, selectSafeInfo } from '@/store/safeInfoSlice' +import { selectSafeInfo } from '@/store/safeInfoSlice' +import type { ExtendedSafeInfo } from '@safe-global/store/slices/SafeInfo/types' +import { defaultSafeInfo } from '@safe-global/store/slices/SafeInfo/utils' const useSafeInfo = (): { safe: ExtendedSafeInfo diff --git a/apps/web/src/hooks/useSafeNotifications.ts b/apps/web/src/hooks/useSafeNotifications.ts index 0797a7a730..6d0564c26f 100644 --- a/apps/web/src/hooks/useSafeNotifications.ts +++ b/apps/web/src/hooks/useSafeNotifications.ts @@ -4,12 +4,13 @@ import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript import useSafeInfo from './useSafeInfo' import { useAppDispatch } from '@/store' import { AppRoutes } from '@/config/routes' -import { isMigrationToL2Possible, isValidMasterCopy } from '@/services/contracts/safeContracts' +import { isMigrationToL2Possible, isValidMasterCopy } from '@safe-global/utils/services/contracts/safeContracts' import { useRouter } from 'next/router' import useIsSafeOwner from './useIsSafeOwner' -import { isValidSafeVersion } from './coreSDK/safeCoreSDK' import useSafeAddress from '@/hooks/useSafeAddress' import useLocalStorage from '@/services/local-storage/useLocalStorage' +import { isValidSafeVersion } from '@safe-global/utils/services/contracts/utils' +import { isNonCriticalUpdate } from '@safe-global/utils/utils/chains' const CLI_LINK = { href: 'https://github.com/5afe/safe-cli', @@ -66,7 +67,6 @@ const useSafeNotifications = (): void => { /** * Show a notification when the Safe version is out of date */ - useEffect(() => { if (safeAddress !== urlSafeAddress) return if (!isOwner) return @@ -84,7 +84,9 @@ const useSafeNotifications = (): void => { } } - if (implementationVersionState !== ImplementationVersionState.OUTDATED) return + // Is Safe version outdated? + // Non-critical Safe upgrades (versions >= '1.3.0') intentionally skip notifications + if (implementationVersionState !== ImplementationVersionState.OUTDATED || isNonCriticalUpdate(version)) return const isUnsupported = !isValidSafeVersion(version) @@ -138,7 +140,7 @@ const useSafeNotifications = (): void => { const message = isMigrationPossible ? `This Safe Account was created with an unsupported base contract. - It is possible to migrate it to a compatible base contract. This migration will be automatically included with your first transaction.` + It is possible to migrate it to a compatible base contract. You can migrate it to a compatible contract on the Home screen.` : `This Safe Account was created with an unsupported base contract. The web interface might not work correctly. We recommend using the command line interface instead.` diff --git a/apps/web/src/hooks/useSafeTokenAllocation.ts b/apps/web/src/hooks/useSafeTokenAllocation.ts index c00eda704c..555821319b 100644 --- a/apps/web/src/hooks/useSafeTokenAllocation.ts +++ b/apps/web/src/hooks/useSafeTokenAllocation.ts @@ -1,14 +1,13 @@ import { getSafeTokenAddress, getSafeLockingAddress } from '@/components/common/SafeTokenWidget' import { IS_PRODUCTION } from '@/config/constants' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' -import { isPast } from 'date-fns' -import { AbiCoder, Interface, type JsonRpcProvider } from 'ethers' +import { AbiCoder, Interface } from 'ethers' import { useMemo } from 'react' -import useAsync, { type AsyncResult } from './useAsync' +import useAsync, { type AsyncResult } from '@safe-global/utils/hooks/useAsync' import useSafeInfo from './useSafeInfo' import { getWeb3ReadOnly } from './wallets/web3' -import memoize from 'lodash/memoize' import { cgwDebugStorage } from '@/config/gateway' +import { multicall } from '../../../../packages/utils/src/utils/multicall' export const VESTING_URL = IS_PRODUCTION || cgwDebugStorage.get() @@ -43,49 +42,42 @@ const tokenInterface = new Interface(['function balanceOf(address _owner) public const safeLockingInterface = new Interface([ 'function getUserTokenBalance(address holder) external view returns (uint96 amount)', ]) - -export const _getRedeemDeadline = memoize( - async (allocation: VestingData, web3ReadOnly: JsonRpcProvider): Promise<string> => { - return web3ReadOnly.call({ - to: allocation.contract, - data: airdropInterface.encodeFunctionData('redeemDeadline'), - }) - }, - ({ chainId, contract }) => chainId + contract, -) - /** * Add on-chain information to allocation. * Fetches if the redeem deadline is expired and the claimed tokens from on-chain */ -const completeAllocation = async (allocation: VestingData): Promise<Vesting> => { +const completeAllocations = async (allocations: VestingData[]): Promise<Vesting[]> => { const web3ReadOnly = getWeb3ReadOnly() if (!web3ReadOnly) { - throw new Error('Cannot fetch vesting without web3 provider') + throw new Error('Cannot fetch vestings without web3 provider') } - const onChainVestingData = await web3ReadOnly.call({ + + const calls = allocations.map((allocation) => ({ to: allocation.contract, data: airdropInterface.encodeFunctionData('vestings', [allocation.vestingId]), - }) + })) + const results = await multicall(web3ReadOnly, calls) - const decodedVestingData = AbiCoder.defaultAbiCoder().decode( - // account, curveType, managed, durationWeeks, startDate, amount, amountClaimed, pausingDate, cancelled} - ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], - onChainVestingData, - ) - - const isRedeemed = decodedVestingData[0].toLowerCase() !== ZERO_ADDRESS.toLowerCase() - if (isRedeemed) { - return { ...allocation, isRedeemed, isExpired: false, amountClaimed: decodedVestingData[6] } - } + return allocations.map((allocation, index) => { + const result = results[index] + if (!result.success) { + throw new Error(`Failed to fetch vesting data for ${allocation.vestingId}`) + } - // Allocation is not yet redeemed => check the redeemDeadline - const redeemDeadline = await _getRedeemDeadline(allocation, web3ReadOnly) + const decodedVestingData = AbiCoder.defaultAbiCoder().decode( + // account, curveType, managed, durationWeeks, startDate, amount, amountClaimed, pausingDate, cancelled} + ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], + result.returnData, + ) - const redeemDeadlineDate = new Date(Number(BigInt(redeemDeadline) * BigInt(1000))) + const isRedeemed = decodedVestingData[0].toLowerCase() !== ZERO_ADDRESS.toLowerCase() + if (isRedeemed) { + return { ...allocation, isRedeemed, isExpired: false, amountClaimed: decodedVestingData[6] } + } - // Allocation is valid if redeem deadline is in future - return { ...allocation, isRedeemed, isExpired: isPast(redeemDeadlineDate), amountClaimed: '0' } + // All allocations are expired by now. We do not load the redeem deadline anymore + return { ...allocation, isRedeemed, isExpired: true, amountClaimed: '0' } + }) } const fetchAllocation = async (chainId: string, safeAddress: string): Promise<VestingData[]> => { @@ -116,41 +108,41 @@ const useSafeTokenAllocation = (): AsyncResult<Vesting[]> => { return useAsync<Vesting[] | undefined>(async () => { if (!safeAddress) return return Promise.all( - await fetchAllocation(chainId, safeAddress).then((allocations) => - allocations.map((allocation) => completeAllocation(allocation)), - ), + await fetchAllocation(chainId, safeAddress).then((allocations) => completeAllocations(allocations)), ) // If the history tag changes we could have claimed / redeemed tokens // eslint-disable-next-line react-hooks/exhaustive-deps }, [chainId, safeAddress, safe.txHistoryTag]) } -const fetchTokenBalance = async (chainId: string, safeAddress: string): Promise<string> => { +const fetchTokenBalances = async (chainId: string, safeAddress: string): Promise<[bigint, bigint]> => { try { const web3ReadOnly = getWeb3ReadOnly() const safeTokenAddress = getSafeTokenAddress(chainId) - if (!safeTokenAddress || !web3ReadOnly) return '0' - - return await web3ReadOnly.call({ - to: safeTokenAddress, - data: tokenInterface.encodeFunctionData('balanceOf', [safeAddress]), - }) - } catch (err) { - throw Error(`Error fetching Safe Token balance: ${err}`) - } -} -const fetchLockingContractBalance = async (chainId: string, safeAddress: string): Promise<string> => { - try { - const web3ReadOnly = getWeb3ReadOnly() const safeLockingAddress = getSafeLockingAddress(chainId) - if (!safeLockingAddress || !web3ReadOnly) return '0' - return await web3ReadOnly.call({ - to: safeLockingAddress, - data: safeLockingInterface.encodeFunctionData('getUserTokenBalance', [safeAddress]), - }) + if (!web3ReadOnly || !safeTokenAddress || !safeLockingAddress) return [BigInt(0), BigInt(0)] + + const calls = [ + { + to: safeTokenAddress, + data: tokenInterface.encodeFunctionData('balanceOf', [safeAddress]), + }, + { + to: safeLockingAddress, + data: safeLockingInterface.encodeFunctionData('getUserTokenBalance', [safeAddress]), + }, + ] + + const [balanceResponse, lockedResponse] = await multicall(web3ReadOnly, calls) + + if (!balanceResponse.success || !lockedResponse.success) { + throw new Error('Failed to fetch token balances') + } + + return [BigInt(balanceResponse.returnData), BigInt(lockedResponse.returnData)] } catch (err) { - throw Error(`Error fetching Safe Token balance in locking contract: ${err}`) + throw Error(`Error fetching Safe Token balances: ${err}`) } } @@ -162,18 +154,11 @@ export const useSafeVotingPower = (allocationData?: Vesting[]): AsyncResult<bigi const { safe, safeAddress } = useSafeInfo() const chainId = safe.chainId - const [balance, balanceError, balanceLoading] = useAsync<bigint>(() => { + const [balance, balanceError, balanceLoading] = useAsync<bigint | undefined>(async () => { if (!safeAddress) return - const tokenBalancePromise = fetchTokenBalance(chainId, safeAddress) - const lockingContractBalancePromise = fetchLockingContractBalance(chainId, safeAddress) - return Promise.all([tokenBalancePromise, lockingContractBalancePromise]).then( - ([tokenBalance, lockingContractBalance]) => { - return BigInt(tokenBalance) + BigInt(lockingContractBalance) - }, - ) - // If the history tag changes we could have claimed / redeemed tokens - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chainId, safeAddress, safe.txHistoryTag]) + const [tokenBalance, lockingContractBalance] = await fetchTokenBalances(chainId, safeAddress) + return tokenBalance + lockingContractBalance + }, [chainId, safeAddress]) const allocation = useMemo(() => { if (balance === undefined) { diff --git a/apps/web/src/hooks/useSpendingLimit.ts b/apps/web/src/hooks/useSpendingLimit.ts index 373f60cdab..a510c33ac9 100644 --- a/apps/web/src/hooks/useSpendingLimit.ts +++ b/apps/web/src/hooks/useSpendingLimit.ts @@ -3,7 +3,7 @@ import type { TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' import useWallet from '@/hooks/wallets/useWallet' import type { SpendingLimitState } from '@/store/spendingLimitsSlice' import { selectSpendingLimits } from '@/store/spendingLimitsSlice' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' const useSpendingLimit = (selectedToken?: TokenInfo): SpendingLimitState | undefined => { const wallet = useWallet() diff --git a/apps/web/src/hooks/useSpendingLimitGas.ts b/apps/web/src/hooks/useSpendingLimitGas.ts index 07a8fb8182..a8b57d2646 100644 --- a/apps/web/src/hooks/useSpendingLimitGas.ts +++ b/apps/web/src/hooks/useSpendingLimitGas.ts @@ -1,7 +1,7 @@ import useWallet from '@/hooks/wallets/useWallet' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { getSpendingLimitContract } from '@/services/contracts/spendingLimitContracts' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { type SpendingLimitTxParams } from '@/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx' import useChainId from '@/hooks/useChainId' import useSafeInfo from './useSafeInfo' diff --git a/apps/web/src/hooks/useTransactionType.tsx b/apps/web/src/hooks/useTransactionType.tsx index 066c22a82b..71f5a366f4 100644 --- a/apps/web/src/hooks/useTransactionType.tsx +++ b/apps/web/src/hooks/useTransactionType.tsx @@ -8,7 +8,9 @@ import { type TransactionSummary, } from '@safe-global/safe-gateway-typescript-sdk' import SwapIcon from '@/public/images/common/swap.svg' +import BridgeIcon from '@/public/images/common/bridge.svg' import StakeIcon from '@/public/images/common/stake.svg' +import EarnIcon from '@/public/images/common/earn.svg' import NestedSafeIcon from '@/public/images/transactions/nestedTx.svg' import BatchIcon from '@/public/images/common/multisend.svg' @@ -100,16 +102,47 @@ export const getTransactionType = (tx: TransactionSummary, addressBook: AddressB } case TransactionInfoType.NATIVE_STAKING_VALIDATORS_EXIT: { return { - icon: <StakeIcon component={StakeIcon} inheritViewBox fontSize="small" alt="Withdraw request" />, + icon: <SvgIcon component={StakeIcon} inheritViewBox fontSize="small" alt="Withdraw request" />, text: 'Withdraw request', } } case TransactionInfoType.NATIVE_STAKING_WITHDRAW: { return { - icon: <StakeIcon component={StakeIcon} inheritViewBox fontSize="small" alt="Claim" />, + icon: <SvgIcon component={StakeIcon} inheritViewBox fontSize="small" alt="Claim" />, text: 'Claim', } } + // @ts-ignore TODO: Add types to old SDK or switch to auto-generated + case 'VaultDeposit': { + return { + icon: <SvgIcon component={EarnIcon} inheritViewBox fontSize="small" alt="Deposit icon" />, + text: 'Deposit', + } + } + // @ts-ignore TODO: Add types to old SDK or switch to auto-generated + case 'VaultRedeem': { + return { + icon: <SvgIcon component={EarnIcon} inheritViewBox fontSize="small" alt="Withdraw icon" />, + text: 'Withdraw', + } + } + + // @ts-ignore TODO: Add types to old SDK or switch to auto-generated + case 'SwapAndBridge': { + return { + icon: <SvgIcon component={BridgeIcon} inheritViewBox fontSize="small" alt="Swap and Bridge" />, + text: 'Bridge', + } + } + + // @ts-ignore TODO: Add types to old SDK or switch to auto-generated + case 'Swap': { + return { + icon: <SvgIcon component={SwapIcon} inheritViewBox fontSize="small" alt="Swap" />, + text: 'Swap', + } + } + case TransactionInfoType.CUSTOM: { if (tx.safeAppInfo) { return { diff --git a/apps/web/src/hooks/useTxDetails.ts b/apps/web/src/hooks/useTxDetails.ts index c62b23ab09..5c8f2f990d 100644 --- a/apps/web/src/hooks/useTxDetails.ts +++ b/apps/web/src/hooks/useTxDetails.ts @@ -1,5 +1,5 @@ import { getTransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useChainId from './useChainId' function useTxDetails(txId?: string) { diff --git a/apps/web/src/hooks/useTxHistory.ts b/apps/web/src/hooks/useTxHistory.ts index eeade39d8f..8225320532 100644 --- a/apps/web/src/hooks/useTxHistory.ts +++ b/apps/web/src/hooks/useTxHistory.ts @@ -1,14 +1,15 @@ import { useMemo } from 'react' import { type TransactionListPage } from '@safe-global/safe-gateway-typescript-sdk' import { useAppSelector } from '@/store' -import useAsync from './useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { selectTxHistory } from '@/store/txHistorySlice' import useSafeInfo from './useSafeInfo' import { fetchFilteredTxHistory, useTxFilter } from '@/utils/tx-history-filter' import { getTxHistory } from '@/services/transactions' import { selectSettings } from '@/store/settingsSlice' import { useHasFeature } from './useChains' -import { FEATURES } from '@/utils/chains' + +import { FEATURES } from '@safe-global/utils/utils/chains' const useTxHistory = ( pageUrl?: string, diff --git a/apps/web/src/hooks/useTxNotifications.ts b/apps/web/src/hooks/useTxNotifications.ts index b1feb2202a..388645675b 100644 --- a/apps/web/src/hooks/useTxNotifications.ts +++ b/apps/web/src/hooks/useTxNotifications.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef } from 'react' -import { formatError } from '@/utils/formatters' +import { formatError } from '@safe-global/utils/utils/formatters' import { selectNotifications, showNotification } from '@/store/notificationsSlice' import { useAppDispatch, useAppSelector } from '@/store' import { TxEvent, txSubscribe } from '@/services/tx/txEvents' @@ -11,10 +11,10 @@ import { selectPendingTxs } from '@/store/pendingTxsSlice' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import useWallet from './wallets/useWallet' import useSafeAddress from './useSafeAddress' -import { getExplorerLink } from '@/utils/gateway' import { isWalletRejection } from '@/utils/wallets' import { getTxLink } from '@/utils/tx-link' import { useLazyGetTransactionDetailsQuery } from '@/store/api/gateway' +import { getExplorerLink } from '@safe-global/utils/utils/gateway' const TxNotifications = { [TxEvent.SIGN_FAILED]: 'Failed to sign. Please try again.', diff --git a/apps/web/src/hooks/useTxQueue.ts b/apps/web/src/hooks/useTxQueue.ts index e02bb5f6de..002b56ce92 100644 --- a/apps/web/src/hooks/useTxQueue.ts +++ b/apps/web/src/hooks/useTxQueue.ts @@ -1,6 +1,6 @@ import { getTransactionQueue, type TransactionListPage } from '@safe-global/safe-gateway-typescript-sdk' import { useAppSelector } from '@/store' -import useAsync from './useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { selectTxQueue, selectQueuedTransactionsByNonce } from '@/store/txQueueSlice' import useSafeInfo from './useSafeInfo' import { isTransactionListItem } from '@/utils/transaction-guards' diff --git a/apps/web/src/hooks/useValidateTxData.ts b/apps/web/src/hooks/useValidateTxData.ts index 85e9e4466f..d85c38d657 100644 --- a/apps/web/src/hooks/useValidateTxData.ts +++ b/apps/web/src/hooks/useValidateTxData.ts @@ -1,8 +1,8 @@ import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import { logError } from '@/services/exceptions' -import ErrorCodes from '@/services/exceptions/ErrorCodes' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' import { ethers } from 'ethers' import { useContext } from 'react' diff --git a/apps/web/src/hooks/useVisibleBalances.ts b/apps/web/src/hooks/useVisibleBalances.ts index 0cf20252af..dbc5fa5075 100644 --- a/apps/web/src/hooks/useVisibleBalances.ts +++ b/apps/web/src/hooks/useVisibleBalances.ts @@ -1,4 +1,4 @@ -import { safeFormatUnits, safeParseUnits } from '@/utils/formatters' +import { safeFormatUnits, safeParseUnits } from '@safe-global/utils/utils/formatters' import type { SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' import { useMemo } from 'react' import useBalances from './useBalances' diff --git a/apps/web/src/hooks/useWalletCanRelay.ts b/apps/web/src/hooks/useWalletCanRelay.ts index 64513475f6..f35d449683 100644 --- a/apps/web/src/hooks/useWalletCanRelay.ts +++ b/apps/web/src/hooks/useWalletCanRelay.ts @@ -1,9 +1,9 @@ -import useAsync from '@/hooks/useAsync' +import useAsync from '@safe-global/utils/hooks/useAsync' import useSafeInfo from '@/hooks/useSafeInfo' import useWallet from '@/hooks/wallets/useWallet' import { isSmartContractWallet } from '@/utils/wallets' import { Errors, logError } from '@/services/exceptions' -import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { type SafeTransaction } from '@safe-global/types-kit' const useWalletCanRelay = (tx: SafeTransaction | undefined) => { const { safe } = useSafeInfo() diff --git a/apps/web/src/hooks/wallets/__tests__/useOnboard.test.ts b/apps/web/src/hooks/wallets/__tests__/useOnboard.test.ts index 237ceeaf1b..a065adae91 100644 --- a/apps/web/src/hooks/wallets/__tests__/useOnboard.test.ts +++ b/apps/web/src/hooks/wallets/__tests__/useOnboard.test.ts @@ -15,13 +15,14 @@ describe('useOnboard', () => { label: 'Wallet 1', icon: 'wallet1.svg', provider: null as unknown as EIP1193Provider, - chains: [{ id: '0x4' }], + chains: [{ id: '0x4', namespace: 'evm' }], accounts: [ { address: '0x1234567890123456789012345678901234567890', ens: { name: 'test.eth', }, + uns: null, balance: { ETH: '0.002346456767547', }, @@ -32,16 +33,17 @@ describe('useOnboard', () => { label: 'Wallet 2', icon: 'wallet2.svg', provider: null as unknown as EIP1193Provider, - chains: [{ id: '0x100' }], + chains: [{ id: '0x100', namespace: 'evm' }], accounts: [ { address: '0x2', ens: null, + uns: null, balance: null, }, ], }, - ] as WalletState[] + ] as unknown as WalletState[] expect(getConnectedWallet(wallets)).toEqual({ label: 'Wallet 1', @@ -61,16 +63,17 @@ describe('useOnboard', () => { label: 'Wallet 1', icon: 'wallet1.svg', provider: null as unknown as EIP1193Provider, - chains: [{ id: '0x4' }], + chains: [{ id: '0x4', namespace: 'evm' }], accounts: [ { address: '0xinvalid', ens: null, + uns: null, balance: null, }, ], }, - ] as WalletState[] + ] as unknown as WalletState[] expect(getConnectedWallet(wallets)).toBeNull() }) diff --git a/apps/web/src/hooks/wallets/consts.ts b/apps/web/src/hooks/wallets/consts.ts index 6de6c74a70..fc13d551a3 100644 --- a/apps/web/src/hooks/wallets/consts.ts +++ b/apps/web/src/hooks/wallets/consts.ts @@ -3,11 +3,9 @@ export const enum WALLET_KEYS { WALLETCONNECT_V2 = 'WALLETCONNECT_V2', COINBASE = 'COINBASE', LEDGER = 'LEDGER', - // Placeholder to appease CGW_NAMES type - module name is also named Ledger - LEDGER_V2 = 'LEDGER_V2', TREZOR = 'TREZOR', KEYSTONE = 'KEYSTONE', - PK = 'PK', + PK = 'PRIVATE KEY', } // TODO: Check if undefined is needed as a return type, possibly couple this with WALLET_MODULES @@ -16,7 +14,6 @@ export const CGW_NAMES: { [_key in WALLET_KEYS]: string | undefined } = { [WALLET_KEYS.WALLETCONNECT_V2]: 'walletConnect_v2', [WALLET_KEYS.COINBASE]: 'coinbase', [WALLET_KEYS.LEDGER]: 'ledger', - [WALLET_KEYS.LEDGER_V2]: 'ledger_v2', [WALLET_KEYS.TREZOR]: 'trezor', [WALLET_KEYS.KEYSTONE]: 'keystone', [WALLET_KEYS.PK]: 'pk', diff --git a/apps/web/src/hooks/wallets/useOnboard.ts b/apps/web/src/hooks/wallets/useOnboard.ts index b73596ffac..10ae533b71 100644 --- a/apps/web/src/hooks/wallets/useOnboard.ts +++ b/apps/web/src/hooks/wallets/useOnboard.ts @@ -4,14 +4,16 @@ import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import type { Eip1193Provider } from 'ethers' import { getAddress } from 'ethers' import useChains, { useCurrentChain } from '@/hooks/useChains' -import ExternalStore from '@/services/ExternalStore' +import ExternalStore from '@safe-global/utils/services/ExternalStore' import { logError, Errors } from '@/services/exceptions' import { trackEvent, WALLET_EVENTS } from '@/services/analytics' -import { useAppSelector } from '@/store' -import { type EnvState, selectRpc } from '@/store/settingsSlice' -import { formatAmount } from '@/utils/formatNumber' +import { useAppSelector, useAppDispatch } from '@/store' +import { selectRpc } from '@/store/settingsSlice' +import { formatAmount } from '@safe-global/utils/utils/formatNumber' import { localItem } from '@/services/local-storage/local' import { isWalletConnect, isWalletUnlocked } from '@/utils/wallets' +import { setUnauthenticated } from '@/store/authSlice' +import type { EnvState } from '@safe-global/store/settingsSlice' export type ConnectedWallet = { label: string @@ -79,7 +81,7 @@ export const getConnectedWallet = (wallets: WalletState[]): ConnectedWallet | nu } } -const getWalletConnectLabel = async (wallet: ConnectedWallet): Promise<string | undefined> => { +export const getWalletConnectLabel = (wallet: ConnectedWallet): string | undefined => { const UNKNOWN_PEER = 'Unknown' if (!isWalletConnect(wallet)) return const { connector } = wallet.provider as unknown as any @@ -90,16 +92,13 @@ const getWalletConnectLabel = async (wallet: ConnectedWallet): Promise<string | const trackWalletType = (wallet: ConnectedWallet) => { trackEvent({ ...WALLET_EVENTS.CONNECT, label: wallet.label }) - getWalletConnectLabel(wallet) - .then((wcLabel) => { - if (wcLabel) { - trackEvent({ - ...WALLET_EVENTS.WALLET_CONNECT, - label: wcLabel, - }) - } + const wcLabel = getWalletConnectLabel(wallet) + if (wcLabel) { + trackEvent({ + ...WALLET_EVENTS.WALLET_CONNECT, + label: wcLabel, }) - .catch(() => null) + } } let isConnecting = false @@ -120,7 +119,7 @@ export const connectWallet = async ( try { wallets = await onboard.connectWallet(options) } catch (e) { - logError(Errors._302, e) + logError(Errors._107, e) isConnecting = false return @@ -160,6 +159,7 @@ export const useInitOnboard = () => { const chain = useCurrentChain() const onboard = useStore() const customRpc = useAppSelector(selectRpc) + const dispatch = useAppDispatch() useEffect(() => { if (configs.length > 0 && chain) { @@ -199,13 +199,14 @@ export const useInitOnboard = () => { } else if (lastConnectedWallet) { lastConnectedWallet = '' saveLastWallet(lastConnectedWallet) + dispatch(setUnauthenticated()) } }) return () => { walletSubscription.unsubscribe() } - }, [onboard]) + }, [onboard, dispatch]) } export default useStore diff --git a/apps/web/src/hooks/wallets/useSelectAvailableSigner.ts b/apps/web/src/hooks/wallets/useSelectAvailableSigner.ts deleted file mode 100644 index feef89fd90..0000000000 --- a/apps/web/src/hooks/wallets/useSelectAvailableSigner.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useCallback } from 'react' -import { useWalletContext } from './useWallet' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import { useNestedSafeOwners } from '../useNestedSafeOwners' -import { getAvailableSigners } from '@/utils/signers' - -/** - * - * @returns a function that sets a signer that can sign the given transaction in the given Safe - */ -export const useSelectAvailableSigner = () => { - const { connectedWallet: wallet, setSignerAddress } = useWalletContext() ?? {} - const nestedSafeOwners = useNestedSafeOwners() - - return useCallback( - (tx: SafeTransaction | undefined, safe: SafeInfo) => { - const availableSigners = getAvailableSigners(wallet, nestedSafeOwners, safe, tx) - - setSignerAddress?.(availableSigners[0]) - }, - [setSignerAddress, nestedSafeOwners, wallet], - ) -} diff --git a/apps/web/src/hooks/wallets/useWalletBalance.ts b/apps/web/src/hooks/wallets/useWalletBalance.ts index 2ae4608552..b8b2c21d4b 100644 --- a/apps/web/src/hooks/wallets/useWalletBalance.ts +++ b/apps/web/src/hooks/wallets/useWalletBalance.ts @@ -1,4 +1,4 @@ -import useAsync, { type AsyncResult } from '../useAsync' +import useAsync, { type AsyncResult } from '@safe-global/utils/hooks/useAsync' import useWallet from './useWallet' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' diff --git a/apps/web/src/hooks/wallets/wallets.ts b/apps/web/src/hooks/wallets/wallets.ts index ad5b508bb7..546e33b6fb 100644 --- a/apps/web/src/hooks/wallets/wallets.ts +++ b/apps/web/src/hooks/wallets/wallets.ts @@ -5,6 +5,7 @@ import coinbaseModule from '@web3-onboard/coinbase' import injectedWalletModule from '@web3-onboard/injected-wallets' import walletConnect from '@web3-onboard/walletconnect' import pkModule from '@/services/private-key-module' +import { ledgerModule } from '@/services/onboard/ledger-module' import { CGW_NAMES, WALLET_KEYS } from './consts' @@ -39,6 +40,7 @@ const WALLET_MODULES: Partial<{ [_key in WALLET_KEYS]: (chain: ChainInfo) => Wal [WALLET_KEYS.INJECTED]: () => injectedWalletModule() as WalletInit, [WALLET_KEYS.WALLETCONNECT_V2]: (chain) => walletConnectV2(chain) as WalletInit, [WALLET_KEYS.COINBASE]: () => coinbaseModule({ darkMode: prefersDarkMode() }) as WalletInit, + [WALLET_KEYS.LEDGER]: () => ledgerModule(), [WALLET_KEYS.PK]: (chain) => pkModule(chain.chainId, chain.rpcUri) as WalletInit, } diff --git a/apps/web/src/hooks/wallets/web3.ts b/apps/web/src/hooks/wallets/web3.ts index 40975d4b81..f3db2592d1 100644 --- a/apps/web/src/hooks/wallets/web3.ts +++ b/apps/web/src/hooks/wallets/web3.ts @@ -1,7 +1,7 @@ import { type ChainInfo, RPC_AUTHENTICATION, type RpcUri } from '@safe-global/safe-gateway-typescript-sdk' -import { INFURA_TOKEN, SAFE_APPS_INFURA_TOKEN } from '@/config/constants' import { JsonRpcProvider, BrowserProvider, type Eip1193Provider } from 'ethers' -import ExternalStore from '@/services/ExternalStore' +import ExternalStore from '@safe-global/utils/services/ExternalStore' +import { INFURA_TOKEN, SAFE_APPS_INFURA_TOKEN } from '@safe-global/utils/config/constants' /** * Infura and other RPC providers limit the max amount included in a batch RPC call. diff --git a/apps/web/src/markdown/privacy/privacy.md b/apps/web/src/markdown/privacy/privacy.md index 453a9d8f83..751567dbdf 100644 --- a/apps/web/src/markdown/privacy/privacy.md +++ b/apps/web/src/markdown/privacy/privacy.md @@ -6,7 +6,7 @@ Your privacy is important to us. It is our policy to respect your privacy and co This Privacy Policy describes how we, as a controller, collect, use and share your personal data. It applies to personal data you voluntarily provide to us, or is automatically collected by us. -In this policy, "we", "us" and "our" refers to Core Contributors GmbH a company incorporated in Germany with its registered address at Gontardstraße 11, 10178 Berlin, Germany. Any data protection related questions you might have about how we handle your personal data or if you wish to exercise your data subject rights, please contact us by post or at privacy@cc0x.dev. +In this policy, "we", "us" and "our" refers to Core Contributors GmbH a company incorporated in Germany with its registered address at c/o WeWork, Dircksenstr. 3, 10179 Berlin, Germany. Any data protection related questions you might have about how we handle your personal data or if you wish to exercise your data subject rights, please contact us by post or at privacy@cc0x.dev. In this Policy, “personal data” means any information relating to you as an identified or identifiable natural person (“Data Subject”); an identifiable natural person is one who can be identified, directly or indirectly, in particular by reference to an identifier such as a name, an online identifier or to one or more factors specific to your physical, physiological, genetic, mental, economic, cultural or social identity. @@ -614,8 +614,9 @@ We may modify this privacy policy at any time to comply with legal requirements ##### Contact us by post or email at: {#contact-us-by-post-or-email-at:} Core Contributors GmbH -Gontardstraße 11 -10178 Berlin +c/o WeWork +Dircksenstr. 3 +10179 Berlin Germany privacy@cc0x.dev diff --git a/apps/web/src/markdown/terms/terms.md b/apps/web/src/markdown/terms/terms.md index 3ddf2c604c..344b4801bd 100644 --- a/apps/web/src/markdown/terms/terms.md +++ b/apps/web/src/markdown/terms/terms.md @@ -1,6 +1,6 @@ # Terms and Conditions -Last updated: September, 2024 +Last updated: April, 2025 [1\. What is the scope of the Terms?](#1.-what-is-the-scope-of-the-terms?) @@ -24,7 +24,7 @@ Last updated: September, 2024 [11\. Are we responsible for the issuance of the Safe Token and any related functionalities or reward programs?](#11.-are-we-responsible-for-the-issuance-of-the-safe-token-and-any-related-functionalities-or-reward-programs?) -[12\. Are we responsible for third-party content and services?](#12.-are-we-responsible-for-third-party-content-and-services?) +[12\. What is Cloud Storage and the Spaces feature?](#12.-what-is-cloud-storage-and-the-spaces-feature?) [13\. Can we terminate or limit your right to use our Services?](#13.-can-we-terminate-or-limit-your-right-to-use-our-services?) @@ -66,7 +66,7 @@ Last updated: September, 2024 # 1\. What is the scope of the Terms? {#1.-what-is-the-scope-of-the-terms?} -These Terms and Conditions (“**Terms**”) become part of any contract (“**Agreement**”) between you (“**you**”, “**yours**” or “**User**”) and Core Contributors GmbH (“**CC**”, “**we**”, “**our**” or “**us**”) provided we made these Terms accessible to you prior to entering into the Agreement and you consent to these Terms. We are a limited liability company registered with the commercial register of Berlin Charlottenburg under company number HRB 240421 B, with its registered office at Gontardstraße 11, 10178 Berlin, Germany. You can contact us by writing to info@cc0x.dev. +These Terms and Conditions (“**Terms**”) become part of any contract (“**Agreement**”) between you (“**you**”, “**yours**” or “**User**”) and Core Contributors GmbH (“**CC**”, “**we**”, “**our**” or “**us**”) provided we made these Terms accessible to you prior to entering into the Agreement and you consent to these Terms. We are a limited liability company registered with the commercial register of Berlin Charlottenburg under company number HRB 240421 B, with its registered office at c/o WeWork, Dircksenstr. 3, 10179 Berlin, Germany. You can contact us by writing to info@cc0x.dev. The Agreement is concluded by using the Mobile App, Web App and/or Browser Extension subject to these Terms. The use of our Services is only permitted to legal entities, partnerships and natural persons with unlimited legal capacity. In particular, minors are prohibited from using our Services. @@ -140,15 +140,27 @@ Our Services do not consist of: # 5\. What do you need to know about Third-Party Safe Apps and Third-Party Services? {#5.-what-do-you-need-to-know-about-third-party-safe-apps-and-third-party-services?} -1. 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. +1. You may view, have access to, and may use Third-Party Safe Apps and Third-Party Services within the Safe{Wallet} App. You view, access, or use Third-Party Safe Apps and Third-Party Services at your own election. Your reliance on Third-Party Safe Apps and Third-Party Services is subject to separate terms and conditions set forth by the applicable third party content and/or service provider (“Third-Party Terms”). Third-Party Terms may, amongst other things, -2. The Services rely in part on third-party and open-source software, including the Blockchain, and the continued development and support by third parties. There is no assurance or guarantee that those third parties will maintain their support of their software or that open-source software will continue to be maintained. This may have a material adverse effect on the Services. + 1. involve separate fees and charges, + + 2. include disclaimers or risk warnings, + + 3. apply a different terms and privacy policy. + +2. It is your responsibility to understand the Third-Party Terms, including how Third-Party Safe Apps and Third-Party Services use any of your information under their privacy policies. + +3. Third-Party Safe Apps and Third-Party Services are provided for your convenience only. We do not verify or control Third-Party Safe Apps and Third-Party Services. + +4. If we offer access to Third-Party Safe Apps and Third-Party Services in the Safe App free of charge by us (third-parties may charge separate fees), the liability for providing access to such Third-Party Safe Apps and Third-Party Services is limited pursuant to Section 18 below. Furthermore we reserve the right to cease to provide access to those Third-Party Safe Apps and Third-Party Services through the Safe{Wallet} App without notice. + +5. The Services rely in part on third-party and open-source software, including the Blockchain, and the continued development and support by third parties. There is no assurance or guarantee that those third parties will maintain their support of their software or that open-source software will continue to be maintained. This may have a material adverse effect on the Services. -3. This means specifically: +6. This means specifically: * We do not have any oversight over your activities with Third-Party Services especially by using Third-Party Safe Apps, and therefore we do not and cannot make any representation regarding their appropriateness and suitability for you. -* Third-Party Safe Apps and Third-Party Services are not hosted, owned, controlled or maintained by us. We also do not participate in the Transaction and will not and cannot monitor, verify, censor or edit the functioning or content of any Third-Party Safe Apps and Third-Party Services. +* Third-Party Safe Apps and Third-Party Services are not hosted, owned, controlled or maintained by us. We also do not participate in the Transaction and will not and cannot monitor, verify, censor or edit the functioning or content of any Third-Party Safe Apps and Third-Party Service. * We have not conducted any security audit, bug bounty or formal verification (whether internal or external) of the Third-Party Safe Apps and Third-Party Services. @@ -158,7 +170,7 @@ Our Services do not consist of: * You know of the inherent risks of cryptographic and Blockchain-based systems and the high volatility of Token markets. Transactions undertaken in the Blockchain are irrevocable and irreversible and there is no possibility to refund Token that have been deployed. -* You should read the license requirements, terms and conditions as well as privacy policy of each Third-Party Safe Appz and Third-Party Service that you access or use. Certain Third-Party Safe Apps and Third-Party Services may involve complex Transactions that entail a high degree of risk. +* You should read the license requirements, terms and conditions as well as privacy policy of each Third-Party Safe Apps and Third-Party Service that you access or use. Certain Third-Party Safe Apps and Third-Party Services may involve complex Transactions that entail a high degree of risk. * If you contribute integrations to Third-Party Safe Apps and Third-Party Services, you are responsible for all content you contribute, in any manner, and you must have all rights necessary to do so, in the manner in which you contribute it. You are responsible for all your activity in connection with any such Third-Party Safe Apps and Third-Party Services. @@ -214,21 +226,33 @@ Our Services do not consist of: 3. Any interface we provide that allows you to claim or delegate your Safe Tokens or to participate in any third party program related to Safe Tokens is provided free of charge and we exclude any and all liability for the correctness, completeness, speed or timeliness of these services. Furthermore we reserve the right to change the feature from time to time or cease to provide them without notice. -# 12\. Are we responsible for third-party content and services? {#12.-are-we-responsible-for-third-party-content-and-services?} +# 12\. What is Cloud Storage and the Spaces feature? {#12.-what-is-cloud-storage-and-the-spaces-feature?} -1. You may view, have access to, and may use third-party content and services, for example widget integrations, within the Safe{Wallet} App (“Third-Party Features”). You view, access, or use Third-Party Features at your own election. Your reliance on Third-Party Features is subject to separate terms and conditions set forth by the applicable third party content and/or service provider (“Third-Party Terms”). Third-Party Terms may, amongst other things, +1. We offer an optional cloud storage service for user created metadata to improve user experience and data management called Spaces. By opting into this service, your metadata, which is by default stored in your browser's local storage, will instead be stored in our private database. The metadata may include, amongst other things: - 1. involve separate fees and charges, + 1. Safe Accounts you created; - 2. include disclaimers or risk warnings, + 2. Counterfactual Safe Accounts you created; - 3. apply a different terms and privacy policy. + 3. Address book you created; + + 4. Safe names you attributed; + + 5. Signer names you attributed; + + 6. Watchlist you created (read-only Safe Accounts). + +2. The cloud storage also allows you to share your metadata with others by creating a shared set of metadata (“Spaces”), adding other users’ Wallet addresses to this Space, and them accepting. By creating or joining a Space, users acknowledge and agree to the following: + +* Users are solely responsible for verifying the identity of members they add to a Space. Users must not add others without their explicit consent. We do not verify or endorse Spacemembers or their actions. + +* Users must ensure that any metadata they add or share within aSpace complies with all applicable laws and regulations, including but not limited to data protection laws. - It is your responsibility to understand the Third-Party Terms, including how Third-Party Features use any of your information under their privacy policies. +* Users must not share or store metadata that is unlawful, fraudulent, misleading, or violates the rights of any third party. We do not assume any responsibility for any metadata shared within a Space. -2. Third Party Features are provided for your convenience only. We do not verify, curate, or control Third Party Features. +* We do not intend for users to process personal data via this feature. However, if a user chooses to process personal data within a Space , they are responsible for ensuring compliance with applicable data protection laws. In such cases, the user must inform us in advance and request a Data Processing Agreement (DPA). We reserve the right to refuse or impose conditions on such data processing. -3. If we offer access to Third-Party Features in the Safe{Wallet} App free of charge by us (Third-Parties may charge separate fees), the liability for providing access to such Third-Party Feature is limited pursuant to Section 18 below. Furthermore we reserve the right to cease to provide access to those Third-Party Features through the Safe{Wallet} App without notice. +3. Any cloud storage of metadata we provide or offer within the Safe{Wallet} App is provided free of charge and liability is limited pursuant to Section 18 below. Furthermore we reserve the right to change the feature from time to time or cease to provide it with one week notice, prompting the deletion of your metadata from our database while retaining a copy in your local browser storage. # 13\. Can we terminate or limit your right to use our Services? {#13.-can-we-terminate-or-limit-your-right-to-use-our-services?} @@ -383,4 +407,4 @@ For users who are merchants within the meaning of the German Commercial Code (Ha # 31\. Is this all? {#31.-is-this-all?} -These Terms constitute the entire agreement between you and us in relation to the Agreement’s subject matter. It replaces and extinguishes any and all prior agreements, draft agreements, arrangements, warranties, statements, assurances, representations and undertakings of any nature made by, or on behalf of either of us, whether oral or written, public or private, in relation to that subject matter. +These Terms constitute the entire agreement between you and us in relation to the Agreement’s subject matter. It replaces and extinguishes any and all prior agreements, draft agreements, arrangements, warranties, statements, assurances, representations and undertakings of any nature made by, or on behalf of either of us, whether oral or written, public or private, in relation to that subject matter. diff --git a/apps/web/src/markdown/terms/version.js b/apps/web/src/markdown/terms/version.js new file mode 100644 index 0000000000..851c11ce45 --- /dev/null +++ b/apps/web/src/markdown/terms/version.js @@ -0,0 +1,3 @@ +// 'version' is used in cypress.config.js. If you need to misplace it please update the file accordingly. +export const version = '1.3' +export const lastUpdated = 'April, 2025' diff --git a/apps/web/src/markdown/terms/version.ts b/apps/web/src/markdown/terms/version.ts deleted file mode 100644 index 3cfbbfd377..0000000000 --- a/apps/web/src/markdown/terms/version.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const version = '1.2' -export const lastUpdated = 'September, 2024' diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 7d70e7f324..49be878863 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -1,4 +1,5 @@ -import { SentryErrorBoundary } from '@/services/sentry' // needs to be imported first +import Analytics from '@/services/analytics/Analytics' +import { SentryErrorBoundary } from '@/services/sentry' import type { ReactNode } from 'react' import { type ReactElement } from 'react' import { type AppProps } from 'next/app' @@ -47,6 +48,8 @@ import GeoblockingProvider from '@/components/common/GeoblockingProvider' import { useVisitedSafes } from '@/features/myAccounts/hooks/useVisitedSafes' import OutreachPopup from '@/features/targetedOutreach/components/OutreachPopup' import { GATEWAY_URL } from '@/config/gateway' +import { useDatadog } from '@/services/datadog' +import useMixpanel from '@/services/analytics/useMixpanel' const reduxStore = makeStore() @@ -55,7 +58,9 @@ const InitApp = (): null => { setNewGatewayBaseUrl(GATEWAY_URL) useHydrateStore(reduxStore) useAdjustUrl() + useDatadog() useGtm() + useMixpanel() useNotificationTracking() useInitSession() useLoadableStores() @@ -99,16 +104,16 @@ export const AppProviders = ({ children }: { children: ReactNode | ReactNode[] } ) } -interface WebCoreAppProps extends AppProps { +interface SafeWalletAppProps extends AppProps { emotionCache?: EmotionCache } -const WebCoreApp = ({ +const SafeWalletApp = ({ Component, pageProps, router, emotionCache = clientSideEmotionCache, -}: WebCoreAppProps): ReactElement => { +}: SafeWalletAppProps): ReactElement => { const safeKey = useChangedValue(router.query.safe?.toString()) return ( @@ -138,6 +143,8 @@ const WebCoreApp = ({ <CounterfactualHooks /> + <Analytics /> + <PkModulePopup /> </AppProviders> </CacheProvider> @@ -145,4 +152,4 @@ const WebCoreApp = ({ ) } -export default WebCoreApp +export default SafeWalletApp diff --git a/apps/web/src/pages/apps/index.tsx b/apps/web/src/pages/apps/index.tsx index 85c87216fa..d6fbdd5ab5 100644 --- a/apps/web/src/pages/apps/index.tsx +++ b/apps/web/src/pages/apps/index.tsx @@ -13,9 +13,9 @@ import { AppRoutes } from '@/config/routes' import useSafeAppsFilters from '@/hooks/safe-apps/useSafeAppsFilters' import SafeAppsFilters from '@/components/safe-apps/SafeAppsFilters' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' import { SAFE_APPS_LABELS } from '@/services/analytics' import { BRAND_NAME } from '@/config/constants' +import { FEATURES } from '@safe-global/utils/utils/chains' const SafeApps: NextPage = () => { const router = useRouter() diff --git a/apps/web/src/pages/apps/open.tsx b/apps/web/src/pages/apps/open.tsx index 938e690dcb..f01758d8f1 100644 --- a/apps/web/src/pages/apps/open.tsx +++ b/apps/web/src/pages/apps/open.tsx @@ -16,14 +16,17 @@ import useChainId from '@/hooks/useChainId' import { AppRoutes } from '@/config/routes' import { getOrigin } from '@/components/safe-apps/utils' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' + +import { FEATURES } from '@safe-global/utils/utils/chains' const SafeApps: NextPage = () => { const chainId = useChainId() const router = useRouter() const appUrl = useSafeAppUrl() + const appHostname = getOrigin(appUrl) const { allSafeApps, remoteSafeAppsLoading } = useSafeApps() - const safeAppData = allSafeApps.find((app) => app.url === appUrl) + const safeAppData = + allSafeApps.find((app) => app.url === appUrl) || allSafeApps.find((app) => getOrigin(app.url) === appHostname) const { safeApp, isLoading } = useSafeAppFromManifest(appUrl || '', chainId, safeAppData) const isSafeAppsEnabled = useHasFeature(FEATURES.SAFE_APPS) diff --git a/apps/web/src/pages/balances/nfts.tsx b/apps/web/src/pages/balances/nfts.tsx index e8e2fa77ce..2b81ccf961 100644 --- a/apps/web/src/pages/balances/nfts.tsx +++ b/apps/web/src/pages/balances/nfts.tsx @@ -10,7 +10,7 @@ import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' // `React.memo` requires a `displayName` const NftApps = memo(function NftApps(): ReactElement | null { - const [nftApps] = useRemoteSafeApps(SafeAppsTag.NFT) + const [nftApps] = useRemoteSafeApps({ tag: SafeAppsTag.NFT }) if (nftApps?.length === 0) { return null diff --git a/apps/web/src/pages/earn.tsx b/apps/web/src/pages/earn.tsx new file mode 100644 index 0000000000..4937285de1 --- /dev/null +++ b/apps/web/src/pages/earn.tsx @@ -0,0 +1,33 @@ +import type { NextPage } from 'next' +import Head from 'next/head' +import dynamic from 'next/dynamic' +import { Typography } from '@mui/material' +import { BRAND_NAME } from '@/config/constants' +import { FEATURES } from '@safe-global/utils/utils/chains' +import { useHasFeature } from '@/hooks/useChains' + +const LazyEarnPage = dynamic(() => import('@/features/earn'), { ssr: false }) + +const EarnPage: NextPage = () => { + const isFeatureEnabled = useHasFeature(FEATURES.EARN) + + return ( + <> + <Head> + <title>{`${BRAND_NAME} – Earn`} + + + {isFeatureEnabled === true ? ( + + ) : isFeatureEnabled === false ? ( +
+ + Earn is not available on this network. + +
+ ) : null} + + ) +} + +export default EarnPage diff --git a/apps/web/src/pages/imprint.tsx b/apps/web/src/pages/imprint.tsx index 50c8ef286f..d5a84aaee2 100644 --- a/apps/web/src/pages/imprint.tsx +++ b/apps/web/src/pages/imprint.tsx @@ -17,9 +17,11 @@ const SafeImprint = () => ( Core Contributors GmbH
- Gontardstraße 11 + c/o WeWork
- 10178 Berlin, Germany + Dircksenstr. 3 +
+ 10179 Berlin, Germany
Managing director: Richard Meißner diff --git a/apps/web/src/pages/licenses.tsx b/apps/web/src/pages/licenses.tsx index f9107851df..34feecf521 100644 --- a/apps/web/src/pages/licenses.tsx +++ b/apps/web/src/pages/licenses.tsx @@ -38,178 +38,127 @@ const SafeLicenses = () => ( AndroidX - - https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/LICENSE.txt - + Bivrost for Kotlin - - https://github.com/gnosis/bivrost-kotlin/blob/master/LICENSE - + Dagger - - {' '} - https://github.com/google/dagger#license{' '} - + FloatingActionButton - - https://github.com/Clans/FloatingActionButton/blob/master/LICENSE - + Material Progress Bar - - https://github.com/DreaminginCodeZH/MaterialProgressBar/blob/master/LICENSE - + Kethereum - - https://github.com/walleth/kethereum/blob/master/LICENSE - + Koptional - - {' '} - https://github.com/gojuno/koptional#license{' '} - + Moshi - - {' '} - https://github.com/square/moshi#license{' '} - + OkHttp - - {' '} - https://github.com/square/okhttp#license{' '} - + Okio - - {' '} - https://github.com/square/okio#license{' '} - + Phrase - - {' '} - https://github.com/square/phrase/#license{' '} - + Picasso - - {' '} - https://github.com/square/picasso#license{' '} - + ReTrofit - - {' '} - https://github.com/square/reTrofit#license{' '} - + RxAndroid - - https://github.com/ReactiveX/RxAndroid#license - + RxBinding - - https://github.com/JakeWharton/RxBinding#license - + RxJava - - {' '} - https://github.com/ReactiveX/RxJava#license{' '} - + RxKotlin - - https://github.com/ReactiveX/RxKotlin/blob/2.x/LICENSE - + SpongyCastle - - https://github.com/rtyley/spongycastle/blob/spongy-master/LICENSE.html - + Svalinn Android - - https://github.com/gnosis/svalinn-kotlin/blob/master/LICENSE - + Timber - - https://github.com/JakeWharton/timber#license - + Zxing - - https://github.com/zxing/zxing/blob/master/LICENSE - + @@ -236,106 +185,79 @@ const SafeLicenses = () => ( BigInt - - https://github.com/attaswift/BigInt/blob/master/LICENSE.md - + BlockiesSwift - - https://github.com/gnosis/BlockiesSwift/blob/master/LICENSE - + CryptoEthereumSwift - - https://github.com/yuzushioh/CryptoEthereumSwift/blob/master/LICENSE - + CryptoSwift - - https://github.com/krzyzanowskim/CryptoSwift#license - + DateTools - - {' '} - https://github.com/gnosis/DateTools#license{' '} - + EthereumKit - - https://github.com/D-Technologies/EthereumKit#license - + Keycard.swift - - https://github.com/gnosis/Keycard.swift/blob/master/LICENSE - + Kingfisher - - https://github.com/onevcat/Kingfisher#license - + SipHash - - https://github.com/attaswift/SipHash/blob/master/LICENSE.md - + Starscream - - https://github.com/daltoniam/Starscream/blob/master/LICENSE - + RsBarcodesSwift - - https://github.com/yeahdongcn/RSBarcodes_Swift#license - + libidn2 - - https://github.com/gnosis/libidn2/blob/master/COPYING.LESSERv3 - + libunisTring - - https://github.com/gnosis/libunisTring/blob/master/COPYING.LIB - + @@ -358,345 +280,253 @@ const SafeLicenses = () => ( @emotion/cache - - https://github.com/emotion-js/emotion/blob/main/LICENSE - + @emotion/react - - https://github.com/emotion-js/emotion/blob/main/LICENSE - + @emotion/server - - https://github.com/emotion-js/emotion/blob/main/LICENSE - + @emotion/styled - - https://github.com/emotion-js/emotion/blob/main/LICENSE - + @safe-global/safe-modules-deployments - - https://github.com/safe-global/safe-modules-deployments/blob/main/LICENSE - + @mui/icons-material - - https://github.com/mui/material-ui/blob/master/LICENSE - + @mui/material - - https://github.com/mui/material-ui/blob/master/LICENSE - + @mui/x-date-pickers - - https://github.com/mui/mui-x#mit-vs-commercial-licenses - + @reduxjs/toolkit - - https://github.com/reduxjs/redux-toolkit/blob/master/LICENSE - + @safe-global/safe-apps-sdk - - https://github.com/safe-global/safe-apps-sdk/blob/main/LICENSE.md - + @safe-global/safe-core-sdk - - https://github.com/safe-global/safe-core-sdk/blob/main/LICENSE.md - + @safe-global/safe-deployments - - https://github.com/safe-global/safe-deployments/blob/main/LICENSE - + @safe-global/safe-gateway-typescript-sdk - - https://github.com/safe-global/safe-gateway-typescript-sdk/blob/main/LICENSE.md - + @safe-global/safe-react-components - - https://github.com/safe-global/safe-react-components/blob/main/LICENSE.md - + @sentry/react - - https://github.com/getsentry/sentry-javascript/blob/develop/LICENSE - + @sentry/tracing - - https://github.com/getsentry/sentry-javascript/blob/develop/LICENSE - + @web3-onboard/coinbase - - https://github.com/blocknative/web3-onboard/blob/main/LICENSE - + @web3-onboard/core - - https://github.com/blocknative/web3-onboard/blob/main/LICENSE - + @web3-onboard/injected-wallets - - https://github.com/blocknative/web3-onboard/blob/main/LICENSE - + @web3-onboard/keystone - - https://github.com/blocknative/web3-onboard/blob/main/LICENSE - + @web3-onboard/ledger - - https://github.com/blocknative/web3-onboard/blob/main/LICENSE - + @web3-onboard/trezor - - https://github.com/blocknative/web3-onboard/blob/main/LICENSE - + @web3-onboard/walletconnect - - https://github.com/blocknative/web3-onboard/blob/main/LICENSE - + classnames - - https://github.com/JedWatson/classnames/blob/main/LICENSE - + date-fns - - https://github.com/date-fns/date-fns/blob/main/LICENSE.md - + - ethereum-blockies-base64 + blo - - https://github.com/MyCryptoHQ/ethereum-blockies-base64 - + ethers - - https://github.com/ethers-io/ethers.js/blob/main/LICENSE.md - + exponential-backoff - - https://github.com/coveo/exponential-backoff/blob/master/LICENSE - + fuse.js - - https://github.com/krisk/Fuse/blob/master/LICENSE - + js-cookie - - https://github.com/js-cookie/js-cookie/blob/main/LICENSE - + lodash - - https://github.com/lodash/lodash/blob/master/LICENSE - + next - - https://github.com/vercel/next.js/blob/canary/LICENSE - + next-pwa - - https://github.com/shadowwalker/next-pwa/blob/master/LICENSE - + papaparse - - https://github.com/mholt/PapaParse/blob/master/LICENSE - + qrcode.react - - https://github.com/zpao/qrcode.react/blob/main/LICENSE - + react - - https://github.com/facebook/react/blob/main/LICENSE - + react-dom - - https://github.com/facebook/react/blob/main/LICENSE - + react-dropzone - - https://github.com/react-dropzone/react-dropzone/blob/master/LICENSE - - - - - react-gtm-module - - - https://github.com/alinemorelli/react-gtm/blob/master/LICENSE - + react-hook-form - - https://github.com/react-hook-form/react-hook-form/blob/master/LICENSE - + react-papaparse - - https://github.com/Bunlong/react-papaparse/blob/master/LICENSE - + react-redux - - https://github.com/reduxjs/react-redux/blob/master/LICENSE - + semver - - https://github.com/npm/node-semver/blob/main/LICENSE - + diff --git a/apps/web/src/pages/privacy.tsx b/apps/web/src/pages/privacy.tsx index db750335a1..f9adfcd54f 100644 --- a/apps/web/src/pages/privacy.tsx +++ b/apps/web/src/pages/privacy.tsx @@ -7,7 +7,6 @@ import { useIsOfficialHost } from '@/hooks/useIsOfficialHost' import { BRAND_NAME } from '@/config/constants' const overrideComponents: MDXComponents = { - // @ts-expect-error a: CustomLink, } diff --git a/apps/web/src/pages/settings/notifications.tsx b/apps/web/src/pages/settings/notifications.tsx index d6c3760779..edac33d083 100644 --- a/apps/web/src/pages/settings/notifications.tsx +++ b/apps/web/src/pages/settings/notifications.tsx @@ -4,8 +4,8 @@ import type { NextPage } from 'next' import SettingsHeader from '@/components/settings/SettingsHeader' import { PushNotifications } from '@/components/settings/PushNotifications' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' import { BRAND_NAME } from '@/config/constants' +import { FEATURES } from '@safe-global/utils/utils/chains' const NotificationsPage: NextPage = () => { const isNotificationFeatureEnabled = useHasFeature(FEATURES.PUSH_NOTIFICATIONS) diff --git a/apps/web/src/pages/settings/setup.tsx b/apps/web/src/pages/settings/setup.tsx index c41cd26b2e..ecbfe80401 100644 --- a/apps/web/src/pages/settings/setup.tsx +++ b/apps/web/src/pages/settings/setup.tsx @@ -1,6 +1,6 @@ import type { NextPage } from 'next' import Head from 'next/head' -import { Grid, Paper, Skeleton, SvgIcon, Tooltip, Typography } from '@mui/material' +import { Grid, Paper, Skeleton, SvgIcon, Tooltip, Typography, Box } from '@mui/material' import InfoIcon from '@/public/images/notifications/info.svg' import { ContractVersion } from '@/components/settings/ContractVersion' import { OwnerList } from '@/components/settings/owner/OwnerList' @@ -10,6 +10,7 @@ import SettingsHeader from '@/components/settings/SettingsHeader' import ProposersList from 'src/components/settings/ProposersList' import SpendingLimits from '@/components/settings/SpendingLimits' import { BRAND_NAME } from '@/config/constants' +import { NestedSafesList } from '@/components/settings/NestedSafesList' const Setup: NextPage = () => { const { safe, safeLoaded } = useSafeInfo() @@ -60,14 +61,27 @@ const Setup: NextPage = () => { - + + + + Members + + - + + + + + + + + + ) diff --git a/apps/web/src/pages/spaces/index.tsx b/apps/web/src/pages/spaces/index.tsx new file mode 100644 index 0000000000..7f8c2421bb --- /dev/null +++ b/apps/web/src/pages/spaces/index.tsx @@ -0,0 +1,26 @@ +import { useRouter } from 'next/router' +import Head from 'next/head' +import { BRAND_NAME } from '@/config/constants' +import SpaceDashboard from '@/features/spaces/components/Dashboard' +import AuthState from '@/features/spaces/components/AuthState' + +export default function SpacePage() { + const router = useRouter() + const { spaceId } = router.query + + if (!router.isReady || !spaceId || typeof spaceId !== 'string') return null + + return ( + <> + + {`${BRAND_NAME} – Space dashboard`} + + +
+ + + +
+ + ) +} diff --git a/apps/web/src/pages/spaces/members.tsx b/apps/web/src/pages/spaces/members.tsx new file mode 100644 index 0000000000..762054ec27 --- /dev/null +++ b/apps/web/src/pages/spaces/members.tsx @@ -0,0 +1,26 @@ +import { useRouter } from 'next/router' +import Head from 'next/head' +import { BRAND_NAME } from '@/config/constants' +import SpaceMembers from '@/features/spaces/components/Members' +import AuthState from '@/features/spaces/components/AuthState' + +export default function SpaceMembersPage() { + const router = useRouter() + const { spaceId } = router.query + + if (!router.isReady || !spaceId || typeof spaceId !== 'string') return null + + return ( + <> + + {`${BRAND_NAME} – Space members`} + + +
+ + + +
+ + ) +} diff --git a/apps/web/src/pages/spaces/safe-accounts.tsx b/apps/web/src/pages/spaces/safe-accounts.tsx new file mode 100644 index 0000000000..5d8b77998b --- /dev/null +++ b/apps/web/src/pages/spaces/safe-accounts.tsx @@ -0,0 +1,26 @@ +import { useRouter } from 'next/router' +import Head from 'next/head' +import { BRAND_NAME } from '@/config/constants' +import SpaceSafeAccounts from '@/features/spaces/components/SafeAccounts' +import AuthState from '@/features/spaces/components/AuthState' + +export default function SpaceAccountsPage() { + const router = useRouter() + const { spaceId } = router.query + + if (!router.isReady || !spaceId || typeof spaceId !== 'string') return null + + return ( + <> + + {`${BRAND_NAME} – Space Safe Accounts`} + + +
+ + + +
+ + ) +} diff --git a/apps/web/src/pages/spaces/settings.tsx b/apps/web/src/pages/spaces/settings.tsx new file mode 100644 index 0000000000..a16ddfab92 --- /dev/null +++ b/apps/web/src/pages/spaces/settings.tsx @@ -0,0 +1,26 @@ +import { useRouter } from 'next/router' +import Head from 'next/head' +import { BRAND_NAME } from '@/config/constants' +import SpaceSettings from 'src/features/spaces/components/SpaceSettings' +import AuthState from '@/features/spaces/components/AuthState' + +export default function SpaceSettingsPage() { + const router = useRouter() + const { spaceId } = router.query + + if (!router.isReady || !spaceId || typeof spaceId !== 'string') return null + + return ( + <> + + {`${BRAND_NAME} – Space settings`} + + +
+ + + +
+ + ) +} diff --git a/apps/web/src/pages/stake.tsx b/apps/web/src/pages/stake.tsx index af79840caa..0ef3db324b 100644 --- a/apps/web/src/pages/stake.tsx +++ b/apps/web/src/pages/stake.tsx @@ -3,8 +3,8 @@ import Head from 'next/head' import dynamic from 'next/dynamic' import { Typography } from '@mui/material' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' import { BRAND_NAME } from '@/config/constants' +import { FEATURES } from '@safe-global/utils/utils/chains' const LazyStakePage = dynamic(() => import('@/features/stake/components/StakePage'), { ssr: false }) diff --git a/apps/web/src/pages/swap.tsx b/apps/web/src/pages/swap.tsx index b97c9a6f9f..1f0b13a30a 100644 --- a/apps/web/src/pages/swap.tsx +++ b/apps/web/src/pages/swap.tsx @@ -4,8 +4,8 @@ import { useRouter } from 'next/router' import dynamic from 'next/dynamic' import { Typography } from '@mui/material' import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' import { BRAND_NAME } from '@/config/constants' +import { FEATURES } from '@safe-global/utils/utils/chains' // Cow Swap expects native token addresses to be in the format '0xeeee...eeee' const adjustEthAddress = (address: string) => { @@ -17,17 +17,19 @@ const adjustEthAddress = (address: string) => { } const SwapWidgetNoSSR = dynamic(() => import('@/features/swap'), { ssr: false }) +const FallbackSwapWidgetNoSSR = dynamic(() => import('@/features/swap/components/FallbackSwapWidget'), { ssr: false }) const SwapPage: NextPage = () => { const router = useRouter() const { token, amount } = router.query const isFeatureEnabled = useHasFeature(FEATURES.NATIVE_SWAPS) + const isCowEnabled = useHasFeature(FEATURES.NATIVE_SWAPS_COW) let sell = undefined if (token && amount) { sell = { asset: adjustEthAddress(String(token ?? '')), - amount: adjustEthAddress(String(amount ?? '')), + amount: String(amount ?? ''), } } @@ -38,8 +40,10 @@ const SwapPage: NextPage = () => {
- {isFeatureEnabled === true ? ( + {isFeatureEnabled === true && isCowEnabled === true ? ( + ) : isFeatureEnabled === true && isCowEnabled === false ? ( + ) : isFeatureEnabled === false ? ( Swaps are not supported on this network. diff --git a/apps/web/src/pages/transactions/messages.tsx b/apps/web/src/pages/transactions/messages.tsx index e39e0659a3..a394ea5c5d 100644 --- a/apps/web/src/pages/transactions/messages.tsx +++ b/apps/web/src/pages/transactions/messages.tsx @@ -1,6 +1,5 @@ import { useEffect } from 'react' import Head from 'next/head' -import { FEATURES } from '@/utils/chains' import { useRouter } from 'next/router' import type { NextPage } from 'next' @@ -9,8 +8,8 @@ import TxHeader from '@/components/transactions/TxHeader' import SignedMessagesHelpLink from '@/components/transactions/SignedMessagesHelpLink' import { AppRoutes } from '@/config/routes' import { useCurrentChain } from '@/hooks/useChains' -import { hasFeature } from '@/utils/chains' import { BRAND_NAME } from '@/config/constants' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' const Messages: NextPage = () => { const chain = useCurrentChain() diff --git a/apps/web/src/pages/user-settings.tsx b/apps/web/src/pages/user-settings.tsx new file mode 100644 index 0000000000..372d409d28 --- /dev/null +++ b/apps/web/src/pages/user-settings.tsx @@ -0,0 +1,18 @@ +import type { NextPage } from 'next' +import Head from 'next/head' +import { BRAND_NAME } from '@/config/constants' +import UserSettings from '@/features/spaces/components/UserSettings' + +const UserSettingsPage: NextPage = () => { + return ( + <> + + {`${BRAND_NAME} – User Settings`} + + + + + ) +} + +export default UserSettingsPage diff --git a/apps/web/src/pages/welcome/spaces.tsx b/apps/web/src/pages/welcome/spaces.tsx new file mode 100644 index 0000000000..b14e1b97a7 --- /dev/null +++ b/apps/web/src/pages/welcome/spaces.tsx @@ -0,0 +1,24 @@ +import type { NextPage } from 'next' +import Head from 'next/head' +import SpacesList from '@/features/spaces/components/SpacesList' +import { BRAND_NAME } from '@/config/constants' +import useFeatureFlagRedirect from '@/features/spaces/hooks/useFeatureFlagRedirect' +import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' + +const Spaces: NextPage = () => { + const isSpacesFeatureEnabled = useHasFeature(FEATURES.SPACES) + useFeatureFlagRedirect() + + return ( + <> + + {`${BRAND_NAME} – Spaces`} + + + {isSpacesFeatureEnabled && } + + ) +} + +export default Spaces diff --git a/apps/web/src/permissions/config.test.ts b/apps/web/src/permissions/config.test.ts new file mode 100644 index 0000000000..39d9841e63 --- /dev/null +++ b/apps/web/src/permissions/config.test.ts @@ -0,0 +1,166 @@ +import { faker } from '@faker-js/faker' +import rolePermissionsConfig, { Permission, Role } from './config' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import type useWallet from '@/hooks/wallets/useWallet' +import type { SafeTransaction } from '@safe-global/types-kit' +import type { SpendingLimitState } from '@/store/spendingLimitsSlice' + +describe('RolePermissionsConfig', () => { + const safeAddress = faker.finance.ethereumAddress() + const walletAddress = faker.finance.ethereumAddress() + + const mockSafeTx = { data: { nonce: 1 } } as SafeTransaction + + const mockSafe = extendedSafeInfoBuilder() + .with({ address: { value: safeAddress }, owners: [{ value: walletAddress }] }) + .with({ deployed: true }) + .build() + + const mockWallet = { + address: walletAddress, + } as ReturnType + + const mockCommonProps = { + safe: mockSafe, + wallet: mockWallet, + } + + describe('Owner', () => { + it('should return correct permissions', () => { + const permissions = rolePermissionsConfig[Role.Owner]!(mockCommonProps) + expect(permissions).toEqual({ + [Permission.CreateTransaction]: true, + [Permission.ProposeTransaction]: true, + [Permission.SignTransaction]: true, + [Permission.ExecuteTransaction]: expect.any(Function), + [Permission.EnablePushNotifications]: true, + }) + expect(permissions[Permission.ExecuteTransaction]!({ safeTx: mockSafeTx })).toBe(true) + }) + }) + + describe('Proposer', () => { + it('should return correct permissions', () => { + const permissions = rolePermissionsConfig[Role.Proposer]!(mockCommonProps) + expect(permissions).toEqual({ + [Permission.CreateTransaction]: true, + [Permission.ProposeTransaction]: true, + [Permission.ExecuteTransaction]: expect.any(Function), + [Permission.EnablePushNotifications]: true, + }) + expect(permissions[Permission.ExecuteTransaction]!({ safeTx: mockSafeTx })).toBe(true) + }) + }) + + describe('Executioner', () => { + it('should return correct permissions', () => { + const permissions = rolePermissionsConfig[Role.Executioner]!(mockCommonProps) + expect(permissions).toEqual({ + [Permission.ExecuteTransaction]: expect.any(Function), + [Permission.EnablePushNotifications]: true, + }) + expect(permissions[Permission.ExecuteTransaction]!({ safeTx: mockSafeTx })).toBe(true) + }) + }) + + describe('SpendingLimitBeneficiary', () => { + const mockSpendingLimits = new Array(3).fill(null).map(() => ({ + token: { address: faker.finance.ethereumAddress() }, + beneficiary: faker.finance.ethereumAddress(), + amount: faker.finance.amount({ min: 1000, max: 5000, dec: 0 }), + spent: faker.finance.amount({ min: 0, max: 1000, dec: 0 }), + })) as SpendingLimitState[] + + it('should return correct permissions', () => { + const permissions = rolePermissionsConfig[Role.SpendingLimitBeneficiary]!(mockCommonProps, { + spendingLimits: mockSpendingLimits, + }) + + expect(permissions).toEqual({ + [Permission.ExecuteTransaction]: expect.any(Function), + [Permission.EnablePushNotifications]: true, + [Permission.CreateSpendingLimitTransaction]: expect.any(Function), + }) + + expect(permissions[Permission.ExecuteTransaction]!({ safeTx: mockSafeTx })).toBe(true) + }) + + describe('CreateSpendingLimitTransaction', () => { + const tokenAddress = mockSpendingLimits[0].token.address + + it('should return `false` if no wallet connected', () => { + const permissions = rolePermissionsConfig[Role.SpendingLimitBeneficiary]!( + { safe: mockSafe, wallet: null }, + { spendingLimits: mockSpendingLimits }, + ) + + expect(permissions[Permission.CreateSpendingLimitTransaction]!({ tokenAddress })).toBe(false) + }) + + describe('without tokenAddress', () => { + it('should return `true` if any spending limit defined for connected wallet address', () => { + const permissions = rolePermissionsConfig[Role.SpendingLimitBeneficiary]!(mockCommonProps, { + spendingLimits: [ + ...mockSpendingLimits, + { + token: { address: faker.finance.ethereumAddress() }, + beneficiary: walletAddress, + amount: faker.finance.amount({ min: 1000, max: 5000, dec: 0 }), + spent: faker.finance.amount({ min: 0, max: 1000, dec: 0 }), + }, + ] as SpendingLimitState[], + }) + + expect(permissions[Permission.CreateSpendingLimitTransaction]!({})).toBe(true) + }) + + it('should return `false` if no spending limit defined for connected wallet address', () => { + const permissions = rolePermissionsConfig[Role.SpendingLimitBeneficiary]!(mockCommonProps, { + spendingLimits: mockSpendingLimits, + }) + + expect(permissions[Permission.CreateSpendingLimitTransaction]!({})).toBe(false) + }) + }) + + describe('with tokenAddress', () => { + it('should return `true` if a spending limit defined for token and connected wallet address', () => { + const permissions = rolePermissionsConfig[Role.SpendingLimitBeneficiary]!(mockCommonProps, { + spendingLimits: [ + ...mockSpendingLimits, + { + token: { address: tokenAddress }, + beneficiary: walletAddress, + amount: faker.finance.amount({ min: 1000, max: 5000, dec: 0 }), + spent: faker.finance.amount({ min: 0, max: 1000, dec: 0 }), + }, + ] as SpendingLimitState[], + }) + + expect(permissions[Permission.CreateSpendingLimitTransaction]!({ tokenAddress })).toBe(true) + }) + + it('should return `false` if no spending limit defined for token and connected wallet address', () => { + const permissions = rolePermissionsConfig[Role.SpendingLimitBeneficiary]!(mockCommonProps, { + spendingLimits: mockSpendingLimits, + }) + + expect(permissions[Permission.CreateSpendingLimitTransaction]!({ tokenAddress })).toBe(false) + }) + + it('should return `false` if the spending limit defined is reached', () => { + const mockAmount = faker.finance.amount({ min: 1000, max: 5000, dec: 0 }) + + const permissions = rolePermissionsConfig[Role.SpendingLimitBeneficiary]!(mockCommonProps, { + spendingLimits: [ + ...mockSpendingLimits, + { token: { address: tokenAddress }, beneficiary: walletAddress, amount: mockAmount, spent: mockAmount }, + ] as SpendingLimitState[], + }) + + expect(permissions[Permission.CreateSpendingLimitTransaction]!({ tokenAddress })).toBe(false) + }) + }) + }) + }) +}) diff --git a/apps/web/src/permissions/config.ts b/apps/web/src/permissions/config.ts index 61d6cbde16..4bcee690a3 100644 --- a/apps/web/src/permissions/config.ts +++ b/apps/web/src/permissions/config.ts @@ -1,45 +1,118 @@ -import type { RolePermissionsConfig } from './types' -import { Permission, Role } from './types' - -const { - CreateTransaction, - ProposeTransaction, - SignTransaction, - ExecuteTransaction, - EnablePushNotifications, - CreateSpendingLimitTransaction, -} = Permission +import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' +import type { ExtendedSafeInfo } from '@safe-global/store/slices/SafeInfo/types' +import type { SpendingLimitState } from '@/store/spendingLimitsSlice' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import type { SafeTransaction } from '@safe-global/types-kit' + +export enum Role { + Owner = 'Owner', + NestedOwner = 'NestedOwner', + Proposer = 'Proposer', + Executioner = 'Executioner', + ModuleRole = 'ModuleRole', + Recoverer = 'Recoverer', + SpendingLimitBeneficiary = 'SpendingLimitBeneficiary', + NoWalletConnected = 'NoWalletConnected', +} + +export enum Permission { + CreateTransaction = 'CreateTransaction', + ProposeTransaction = 'ProposeTransaction', + SignTransaction = 'SignTransaction', + ExecuteTransaction = 'ExecuteTransaction', + CreateSpendingLimitTransaction = 'CreateSpendingLimitTransaction', + EnablePushNotifications = 'EnablePushNotifications', +} + +/** + * RolePropsMap defines property types for specific roles. + * The props are used to specify conditional permission values for the respective role. + */ +export type RolePropsMap = { + [Role.SpendingLimitBeneficiary]: { + spendingLimits: SpendingLimitState[] + } +} + +// Extract the props for a specific role from RolePropsMap +export type RoleProps = R extends keyof RolePropsMap ? RolePropsMap[R] : undefined + +/** + * PermissionPropsMap defines property types for specific permissions. + * The props are used as inputs to evaluate permission functions. + */ +export type PermissionPropsMap = { + [Permission.ExecuteTransaction]: { safeTx: SafeTransaction } + [Permission.CreateSpendingLimitTransaction]: { tokenAddress?: string } | undefined +} + +// Extract the props for a specific permission from PermissionPropsMap +export type PermissionProps

= P extends keyof PermissionPropsMap + ? PermissionPropsMap[P] + : undefined + +// Define the type for a permission function that evaluates to a boolean +type PermissionFn

= + PermissionProps

extends undefined ? undefined : (args: PermissionProps

) => boolean + +// Define the type for a permission set that maps permissions to their values +export type PermissionSet = { + [P in Permission]?: PermissionFn

extends undefined ? boolean : PermissionFn

+} + +export type CommonProps = { + safe: ExtendedSafeInfo + wallet: ConnectedWallet | null +} + +export type RolePermissionsFn = + RoleProps extends undefined + ? (props: CommonProps) => PermissionSet + : (props: CommonProps, roleProps: RoleProps) => PermissionSet + +type RolePermissionsConfig = { + [R in Role]?: RolePermissionsFn +} /** * Defines the permissions for each role. */ export default { [Role.Owner]: () => ({ - [CreateTransaction]: true, - [ProposeTransaction]: true, - [SignTransaction]: true, - [ExecuteTransaction]: () => true, - [EnablePushNotifications]: true, + [Permission.CreateTransaction]: true, + [Permission.ProposeTransaction]: true, + [Permission.SignTransaction]: true, + [Permission.ExecuteTransaction]: () => true, + [Permission.EnablePushNotifications]: true, }), [Role.Proposer]: () => ({ - [CreateTransaction]: true, - [ProposeTransaction]: true, - [ExecuteTransaction]: () => true, - [EnablePushNotifications]: true, + [Permission.CreateTransaction]: true, + [Permission.ProposeTransaction]: true, + [Permission.ExecuteTransaction]: () => true, + [Permission.EnablePushNotifications]: true, }), [Role.Executioner]: () => ({ - [ExecuteTransaction]: () => true, - [EnablePushNotifications]: true, + [Permission.ExecuteTransaction]: () => true, + [Permission.EnablePushNotifications]: true, }), - [Role.SpendingLimitBeneficiary]: ({ spendingLimits }) => ({ - [ExecuteTransaction]: () => true, - [EnablePushNotifications]: true, - [CreateSpendingLimitTransaction]: ({ token } = {}) => { - if (!token) return false + [Role.SpendingLimitBeneficiary]: ({ wallet }, { spendingLimits }) => ({ + [Permission.ExecuteTransaction]: () => true, + [Permission.EnablePushNotifications]: true, + [Permission.CreateSpendingLimitTransaction]: ({ tokenAddress } = {}) => { + if (!wallet) return false + + if (!tokenAddress) { + // Check if the connected wallet has a spending limit for any token + return spendingLimits.some((sl) => sameAddress(sl.beneficiary, wallet.address)) + } - const spendingLimit = spendingLimits.find((sl) => sl.token.address === token.address) + // Check if the connected wallet has a spending limit for the given token + const spendingLimit = spendingLimits.find( + (sl) => sameAddress(sl.token.address, tokenAddress) && sameAddress(sl.beneficiary, wallet.address), + ) if (spendingLimit) { + // Check if the spending limit has not been reached return BigInt(spendingLimit.amount) - BigInt(spendingLimit.spent) > 0 } @@ -47,6 +120,6 @@ export default { }, }), [Role.NoWalletConnected]: () => ({ - [EnablePushNotifications]: false, + [Permission.EnablePushNotifications]: false, }), } diff --git a/apps/web/src/permissions/getRolePermissions.test.ts b/apps/web/src/permissions/getRolePermissions.test.ts index 57bd416710..b8134b3353 100644 --- a/apps/web/src/permissions/getRolePermissions.test.ts +++ b/apps/web/src/permissions/getRolePermissions.test.ts @@ -1,28 +1,51 @@ import type { SpendingLimitState } from '@/store/spendingLimitsSlice' import { getRolePermissions } from './getRolePermissions' -import { Role } from './types' +import { Role } from './config' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import { connectedWalletBuilder } from '@/tests/builders/wallet' +import { faker } from '@faker-js/faker' + +const safeAddress = faker.finance.ethereumAddress() +const walletAddress = faker.finance.ethereumAddress() jest.mock('./config', () => ({ - Owner: () => ({ - CreateTransaction: true, - ProposeTransaction: true, - SignTransaction: true, - ExecuteTransaction: () => true, - }), - Proposer: () => ({ - CreateTransaction: true, - ProposeTransaction: true, - ExecuteTransaction: () => true, - }), - SpendingLimitBeneficiary: ({ spendingLimits }: { spendingLimits: number[] }) => ({ - CreateTransaction: spendingLimits.includes(1), - ProposeTransaction: spendingLimits.includes(5), - }), + ...jest.requireActual('./config'), + __esModule: true, + default: { + Owner: () => ({ + CreateTransaction: true, + ProposeTransaction: true, + SignTransaction: true, + ExecuteTransaction: () => true, + }), + Proposer: () => ({ + CreateTransaction: true, + ProposeTransaction: true, + ExecuteTransaction: () => true, + }), + SpendingLimitBeneficiary: ( + { wallet }: { wallet: { address: string } }, + { spendingLimits }: { spendingLimits: number[] }, + ) => ({ + CreateTransaction: spendingLimits.includes(1) && wallet.address === walletAddress, + ProposeTransaction: spendingLimits.includes(5), + }), + }, })) describe('getRolePermissions', () => { it('should return the permissions for the given roles', () => { - const rolePermissions = getRolePermissions([Role.Owner, Role.Proposer], {}) + const rolePermissions = getRolePermissions( + [Role.Owner, Role.Proposer], + { + wallet: connectedWalletBuilder().with({ address: walletAddress }).build(), + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress }, owners: [{ value: walletAddress }] }) + .with({ deployed: false }) + .build(), + }, + {}, + ) expect(rolePermissions).toEqual({ Owner: { @@ -40,7 +63,17 @@ describe('getRolePermissions', () => { }) it('should ignore roles that do not have permissions defined', () => { - const rolePermissions = getRolePermissions([Role.Owner, Role.NestedOwner], {}) + const rolePermissions = getRolePermissions( + [Role.Owner, Role.NestedOwner], + { + wallet: connectedWalletBuilder().with({ address: walletAddress }).build(), + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress }, owners: [{ value: walletAddress }] }) + .with({ deployed: false }) + .build(), + }, + {}, + ) expect(rolePermissions).toEqual({ Owner: { @@ -53,9 +86,19 @@ describe('getRolePermissions', () => { }) it('should return the permissions for the given roles with the specific props', () => { - const rolePermissions = getRolePermissions([Role.SpendingLimitBeneficiary], { - SpendingLimitBeneficiary: { spendingLimits: [1, 2, 3] as unknown as SpendingLimitState[] }, - }) + const rolePermissions = getRolePermissions( + [Role.SpendingLimitBeneficiary], + { + wallet: connectedWalletBuilder().with({ address: walletAddress }).build(), + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress }, owners: [{ value: walletAddress }] }) + .with({ deployed: false }) + .build(), + }, + { + SpendingLimitBeneficiary: { spendingLimits: [1, 2, 3] as unknown as SpendingLimitState[] }, + }, + ) expect(rolePermissions).toEqual({ SpendingLimitBeneficiary: { @@ -66,12 +109,32 @@ describe('getRolePermissions', () => { }) it('should return an empty object if no permissions are defined for the roles', () => { - const rolePermissions = getRolePermissions([Role.NestedOwner, Role.Recoverer], {}) + const rolePermissions = getRolePermissions( + [Role.NestedOwner, Role.Recoverer], + { + wallet: connectedWalletBuilder().with({ address: walletAddress }).build(), + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress }, owners: [{ value: walletAddress }] }) + .with({ deployed: false }) + .build(), + }, + {}, + ) expect(rolePermissions).toEqual({}) }) it('should return an empty object if being called with an empty roles array', () => { - const rolePermissions = getRolePermissions([], {}) + const rolePermissions = getRolePermissions( + [], + { + wallet: connectedWalletBuilder().with({ address: walletAddress }).build(), + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress }, owners: [{ value: walletAddress }] }) + .with({ deployed: false }) + .build(), + }, + {}, + ) expect(rolePermissions).toEqual({}) }) }) diff --git a/apps/web/src/permissions/getRolePermissions.ts b/apps/web/src/permissions/getRolePermissions.ts index 4c1c0aaebd..0919c16300 100644 --- a/apps/web/src/permissions/getRolePermissions.ts +++ b/apps/web/src/permissions/getRolePermissions.ts @@ -1,13 +1,18 @@ import rolePermissionConfig from './config' -import type { PermissionSet, Role, RoleProps } from './types' +import type { CommonProps, PermissionSet, Role, RoleProps } from './config' /** * Get the PermissionSet for multiple roles with the given role props object. * @param roles Roles to get permissions for - * @param props Object with specific parameters for the roles + * @param props Common props used to evaluate the permissions + * @param roleProps Object with specific parameters for the roles * @returns Object with PermissionSet for each of the give roles that has permissions defined */ -export const getRolePermissions = (roles: R[], props: { [K in R]?: RoleProps }) => +export const getRolePermissions = ( + roles: R[], + props: CommonProps, + roleProps: { [K in R]?: RoleProps }, +) => roles.reduce<{ [_K in R]?: PermissionSet }>((acc, role) => { const rolePermissionsFn = rolePermissionConfig[role] @@ -15,5 +20,5 @@ export const getRolePermissions = (roles: R[], props: { [K in R] return acc } - return { ...acc, [role]: rolePermissionsFn(props[role] as RoleProps) } + return { ...acc, [role]: rolePermissionsFn(props, roleProps[role] as RoleProps) } }, {}) diff --git a/apps/web/src/permissions/hoc/withPermission.test.tsx b/apps/web/src/permissions/hoc/withPermission.test.tsx index cdd712efcc..f33156594f 100644 --- a/apps/web/src/permissions/hoc/withPermission.test.tsx +++ b/apps/web/src/permissions/hoc/withPermission.test.tsx @@ -2,7 +2,7 @@ import React from 'react' import { render } from '@/tests/test-utils' import { withPermission } from './withPermission' import * as useHasPermission from '../hooks/useHasPermission' -import { Permission } from '../types' +import { Permission } from '../config' describe('withPermission', () => { const useHasPermissionSpy = jest.spyOn(useHasPermission, 'useHasPermission') diff --git a/apps/web/src/permissions/hoc/withPermission.tsx b/apps/web/src/permissions/hoc/withPermission.tsx index 599ec02e2e..7c5e8cb2ef 100644 --- a/apps/web/src/permissions/hoc/withPermission.tsx +++ b/apps/web/src/permissions/hoc/withPermission.tsx @@ -1,4 +1,4 @@ -import type { Permission, PermissionProps } from '../types' +import type { Permission, PermissionProps } from '../config' import { useHasPermission } from '../hooks/useHasPermission' type WrappingComponentProps< diff --git a/apps/web/src/permissions/hoc/withRole.test.tsx b/apps/web/src/permissions/hoc/withRole.test.tsx index bb0cec966d..1ba3fc23d9 100644 --- a/apps/web/src/permissions/hoc/withRole.test.tsx +++ b/apps/web/src/permissions/hoc/withRole.test.tsx @@ -2,7 +2,7 @@ import React from 'react' import { render } from '@/tests/test-utils' import { withRole } from './withRole' import * as useHasRoles from '../hooks/useHasRoles' -import { Role } from '../types' +import { Role } from '../config' describe('withRole', () => { const useHasRolesSpy = jest.spyOn(useHasRoles, 'useHasRoles') diff --git a/apps/web/src/permissions/hoc/withRole.tsx b/apps/web/src/permissions/hoc/withRole.tsx index 07de04a1f9..9ed2dc5810 100644 --- a/apps/web/src/permissions/hoc/withRole.tsx +++ b/apps/web/src/permissions/hoc/withRole.tsx @@ -1,4 +1,4 @@ -import type { Role } from '../types' +import type { Role } from '../config' import { useHasRoles } from '../hooks/useHasRoles' type WrappingComponentProps> = React.ComponentProps & { diff --git a/apps/web/src/permissions/hooks/useHasPermission.test.tsx b/apps/web/src/permissions/hooks/useHasPermission.test.tsx index fe6187f11b..63ee723b93 100644 --- a/apps/web/src/permissions/hooks/useHasPermission.test.tsx +++ b/apps/web/src/permissions/hooks/useHasPermission.test.tsx @@ -1,7 +1,7 @@ import { renderHook } from '@/tests/test-utils' import { useHasPermission } from './useHasPermission' import * as usePermission from './usePermission' -import { Permission, Role } from '../types' +import { Permission, Role } from '../config' jest.mock('./usePermission') diff --git a/apps/web/src/permissions/hooks/useHasPermission.ts b/apps/web/src/permissions/hooks/useHasPermission.ts index 81901169d5..034741a241 100644 --- a/apps/web/src/permissions/hooks/useHasPermission.ts +++ b/apps/web/src/permissions/hooks/useHasPermission.ts @@ -1,4 +1,4 @@ -import type { Permission, PermissionProps } from '../types' +import type { Permission, PermissionProps } from '../config' import { usePermission } from './usePermission' /** diff --git a/apps/web/src/permissions/hooks/useHasRoles.test.tsx b/apps/web/src/permissions/hooks/useHasRoles.test.tsx index 9eea1457b1..b6307dab51 100644 --- a/apps/web/src/permissions/hooks/useHasRoles.test.tsx +++ b/apps/web/src/permissions/hooks/useHasRoles.test.tsx @@ -1,7 +1,7 @@ import { renderHook } from '@/tests/test-utils' import { useHasRoles } from './useHasRoles' import * as useRoles from './useRoles' -import { Role } from '../types' +import { Role } from '../config' describe('useHasRoles', () => { const useRolesSpy = jest.spyOn(useRoles, 'useRoles') diff --git a/apps/web/src/permissions/hooks/useHasRoles.ts b/apps/web/src/permissions/hooks/useHasRoles.ts index 507c3b98a5..665b197dae 100644 --- a/apps/web/src/permissions/hooks/useHasRoles.ts +++ b/apps/web/src/permissions/hooks/useHasRoles.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' -import type { Role } from '../types' import { useRoles } from './useRoles' import { intersection, uniq } from 'lodash' +import type { Role } from '../config' /** * Hook to check if the current user has the given roles. diff --git a/apps/web/src/permissions/hooks/usePermission.test.ts b/apps/web/src/permissions/hooks/usePermission.test.ts index 80e49cc1f4..319fd1a141 100644 --- a/apps/web/src/permissions/hooks/usePermission.test.ts +++ b/apps/web/src/permissions/hooks/usePermission.test.ts @@ -1,15 +1,21 @@ import { renderHook } from '@/tests/test-utils' import { usePermission } from './usePermission' -import { Permission, Role } from '../types' import * as useRoles from './useRoles' import * as useRoleProps from './useRoleProps' import type { SpendingLimitState } from '@/store/spendingLimitsSlice' import * as getRolePermissions from '../getRolePermissions' +import * as useSafeInfo from '@/hooks/useSafeInfo' +import * as useWallet from '@/hooks/wallets/useWallet' +import { Permission, Role } from '../config' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import { faker } from '@faker-js/faker' describe('usePermission', () => { const useRolesSpy = jest.spyOn(useRoles, 'useRoles') const useRolePropsSpy = jest.spyOn(useRoleProps, 'useRoleProps') const getRolePermissionsSpy = jest.spyOn(getRolePermissions, 'getRolePermissions') + const useSafeInfoSpy = jest.spyOn(useSafeInfo, 'default') + const useWalletSpy = jest.spyOn(useWallet, 'default') const mockSpendingLimits = [{ limit: 1000 }, { limit: 2000 }] as unknown as SpendingLimitState[] const mockRoles = [Role.Owner, Role.Proposer, Role.Recoverer, Role.SpendingLimitBeneficiary] @@ -28,10 +34,29 @@ describe('usePermission', () => { }, } + const safeAddress = faker.finance.ethereumAddress() + const walletAddress = faker.finance.ethereumAddress() + + const mockSafe = extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: true }) + .build() + + const mockWallet = { + address: walletAddress, + } as ReturnType + beforeEach(() => { useRolesSpy.mockReturnValue(mockRoles) useRolePropsSpy.mockReturnValue(mockRoleProps) getRolePermissionsSpy.mockReturnValue(mockRolePermissions) + + useSafeInfoSpy.mockReturnValue({ + safeAddress, + safe: mockSafe, + } as unknown as ReturnType) + + useWalletSpy.mockReturnValue(mockWallet) }) afterEach(() => { @@ -46,7 +71,9 @@ describe('usePermission', () => { expect(useRolesSpy).toHaveBeenCalledTimes(1) expect(useRolePropsSpy).toHaveBeenCalledTimes(1) expect(getRolePermissionsSpy).toHaveBeenCalledTimes(1) - expect(getRolePermissionsSpy).toHaveBeenCalledWith(mockRoles, mockRoleProps) + expect(getRolePermissionsSpy).toHaveBeenCalledWith(mockRoles, { safe: mockSafe, wallet: mockWallet }, mockRoleProps) + expect(useSafeInfoSpy).toHaveBeenCalledTimes(1) + expect(useWalletSpy).toHaveBeenCalledTimes(1) }) it('should return correct permission value for each role when the permission is a function', () => { @@ -57,7 +84,9 @@ describe('usePermission', () => { expect(useRolesSpy).toHaveBeenCalledTimes(1) expect(useRolePropsSpy).toHaveBeenCalledTimes(1) expect(getRolePermissionsSpy).toHaveBeenCalledTimes(1) - expect(getRolePermissionsSpy).toHaveBeenCalledWith(mockRoles, mockRoleProps) + expect(getRolePermissionsSpy).toHaveBeenCalledWith(mockRoles, { safe: mockSafe, wallet: mockWallet }, mockRoleProps) + expect(useSafeInfoSpy).toHaveBeenCalledTimes(1) + expect(useWalletSpy).toHaveBeenCalledTimes(1) }) it("should return empty object when permission is defined for none of the user's role", () => { @@ -78,7 +107,13 @@ describe('usePermission', () => { expect(useRolesSpy).toHaveBeenCalledTimes(1) expect(useRolePropsSpy).toHaveBeenCalledTimes(1) expect(getRolePermissionsSpy).toHaveBeenCalledTimes(1) - expect(getRolePermissionsSpy).toHaveBeenCalledWith([Role.Proposer, Role.Recoverer], mockRoleProps) + expect(getRolePermissionsSpy).toHaveBeenCalledWith( + [Role.Proposer, Role.Recoverer], + { safe: mockSafe, wallet: mockWallet }, + mockRoleProps, + ) + expect(useSafeInfoSpy).toHaveBeenCalledTimes(1) + expect(useWalletSpy).toHaveBeenCalledTimes(1) }) it('should return empty object when no roles are defined', () => { @@ -92,6 +127,8 @@ describe('usePermission', () => { expect(useRolesSpy).toHaveBeenCalledTimes(1) expect(useRolePropsSpy).toHaveBeenCalledTimes(1) expect(getRolePermissionsSpy).toHaveBeenCalledTimes(1) - expect(getRolePermissionsSpy).toHaveBeenCalledWith([], mockRoleProps) + expect(getRolePermissionsSpy).toHaveBeenCalledWith([], { safe: mockSafe, wallet: mockWallet }, mockRoleProps) + expect(useSafeInfoSpy).toHaveBeenCalledTimes(1) + expect(useWalletSpy).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/web/src/permissions/hooks/usePermission.ts b/apps/web/src/permissions/hooks/usePermission.ts index 7ff674a06b..0bc3d5525d 100644 --- a/apps/web/src/permissions/hooks/usePermission.ts +++ b/apps/web/src/permissions/hooks/usePermission.ts @@ -1,8 +1,10 @@ import { useMemo } from 'react' -import type { Permission, PermissionProps, Role } from '../types' import { useRoles } from './useRoles' import { useRoleProps } from './useRoleProps' import { getRolePermissions } from '../getRolePermissions' +import type { Permission, Role, PermissionProps } from '../config' +import useSafeInfo from '@/hooks/useSafeInfo' +import useWallet from '@/hooks/wallets/useWallet' /** * Hook to get the result of a permission check for the current user based on the Safe and the connected wallet. @@ -16,10 +18,12 @@ export const usePermission =

( ): { [_R in Role]?: boolean } => { const userRoles = useRoles() const roleProps = useRoleProps() + const { safe } = useSafeInfo() + const wallet = useWallet() const userPermissions = useMemo(() => { - return getRolePermissions(userRoles, roleProps) - }, [userRoles, roleProps]) + return getRolePermissions(userRoles, { safe, wallet }, roleProps) + }, [userRoles, roleProps, safe, wallet]) const permissionPerRole = useMemo(() => { return Object.entries(userPermissions).reduce((acc, [role, permissions]) => { diff --git a/apps/web/src/permissions/hooks/useRoleProps.test.tsx b/apps/web/src/permissions/hooks/useRoleProps.test.tsx index 881d17d5d0..a73e23ece2 100644 --- a/apps/web/src/permissions/hooks/useRoleProps.test.tsx +++ b/apps/web/src/permissions/hooks/useRoleProps.test.tsx @@ -1,7 +1,7 @@ import { renderHook } from '@/tests/test-utils' import * as reactRedux from 'react-redux' import { useRoleProps } from './useRoleProps' -import { Role } from '../types' +import { Role } from '../config' import * as spendingLimitsSlice from '@/store/spendingLimitsSlice' describe('useRoleProps', () => { diff --git a/apps/web/src/permissions/hooks/useRoleProps.ts b/apps/web/src/permissions/hooks/useRoleProps.ts index 63bc84548e..008a3dd977 100644 --- a/apps/web/src/permissions/hooks/useRoleProps.ts +++ b/apps/web/src/permissions/hooks/useRoleProps.ts @@ -1,7 +1,6 @@ import { useSelector } from 'react-redux' import { selectSpendingLimits } from '@/store/spendingLimitsSlice' -import type { RolePropsMap } from '../types' -import { Role } from '../types' +import { type RolePropsMap, Role } from '../config' /** * Hook to get the props for each role based on the current state of the application. diff --git a/apps/web/src/permissions/hooks/useRoles.test.tsx b/apps/web/src/permissions/hooks/useRoles.test.tsx index 4c6b4f71c5..8a4fea9e64 100644 --- a/apps/web/src/permissions/hooks/useRoles.test.tsx +++ b/apps/web/src/permissions/hooks/useRoles.test.tsx @@ -1,6 +1,6 @@ import { renderHook } from '@/tests/test-utils' import { useRoles } from './useRoles' -import { Role } from '../types' +import { Role } from '../config' import * as useWallet from '@/hooks/wallets/useWallet' import * as useIsSafeOwner from '@/hooks/useIsSafeOwner' import * as useIsNestedSafeOwner from '@/hooks/useIsNestedSafeOwner' diff --git a/apps/web/src/permissions/hooks/useRoles.ts b/apps/web/src/permissions/hooks/useRoles.ts index 5ce4098f5f..6ff0690f33 100644 --- a/apps/web/src/permissions/hooks/useRoles.ts +++ b/apps/web/src/permissions/hooks/useRoles.ts @@ -4,7 +4,7 @@ import { useIsWalletProposer } from '@/hooks/useProposers' import { useIsRecoverer } from '@/features/recovery/hooks/useIsRecoverer' import { useIsSpendingLimitBeneficiary } from '@/hooks/useIsOnlySpendingLimitBeneficiary' import useWallet from '@/hooks/wallets/useWallet' -import { Role } from '../types' +import { Role } from '../config' import { useIsNestedSafeOwner } from '@/hooks/useIsNestedSafeOwner' /** diff --git a/apps/web/src/permissions/types/Permission.ts b/apps/web/src/permissions/types/Permission.ts deleted file mode 100644 index 44a74cb6c3..0000000000 --- a/apps/web/src/permissions/types/Permission.ts +++ /dev/null @@ -1,8 +0,0 @@ -export enum Permission { - CreateTransaction = 'CreateTransaction', - ProposeTransaction = 'ProposeTransaction', - SignTransaction = 'SignTransaction', - ExecuteTransaction = 'ExecuteTransaction', - CreateSpendingLimitTransaction = 'CreateSpendingLimitTransaction', - EnablePushNotifications = 'EnablePushNotifications', -} diff --git a/apps/web/src/permissions/types/PermissionProps.ts b/apps/web/src/permissions/types/PermissionProps.ts deleted file mode 100644 index a9edd0265b..0000000000 --- a/apps/web/src/permissions/types/PermissionProps.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import type { Permission } from './Permission' -import type { TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' - -/** - * PermissionPropsMap defines property types for specific permissions. - * The props are used as inputs to evaluate permission functions. - */ -export type PermissionPropsMap = { - [Permission.ExecuteTransaction]: { safeTx: SafeTransaction } - [Permission.CreateSpendingLimitTransaction]: { token?: TokenInfo } | undefined -} - -// Extract the props for a specific permission from PermissionPropsMap -export type PermissionProps

= P extends keyof PermissionPropsMap - ? PermissionPropsMap[P] - : undefined diff --git a/apps/web/src/permissions/types/PermissionSet.ts b/apps/web/src/permissions/types/PermissionSet.ts deleted file mode 100644 index d335aced8c..0000000000 --- a/apps/web/src/permissions/types/PermissionSet.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Permission } from './Permission' -import type { PermissionProps } from './PermissionProps' - -// Define the type for a permission function that evaluates to a boolean -type PermissionFn

= - PermissionProps

extends undefined ? undefined : (args: PermissionProps

) => boolean - -// Define the type for a permission set that maps permissions to their values -export type PermissionSet = { - [P in Permission]?: PermissionFn

extends undefined ? boolean : PermissionFn

-} diff --git a/apps/web/src/permissions/types/Role.ts b/apps/web/src/permissions/types/Role.ts deleted file mode 100644 index 6921ceb332..0000000000 --- a/apps/web/src/permissions/types/Role.ts +++ /dev/null @@ -1,10 +0,0 @@ -export enum Role { - Owner = 'Owner', - NestedOwner = 'NestedOwner', - Proposer = 'Proposer', - Executioner = 'Executioner', - ModuleRole = 'ModuleRole', - Recoverer = 'Recoverer', - SpendingLimitBeneficiary = 'SpendingLimitBeneficiary', - NoWalletConnected = 'NoWalletConnected', -} diff --git a/apps/web/src/permissions/types/RoleProps.ts b/apps/web/src/permissions/types/RoleProps.ts deleted file mode 100644 index 49a2afa25f..0000000000 --- a/apps/web/src/permissions/types/RoleProps.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { SpendingLimitState } from '@/store/spendingLimitsSlice' -import type { Role } from './Role' - -/** - * RolePropsMap defines property types for specific roles. - * The props are used to specify conditional permission values for the respective role. - */ -export type RolePropsMap = { - [Role.SpendingLimitBeneficiary]: { - spendingLimits: SpendingLimitState[] - } -} - -// Extract the props for a specific role from RolePropsMap -export type RoleProps = R extends keyof RolePropsMap ? RolePropsMap[R] : undefined diff --git a/apps/web/src/permissions/types/index.ts b/apps/web/src/permissions/types/index.ts deleted file mode 100644 index 2b25c537f2..0000000000 --- a/apps/web/src/permissions/types/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { PermissionSet } from './PermissionSet' -import type { Role } from './Role' -import type { RoleProps } from './RoleProps' - -export * from './Role' -export * from './Permission' -export * from './PermissionProps' -export * from './PermissionSet' -export * from './RoleProps' - -export type RolePermissionsFn = - RoleProps extends undefined ? () => PermissionSet : (props: RoleProps) => PermissionSet - -export type RolePermissionsConfig = { - [R in Role]?: RolePermissionsFn -} diff --git a/apps/web/src/service-workers/firebase-messaging/notification-mapper.ts b/apps/web/src/service-workers/firebase-messaging/notification-mapper.ts index 1a5b0ad4d3..89a529a5c0 100644 --- a/apps/web/src/service-workers/firebase-messaging/notification-mapper.ts +++ b/apps/web/src/service-workers/firebase-messaging/notification-mapper.ts @@ -54,7 +54,8 @@ const getTokenInfo = async ( } const symbol = tokenInfo?.symbol ?? DEFAULT_INFO.symbol - const value = tokenValue && tokenInfo ? formatUnits(tokenValue, tokenInfo.decimals).toString() : DEFAULT_INFO.value + const value = + tokenValue && tokenInfo ? formatUnits(tokenValue, tokenInfo.decimals ?? 0).toString() : DEFAULT_INFO.value const name = tokenInfo?.name ?? DEFAULT_INFO.name return { diff --git a/apps/web/src/services/analytics/Analytics.tsx b/apps/web/src/services/analytics/Analytics.tsx new file mode 100644 index 0000000000..a8f74cef10 --- /dev/null +++ b/apps/web/src/services/analytics/Analytics.tsx @@ -0,0 +1,23 @@ +import { GA_TRACKING_ID, IS_PRODUCTION, SAFE_APPS_GA_TRACKING_ID } from '@/config/constants' +import { GoogleAnalytics } from '@next/third-parties/google' +import { useEffect } from 'react' + +const Analytics = () => { + useEffect(() => { + // This needs to be added once in order for events with send_to: SAFE_APPS_GA_TRACKING_ID to work + window.gtag?.('config', SAFE_APPS_GA_TRACKING_ID, { debug_mode: !IS_PRODUCTION }) + + window.gtag?.('consent', 'default', { + ad_storage: 'denied', + analytics_storage: 'denied', + functionality_storage: 'granted', + personalization_storage: 'denied', + security_storage: 'granted', + wait_for_update: 500, + }) + }, []) + + return +} + +export default Analytics diff --git a/apps/web/src/services/analytics/TagManager.ts b/apps/web/src/services/analytics/TagManager.ts deleted file mode 100644 index 254539274e..0000000000 --- a/apps/web/src/services/analytics/TagManager.ts +++ /dev/null @@ -1,117 +0,0 @@ -import Cookies from 'js-cookie' - -import { IS_PRODUCTION } from '@/config/constants' - -export type TagManagerArgs = { - // GTM id, e.g. GTM-000000 - gtmId: string - // GTM authentication key - auth: string - // GTM environment, e.g. env-00. - preview: string -} - -const DATA_LAYER_NAME = 'dataLayer' - -const TagManager = { - // `jest.spyOn` is not possible if outside of `TagManager` - _getScript: ({ gtmId, auth, preview }: TagManagerArgs) => { - const script = document.createElement('script') - - const gtmScript = ` - (function (w, d, s, l, i) { - w[l] = w[l] || []; - w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }); - var f = d.getElementsByTagName(s)[0], - j = d.createElement(s), - dl = l != 'dataLayer' ? '&l=' + l : ''; - j.async = true; - j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl + '>m_auth=${auth}>m_preview=${preview}>m_cookies_win=x'; - f.parentNode.insertBefore(j, f); - })(window, document, 'script', '${DATA_LAYER_NAME}', '${gtmId}');` - - script.innerHTML = gtmScript - - return script - }, - - dataLayer: (data: Record) => { - window[DATA_LAYER_NAME] = window[DATA_LAYER_NAME] || [] - window[DATA_LAYER_NAME].push(data) - - if (!IS_PRODUCTION) { - console.info('[GTM] -', data) - } - }, - - initialize: (args: TagManagerArgs) => { - window[DATA_LAYER_NAME] = window[DATA_LAYER_NAME] || [] - // This function MUST be in `window`, otherwise GTM Consent Mode just doesn't work - window.gtag = function () { - window[DATA_LAYER_NAME]?.push(arguments) - } - - // Consent mode - window.gtag('consent', 'default', { - ad_storage: 'denied', - analytics_storage: 'denied', - functionality_storage: 'granted', - personalization_storage: 'denied', - security_storage: 'granted', - wait_for_update: 500, - }) - - TagManager.dataLayer({ - // Block JS variables and custom scripts - // @see https://developers.google.com/tag-platform/tag-manager/web/restrict - 'gtm.blocklist': ['j', 'jsm', 'customScripts'], - pageLocation: `${location.origin}${location.pathname}`, - pagePath: location.pathname, - }) - - const script = TagManager._getScript(args) - - // Initialize GTM. This pushes the default dataLayer event: - // { "gtm.start": new Date().getTime(), event: "gtm.js" } - document.head.insertBefore(script, document.head.childNodes[0]) - }, - - enableCookies: () => { - window.gtag?.('consent', 'update', { - analytics_storage: 'granted', - }) - }, - - disableCookies: () => { - window.gtag?.('consent', 'update', { - analytics_storage: 'denied', - }) - - const GA_COOKIE_LIST = ['_ga', '_gat', '_gid'] - const GA_PREFIX = '_ga_' - const allCookies = document.cookie.split(';').map((cookie) => cookie.split('=')[0].trim()) - const gaCookies = allCookies.filter((cookie) => cookie.startsWith(GA_PREFIX)) - - GA_COOKIE_LIST.concat(gaCookies).forEach((cookie) => { - Cookies.remove(cookie, { - path: '/', - domain: `.${location.host.split('.').slice(-2).join('.')}`, - }) - }) - - // Injected script will remain in memory until new session - location.reload() - }, - - setUserProperty: (name: string, value: string) => { - window.gtag?.('set', 'user_properties', { - [name]: value, - }) - - if (!IS_PRODUCTION) { - console.info('[GTM] -', 'set user_properties', name, '=', value) - } - }, -} - -export default TagManager diff --git a/apps/web/src/services/analytics/__tests__/TagManager.test.ts b/apps/web/src/services/analytics/__tests__/TagManager.test.ts deleted file mode 100644 index 27204fda1d..0000000000 --- a/apps/web/src/services/analytics/__tests__/TagManager.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import Cookies from 'js-cookie' - -import * as gtm from '../TagManager' -import { AnalyticsUserProperties } from '../types' - -const { default: TagManager } = gtm - -const MOCK_ID = 'GTM-123456' -const MOCK_AUTH = 'key123' -const MOCK_PREVIEW = 'env-0' - -jest.mock('js-cookie', () => ({ - remove: jest.fn(), -})) - -describe('TagManager', () => { - const originalLocation = window.location - - // Mock `location.reload` - beforeAll(() => { - Object.defineProperty(window, 'location', { - configurable: true, - value: { - ...originalLocation, - reload: jest.fn(), - }, - }) - }) - - // Remove mock - afterAll(() => { - Object.defineProperty(window, 'location', { - configurable: true, - value: originalLocation, - }) - }) - - // Clear GTM between tests - afterEach(() => { - document.head.innerHTML = '' - delete window.dataLayer - }) - - describe('TagManager._getScript', () => { - it('should use the id, auth and preview', () => { - const script1 = TagManager._getScript({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) - - expect(script1.innerHTML).toContain(MOCK_ID) - expect(script1.innerHTML).toContain(`>m_auth=${MOCK_AUTH}`) - expect(script1.innerHTML).toContain(`>m_preview=${MOCK_PREVIEW}`) - expect(script1.innerHTML).toContain('dataLayer') - }) - }) - - describe('TagManager.initialize', () => { - it('should initialize TagManager', () => { - TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) - - expect(document.head.childNodes).toHaveLength(2) - - // Script added by `TagManager._getScript` - // @ts-expect-error - expect(document.head.childNodes[0].src).toBe( - `https://www.googletagmanager.com/gtm.js?id=${MOCK_ID}>m_auth=${MOCK_AUTH}>m_preview=${MOCK_PREVIEW}>m_cookies_win=x`, - ) - - // Manually added script - expect(document.head.childNodes[1]).toStrictEqual( - TagManager._getScript({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }), - ) - - expect(window.dataLayer).toHaveLength(3) - expect(window.dataLayer?.[0][0]).toBe('consent') - expect(window.dataLayer?.[0][1]).toBe('default') - expect(window.dataLayer?.[0][2]).toStrictEqual({ - ad_storage: 'denied', - analytics_storage: 'denied', - functionality_storage: 'granted', - personalization_storage: 'denied', - security_storage: 'granted', - wait_for_update: 500, - }) - expect(window.dataLayer?.[1]).toStrictEqual({ - 'gtm.blocklist': ['j', 'jsm', 'customScripts'], - pageLocation: 'http://localhost/balances', - pagePath: '/balances', - }) - expect(window.dataLayer?.[2]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) }) - }) - }) - - describe('TagManager.dataLayer', () => { - it('should push data to the dataLayer', () => { - expect(window.dataLayer).toBeUndefined() - - TagManager.initialize({ - gtmId: MOCK_ID, - auth: MOCK_AUTH, - preview: MOCK_PREVIEW, - }) - - expect(window.dataLayer).toHaveLength(3) - - TagManager.dataLayer({ - test: '123', - }) - - expect(window.dataLayer).toHaveLength(4) - expect(window.dataLayer?.[3]).toStrictEqual({ test: '123' }) - }) - }) - - describe('TagManager.disable', () => { - it('should remove GA cookies and reload', () => { - TagManager.initialize({ - gtmId: MOCK_ID, - auth: MOCK_AUTH, - preview: MOCK_PREVIEW, - }) - - document.cookie = '_ga=GA123;' - document.cookie = '_ga_JB9NXCRJ0G=GS123;' - document.cookie = '_gat=GA123;' - document.cookie = '_gid=GI123;' - - TagManager.disableCookies() - - const path = '/' - const domain = '.localhost' - - expect(Cookies.remove).toHaveBeenCalledWith('_ga', { path, domain }) - expect(Cookies.remove).toHaveBeenCalledWith('_ga_JB9NXCRJ0G', { path, domain }) - expect(Cookies.remove).toHaveBeenCalledWith('_gat', { path, domain }) - expect(Cookies.remove).toHaveBeenCalledWith('_gid', { path, domain }) - - expect(global.location.reload).toHaveBeenCalled() - }) - }) - - describe('TagManager.setUserProperty', () => { - it('should push new user properties to dataLayer', () => { - expect(window.dataLayer).toBeUndefined() - - TagManager.initialize({ - gtmId: MOCK_ID, - auth: MOCK_AUTH, - preview: MOCK_PREVIEW, - }) - - expect(window.dataLayer).toHaveLength(3) - - TagManager.setUserProperty(AnalyticsUserProperties.WALLET_LABEL, 'Boba Safe') - - expect(window.dataLayer).toHaveLength(4) - - expect(Array.from(window.dataLayer?.[3])).toEqual([ - 'set', - 'user_properties', - { [AnalyticsUserProperties.WALLET_LABEL]: 'Boba Safe' }, - ]) - }) - }) -}) diff --git a/apps/web/src/services/analytics/__tests__/gtm.test.ts b/apps/web/src/services/analytics/__tests__/gtm.test.ts index 5381ec244c..3d3f150c2e 100644 --- a/apps/web/src/services/analytics/__tests__/gtm.test.ts +++ b/apps/web/src/services/analytics/__tests__/gtm.test.ts @@ -1,14 +1,9 @@ +import GA from '@next/third-parties/google' import * as gtm from '../gtm' -import TagManager from '../TagManager' import { EventType, DeviceType } from '../types' -// Mock dependencies -jest.mock('../TagManager', () => ({ - initialize: jest.fn(), - dataLayer: jest.fn(), - enableCookies: jest.fn(), - disableCookies: jest.fn(), - setUserProperty: jest.fn(), +jest.mock('@next/third-parties/google', () => ({ + sendGAEvent: jest.fn(), })) describe('gtm', () => { @@ -29,7 +24,9 @@ describe('gtm', () => { gtm.gtmTrack(mockEventData) - expect(TagManager.dataLayer).toHaveBeenCalledWith( + expect(GA.sendGAEvent).toHaveBeenCalledWith( + 'event', + 'customClick', expect.objectContaining({ event: mockEventData.event, eventCategory: mockEventData.category, @@ -55,7 +52,9 @@ describe('gtm', () => { gtm.gtmTrack(mockEventData) - expect(TagManager.dataLayer).toHaveBeenCalledWith( + expect(GA.sendGAEvent).toHaveBeenCalledWith( + 'event', + 'customClick', expect.objectContaining({ event: mockEventData.event, eventCategory: mockEventData.category, @@ -95,7 +94,9 @@ describe('gtm', () => { gtm.gtmTrackSafeApp(mockEventData, mockAppName, mockSdkEventData) - expect(TagManager.dataLayer).toHaveBeenCalledWith( + expect(GA.sendGAEvent).toHaveBeenCalledWith( + 'event', + 'safeAppEvent', expect.objectContaining({ appVersion: expect.any(String), chainId: expect.any(String), diff --git a/apps/web/src/services/analytics/__tests__/mixpanel.test.ts b/apps/web/src/services/analytics/__tests__/mixpanel.test.ts new file mode 100644 index 0000000000..627fdd1ec1 --- /dev/null +++ b/apps/web/src/services/analytics/__tests__/mixpanel.test.ts @@ -0,0 +1,226 @@ +// Mock constants before any imports +jest.mock('@/config/constants', () => ({ + ...jest.requireActual('@/config/constants'), + MIXPANEL_TOKEN: 'test-token', + IS_PRODUCTION: false, +})) + +import { + trackEvent, + trackMixPanelEvent, + MixPanelEvent, + safeAppToMixPanelEventProperties, + SafeAppLaunchLocation, +} from '../index' +import { mixpanelInit, mixpanelTrack, mixpanelSetSafeAddress } from '../mixpanel' +import packageJson from '../../../../package.json' + +// Mock GTM +jest.mock('../gtm', () => ({ + gtmTrack: jest.fn(), + gtmTrackSafeApp: jest.fn(), +})) + +// Mock mixpanel-browser +jest.mock('mixpanel-browser', () => ({ + init: jest.fn(), + track: jest.fn(), + identify: jest.fn(), + register: jest.fn(), + reset: jest.fn(), + opt_in_tracking: jest.fn(), + opt_out_tracking: jest.fn(), + has_opted_in_tracking: jest.fn().mockReturnValue(true), + people: { + set: jest.fn(), + set_once: jest.fn(), + increment: jest.fn(), + append: jest.fn(), + union: jest.fn(), + }, +})) + +const mockMixpanel = jest.requireMock('mixpanel-browser') +const mockGtm = jest.requireMock('../gtm') + +describe('MixPanel Integration', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('MixPanel initialization', () => { + it('should initialize MixPanel with correct configuration', () => { + mixpanelInit() + + expect(mockMixpanel.init).toHaveBeenCalledWith('test-token', { + debug: true, // IS_PRODUCTION is false in tests + persistence: 'localStorage', + autocapture: false, + batch_requests: true, + ip: false, + opt_out_tracking_by_default: true, + }) + + // Should register initial params + expect(mockMixpanel.register).toHaveBeenCalledWith({ + 'App Version': packageJson.version, + 'Device Type': 'desktop', + }) + }) + }) + + describe('Event tracking', () => { + it('should track events with MixPanel when initialized', () => { + mixpanelInit() + + mixpanelTrack(MixPanelEvent.SAFE_APP_LAUNCHED, { + 'Safe App Name': 'Test App', + 'Custom Property': 'value', + }) + + expect(mockMixpanel.track).toHaveBeenCalledWith( + 'Safe App Launched', + expect.objectContaining({ + 'Safe App Name': 'Test App', + 'Custom Property': 'value', + }), + ) + }) + }) + + describe('Safe address handling', () => { + it('should set safe address without removing 0x prefix', () => { + mixpanelInit() + + const testAddress = '0x1234567890abcdef1234567890abcdef12345678' + mixpanelSetSafeAddress(testAddress) + + expect(mockMixpanel.register).toHaveBeenCalledWith({ + 'Safe Address': testAddress, + }) + }) + + it('should handle safe address without 0x prefix', () => { + mixpanelInit() + + const testAddress = '1234567890abcdef1234567890abcdef12345678' + mixpanelSetSafeAddress(testAddress) + + expect(mockMixpanel.register).toHaveBeenCalledWith({ + 'Safe Address': testAddress, + }) + }) + }) + + describe('Separate tracking', () => { + it('should track with GA only when using trackEvent', () => { + mixpanelInit() + + const eventData = { + category: 'test', + action: 'click', + } + + trackEvent(eventData) + + // Should NOT call MixPanel track (only GA) + expect(mockMixpanel.track).not.toHaveBeenCalled() + }) + + it('should track with MixPanel only when using trackMixPanelEvent', () => { + mixpanelInit() + + trackMixPanelEvent(MixPanelEvent.SAFE_APP_LAUNCHED, { + 'Safe App Name': 'Test App', + 'Safe App Version': '1.0.0', + }) + + // Should call MixPanel track + expect(mockMixpanel.track).toHaveBeenCalledWith( + 'Safe App Launched', + expect.objectContaining({ + 'Safe App Name': 'Test App', + 'Safe App Version': '1.0.0', + }), + ) + + // Should NOT call GA track + expect(mockGtm.gtmTrack).not.toHaveBeenCalled() + }) + + it('should convert SafeApp to MixPanel properties', () => { + const mockSafeApp = { + id: 123, + name: 'Test App', + url: 'https://test-app.com', + description: 'A test app', + iconUrl: 'https://test-app.com/icon.png', + developerWebsite: 'https://developer.com', + chainIds: ['1', '5'], + socialProfiles: [], + tags: ['defi', 'swap'], + accessControl: { type: 'NO_RESTRICTIONS' as const }, + features: [], + } + + const properties = safeAppToMixPanelEventProperties(mockSafeApp as any) + + expect(properties).toEqual({ + 'Safe App Name': 'Test App', + 'Safe App Tags': ['defi', 'swap'], + }) + }) + + it('should convert SafeApp to MixPanel properties with launch location', () => { + const mockSafeApp = { + id: 123, + name: 'Test App', + url: 'https://test-app.com', + description: 'A test app', + iconUrl: 'https://test-app.com/icon.png', + developerWebsite: 'https://developer.com', + chainIds: ['1', '5'], + socialProfiles: [], + tags: ['defi', 'swap'], + accessControl: { type: 'NO_RESTRICTIONS' as const }, + features: [], + } + + const properties = safeAppToMixPanelEventProperties(mockSafeApp as any, { + launchLocation: SafeAppLaunchLocation.PREVIEW_DRAWER, + }) + + expect(properties).toEqual({ + 'Safe App Name': 'Test App', + 'Safe App Tags': ['defi', 'swap'], + 'Launch Location': 'Preview Drawer', + }) + }) + + it('should convert SafeApp to MixPanel properties with Safe App List launch location', () => { + const mockSafeApp = { + id: 123, + name: 'Test App', + url: 'https://test-app.com', + description: 'A test app', + iconUrl: 'https://test-app.com/icon.png', + developerWebsite: 'https://developer.com', + chainIds: ['1', '5'], + socialProfiles: [], + tags: ['defi', 'swap'], + accessControl: { type: 'NO_RESTRICTIONS' as const }, + features: [], + } + + const properties = safeAppToMixPanelEventProperties(mockSafeApp as any, { + launchLocation: SafeAppLaunchLocation.SAFE_APPS_LIST, + }) + + expect(properties).toEqual({ + 'Safe App Name': 'Test App', + 'Safe App Tags': ['defi', 'swap'], + 'Launch Location': 'Safe Apps List', + }) + }) + }) +}) diff --git a/apps/web/src/services/analytics/__tests__/tx-tracking.test.ts b/apps/web/src/services/analytics/__tests__/tx-tracking.test.ts index 063638f7a2..b594800221 100644 --- a/apps/web/src/services/analytics/__tests__/tx-tracking.test.ts +++ b/apps/web/src/services/analytics/__tests__/tx-tracking.test.ts @@ -181,8 +181,19 @@ describe('getTransactionTrackingType', () => { type: TransactionInfoType.CUSTOM, }, } as unknown as TransactionDetails - const origin = '{"url":"https://iframe.jumper.exchange","name":"Bridge"}' + const origin = '{"url":"https://iframe.jumper.exchange/bridge","name":"Bridge"}' const txType = getTransactionTrackingType(details, origin) expect(txType).toEqual(TX_TYPES.native_bridge) }) + + it('should return native_swap_lifi for native swap lifi transactions', () => { + const details = { + txInfo: { + type: TransactionInfoType.CUSTOM, + }, + } as unknown as TransactionDetails + const origin = '{"url":"https://iframe.jumper.exchange/swap","name":"Swap"}' + const txType = getTransactionTrackingType(details, origin) + expect(txType).toEqual(TX_TYPES.native_swap_lifi) + }) }) diff --git a/apps/web/src/services/analytics/__tests__/useMixPanelUserProperties.test.ts b/apps/web/src/services/analytics/__tests__/useMixPanelUserProperties.test.ts new file mode 100644 index 0000000000..d546bbf4fe --- /dev/null +++ b/apps/web/src/services/analytics/__tests__/useMixPanelUserProperties.test.ts @@ -0,0 +1,158 @@ +import { renderHook } from '@testing-library/react' +import { faker } from '@faker-js/faker' +import { useMixPanelUserProperties } from '../useMixPanelUserProperties' +import { MixPanelUserProperty } from '@/services/analytics/mixpanel-events' + +// Mock dependencies +jest.mock('@/hooks/useChains', () => ({ + useChain: jest.fn((chainId: string) => { + const chains: Record = { + '1': { chainName: 'Ethereum', chainId: '1' }, + '137': { chainName: 'Polygon', chainId: '137' }, + } + return chains[chainId] || null + }), +})) + +jest.mock('@/hooks/useSafeInfo', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('@/store', () => ({ + useAppSelector: jest.fn(() => ({ + data: { + results: [ + { + type: 'TRANSACTION', + transaction: { + id: 'tx2', + timestamp: 1672531200000, // Jan 1, 2023 (most recent first) + }, + }, + { + type: 'TRANSACTION', + transaction: { + id: 'tx1', + timestamp: 1640995200000, // Jan 1, 2022 + }, + }, + ], + }, + })), +})) + +jest.mock('@/utils/transaction-guards', () => ({ + isTransactionListItem: jest.fn((item) => item.type === 'TRANSACTION'), +})) + +jest.mock('@/features/myAccounts/hooks/useNetworksOfSafe', () => ({ + useNetworksOfSafe: jest.fn(() => ['ethereum', 'polygon']), +})) + +describe('useMixPanelUserProperties', () => { + // Generate test addresses + const safeAddress = faker.finance.ethereumAddress() + const owner1Address = faker.finance.ethereumAddress() + const owner2Address = faker.finance.ethereumAddress() + + // Update mocks with generated addresses + beforeEach(() => { + const useSafeInfo = require('@/hooks/useSafeInfo').default + useSafeInfo.mockReturnValue({ + safe: { + address: { value: safeAddress }, + version: '1.3.0', + owners: [{ value: owner1Address }, { value: owner2Address }], + threshold: 2, + nonce: 42, + chainId: '1', + }, + safeLoaded: true, + }) + }) + + it('should return correct user properties', () => { + const { result } = renderHook(() => useMixPanelUserProperties()) + + expect(result.current).toEqual({ + properties: { + [MixPanelUserProperty.SAFE_ADDRESS]: safeAddress, + [MixPanelUserProperty.SAFE_VERSION]: '1.3.0', + [MixPanelUserProperty.NUM_SIGNERS]: 2, + [MixPanelUserProperty.THRESHOLD]: 2, + [MixPanelUserProperty.TOTAL_TX_COUNT]: 42, + [MixPanelUserProperty.LAST_TX_AT]: new Date(1672531200000).toISOString(), + [MixPanelUserProperty.NETWORKS]: ['ethereum', 'polygon'], + }, + networks: ['ethereum', 'polygon'], + }) + }) + + it('should return null when safe is not loaded', () => { + const useSafeInfo = require('@/hooks/useSafeInfo').default + useSafeInfo.mockReturnValueOnce({ + safe: null, + safeLoaded: false, + }) + + const { result } = renderHook(() => useMixPanelUserProperties()) + + expect(result.current).toBeNull() + }) + + it('should handle safe without version', () => { + const useSafeInfo = require('@/hooks/useSafeInfo').default + useSafeInfo.mockReturnValueOnce({ + safe: { + address: { value: safeAddress }, + version: null, + owners: [{ value: owner1Address }], + threshold: 1, + nonce: 5, + chainId: '1', + }, + safeLoaded: true, + }) + + const { result } = renderHook(() => useMixPanelUserProperties()) + + expect(result.current?.properties[MixPanelUserProperty.SAFE_VERSION]).toBe('unknown') + }) + + it('should handle empty transaction history', () => { + const useSafeInfo = require('@/hooks/useSafeInfo').default + useSafeInfo.mockReturnValueOnce({ + safe: { + address: { value: safeAddress }, + version: '1.3.0', + owners: [{ value: owner1Address }], + threshold: 1, + nonce: 10, // nonce is still used for total_tx_count + chainId: '1', + }, + safeLoaded: true, + }) + + const { useAppSelector } = require('@/store') + useAppSelector.mockReturnValueOnce({ + data: { + results: [], // empty transaction history + }, + }) + + const { result } = renderHook(() => useMixPanelUserProperties()) + + expect(result.current?.properties[MixPanelUserProperty.TOTAL_TX_COUNT]).toBe(10) // from nonce + expect(result.current?.properties[MixPanelUserProperty.LAST_TX_AT]).toBeNull() // from empty tx history + }) + + it('should fallback to current chain when useNetworksOfSafe returns empty array', () => { + const { useNetworksOfSafe } = require('@/features/myAccounts/hooks/useNetworksOfSafe') + useNetworksOfSafe.mockReturnValueOnce([]) + + const { result } = renderHook(() => useMixPanelUserProperties()) + + expect(result.current?.networks).toEqual(['Ethereum']) + }) +}) diff --git a/apps/web/src/services/analytics/events/earn.ts b/apps/web/src/services/analytics/events/earn.ts new file mode 100644 index 0000000000..7c9cbb039e --- /dev/null +++ b/apps/web/src/services/analytics/events/earn.ts @@ -0,0 +1,29 @@ +const EARN_CATEGORY = 'earn' + +export const EARN_EVENTS = { + OPEN_EARN_PAGE: { + action: 'Open earn page', + category: EARN_CATEGORY, + }, + HIDE_EARN_BANNER: { + action: 'Hide earn banner', + category: EARN_CATEGORY, + }, + GET_STARTED_WITH_EARN: { + action: 'Get started with earn', + category: EARN_CATEGORY, + }, + OPEN_EARN_LEARN_MORE: { + action: 'Open earn learn more', + category: EARN_CATEGORY, + }, +} + +export enum EARN_LABELS { + safe_dashboard_banner = 'safe_dashboard_banner', + sidebar = 'sidebar', + info_banner = 'info_banner', + asset = 'asset', + info_asset = 'info_asset', + dashboard_asset = 'dashboard_asset', +} diff --git a/apps/web/src/services/analytics/events/modals.ts b/apps/web/src/services/analytics/events/modals.ts index 33b1dd6352..2a22aff57c 100644 --- a/apps/web/src/services/analytics/events/modals.ts +++ b/apps/web/src/services/analytics/events/modals.ts @@ -7,10 +7,6 @@ export const MODALS_EVENTS = { action: 'Send tokens', category: MODALS_CATEGORY, }, - SEND_COLLECTIBLE: { - action: 'Send NFTs', - category: MODALS_CATEGORY, - }, CONTRACT_INTERACTION: { action: 'Contract interaction', category: MODALS_CATEGORY, @@ -36,6 +32,14 @@ export const MODALS_EVENTS = { action: 'Use spending limit', category: MODALS_CATEGORY, }, + OPEN_SAFE_UTILS: { + action: 'Open Safe Utils', + category: MODALS_CATEGORY, + }, + SIGNING_ARTICLE: { + action: 'Open signing article', + category: MODALS_CATEGORY, + }, SIMULATE_TX: { action: 'Simulate transaction', category: MODALS_CATEGORY, @@ -92,6 +96,31 @@ export const MODALS_EVENTS = { category: MODALS_CATEGORY, event: EventType.CLICK, }, + CONFIRM_SIGN_CHECKBOX: { + action: 'Confirm sign checkbox', + category: MODALS_CATEGORY, + event: EventType.CLICK, + }, + ADD_RECIPIENT: { + action: 'Add recipient', + category: MODALS_CATEGORY, + event: EventType.CLICK, + }, + REMOVE_RECIPIENT: { + action: 'Remove recipient', + category: MODALS_CATEGORY, + event: EventType.CLICK, + }, + CONTINUE_CLICKED: { + action: 'Continue to receipt', + category: MODALS_CATEGORY, + event: EventType.CLICK, + }, + RECEIPT_TIME_SPENT: { + action: 'Time spent on receipt', + category: MODALS_CATEGORY, + event: EventType.META, + }, } export enum MODAL_NAVIGATION { diff --git a/apps/web/src/services/analytics/events/nested-safes.ts b/apps/web/src/services/analytics/events/nested-safes.ts new file mode 100644 index 0000000000..0c72fcc227 --- /dev/null +++ b/apps/web/src/services/analytics/events/nested-safes.ts @@ -0,0 +1,31 @@ +const NESTED_SAFES_CATEGORY = 'nested-safes' + +export const NESTED_SAFE_EVENTS = { + OPEN_LIST: { + action: 'Open nested Safe list', + category: NESTED_SAFES_CATEGORY, + }, + OPEN_NESTED_SAFE: { + action: 'Open nested Safe', + category: NESTED_SAFES_CATEGORY, + }, + SHOW_ALL: { + action: 'Show all', + category: NESTED_SAFES_CATEGORY, + }, + ADD: { + action: 'Add', + category: NESTED_SAFES_CATEGORY, + }, + RENAME: { + action: 'Rename', + category: NESTED_SAFES_CATEGORY, + }, +} + +export enum NESTED_SAFE_LABELS { + header = 'header', + sidebar = 'sidebar', + list = 'list', + success_screen = 'success_screen', +} diff --git a/apps/web/src/services/analytics/events/overview.ts b/apps/web/src/services/analytics/events/overview.ts index 67d05717a7..49cb1ea777 100644 --- a/apps/web/src/services/analytics/events/overview.ts +++ b/apps/web/src/services/analytics/events/overview.ts @@ -190,6 +190,14 @@ export const OVERVIEW_EVENTS = { action: 'Staking banner learn more', category: OVERVIEW_CATEGORY, }, + OPEN_EARN_WIDGET: { + action: 'Open earn widget from banner', + category: OVERVIEW_CATEGORY, + }, + HIDE_EARN_BANNER: { + action: 'Hide earn banner', + category: OVERVIEW_CATEGORY, + }, } export enum PIN_SAFE_LABELS { @@ -212,4 +220,6 @@ export enum OVERVIEW_LABELS { welcome_page = 'welcome_page', login_page = 'login_page', settings = 'settings', + space_list_page = 'space_list_page', + space_page = 'space_page', } diff --git a/apps/web/src/services/analytics/events/recovery.ts b/apps/web/src/services/analytics/events/recovery.ts index 478aa9313b..ae117a05ee 100644 --- a/apps/web/src/services/analytics/events/recovery.ts +++ b/apps/web/src/services/analytics/events/recovery.ts @@ -1,11 +1,5 @@ import { EventType } from '@/services/analytics' -// These are used for the generic stepper flow events (Next, Back) -export const SETUP_RECOVERY_CATEGORY = 'setup-recovery' -export const START_RECOVERY_CATEGORY = 'propose-recovery' -export const REMOVE_RECOVERY_CATEGORY = 'remove-recovery' -export const CANCEL_RECOVERY_CATEGORY = 'cancel-recovery' - const RECOVERY_CATEGORY = 'recovery' export const RECOVERY_EVENTS = { diff --git a/apps/web/src/services/analytics/events/settings.ts b/apps/web/src/services/analytics/events/settings.ts index 32286dfdb7..b6bad392ad 100644 --- a/apps/web/src/services/analytics/events/settings.ts +++ b/apps/web/src/services/analytics/events/settings.ts @@ -4,6 +4,10 @@ const SETTINGS_CATEGORY = 'settings' export const SETTINGS_EVENTS = { SETUP: { + MANAGE_SIGNERS: { + action: 'Manage signers', + category: SETTINGS_CATEGORY, + }, ADD_OWNER: { action: 'Add owner', category: SETTINGS_CATEGORY, @@ -133,6 +137,10 @@ export const SETTINGS_EVENTS = { action: 'Imported visited safes via Import all', category: SETTINGS_CATEGORY, }, + CLEAR_PENDING_TXS: { + action: 'Cleared pending transactions', + category: SETTINGS_CATEGORY, + }, }, ENV_VARIABLES: { SAVE: { @@ -147,3 +155,7 @@ export const SETTINGS_EVENTS = { }, }, } + +export enum SETTINGS_LABELS { + manage_signers = 'manage_signers', +} diff --git a/apps/web/src/services/analytics/events/spaces.ts b/apps/web/src/services/analytics/events/spaces.ts new file mode 100644 index 0000000000..74d7c58257 --- /dev/null +++ b/apps/web/src/services/analytics/events/spaces.ts @@ -0,0 +1,166 @@ +import { EventType } from '@/services/analytics' + +const SPACE_CATEGORY = 'spaces' + +export const SPACE_EVENTS = { + SIGN_IN_BUTTON: { + action: 'Open sign in message', + category: SPACE_CATEGORY, + }, + INFO_MODAL: { + action: 'Open info dialog', + category: SPACE_CATEGORY, + }, + OPEN_SPACE_LIST_PAGE: { + action: 'Open space list page', + category: SPACE_CATEGORY, + }, + OPEN_SPACE_DASHBOARD: { + action: 'Open space dashboard', + category: SPACE_CATEGORY, + }, + CREATE_SPACE_MODAL: { + action: 'Open create space dialog', + category: SPACE_CATEGORY, + }, + CREATE_SPACE: { + action: 'Submit space creation', + category: SPACE_CATEGORY, + }, + ACCEPT_INVITE: { + action: 'Open accept invitation dialog', + category: SPACE_CATEGORY, + }, + ACCEPT_INVITE_SUBMIT: { + action: 'Submit accept invitation', + category: SPACE_CATEGORY, + }, + DECLINE_INVITE: { + action: 'Open decline invitation dialog', + category: SPACE_CATEGORY, + }, + DECLINE_INVITE_SUBMIT: { + action: 'Submit decline invitation', + category: SPACE_CATEGORY, + }, + VIEW_INVITING_SPACE: { + action: 'View preview of inviting space', + category: SPACE_CATEGORY, + }, + ADD_MEMBER_MODAL: { + action: 'Open add member modal', + category: SPACE_CATEGORY, + }, + REMOVE_MEMBER_MODAL: { + action: 'Open remove member modal', + category: SPACE_CATEGORY, + }, + REMOVE_MEMBER: { + action: 'Submit remove member', + category: SPACE_CATEGORY, + }, + ADD_MEMBER: { + action: 'Submit add member', + category: SPACE_CATEGORY, + }, + ADD_ACCOUNTS_MODAL: { + action: 'Open add accounts modal', + category: SPACE_CATEGORY, + }, + ADD_ACCOUNTS: { + action: 'Submit add accounts', + category: SPACE_CATEGORY, + }, + ADD_ACCOUNT_MANUALLY_MODAL: { + action: 'Open add account manually modal', + category: SPACE_CATEGORY, + }, + ADD_ACCOUNT_MANUALLY: { + action: 'Add account manually submit', + category: SPACE_CATEGORY, + }, + RENAME_ACCOUNT_MODAL: { + action: 'Open rename account modal', + category: SPACE_CATEGORY, + }, + RENAME_ACCOUNT: { + action: 'Submit rename account', + category: SPACE_CATEGORY, + }, + DELETE_ACCOUNT_MODAL: { + action: 'Open delete account modal', + category: SPACE_CATEGORY, + }, + DELETE_ACCOUNT: { + action: 'Submit delete account', + category: SPACE_CATEGORY, + }, + DELETE_SPACE_MODAL: { + action: 'Open delete space modal', + category: SPACE_CATEGORY, + }, + DELETE_SPACE: { + action: 'Submit delete space', + category: SPACE_CATEGORY, + }, + LEAVE_SPACE_MODAL: { + action: 'Open leave space modal', + category: SPACE_CATEGORY, + }, + LEAVE_SPACE: { + action: 'Submit leave space', + category: SPACE_CATEGORY, + }, + VIEW_ALL_ACCOUNTS: { + action: 'View all accounts', + category: SPACE_CATEGORY, + }, + VIEW_ALL_MEMBERS: { + action: 'View all members', + category: SPACE_CATEGORY, + }, + SEARCH_ACCOUNTS: { + action: 'Search accounts', + category: SPACE_CATEGORY, + }, + SEARCH_MEMBERS: { + action: 'Search members', + category: SPACE_CATEGORY, + }, + CREATE_SPACE_TX: { + action: 'Open send tokens flow in space', + category: SPACE_CATEGORY, + }, + TOTAL_SAFE_ACCOUNTS: { + action: 'Total safes added to space', + category: SPACE_CATEGORY, + event: EventType.META, + }, + TOTAL_ACTIVE_MEMBERS: { + action: 'Total active members in space', + category: SPACE_CATEGORY, + event: EventType.META, + }, + HIDE_DASHBOARD_WIDGET: { + action: 'Hide spaces dashboard widget', + category: SPACE_CATEGORY, + }, +} + +export enum SPACE_LABELS { + space_list_page = 'space_list_page', + safe_dashboard_banner = 'safe_dashboard_banner', + info_modal = 'info_modal', + space_selector = 'space_selector', + accounts_page = 'accounts_page', + preview_banner = 'preview_banner', + space_dashboard = 'space_dashboard', + space_dashboard_card = 'space_dashboard_card', + members_page = 'members_page', + member_list = 'member_list', + invite_list = 'invite_list', + add_accounts_modal = 'add_accounts_modal', + space_settings = 'space_settings', + space_context_menu = 'space_context_menu', + space_breadcrumbs = 'space_breadcrumbs', +} diff --git a/apps/web/src/services/analytics/events/transactions.ts b/apps/web/src/services/analytics/events/transactions.ts index 61b198035c..bdffe2453b 100644 --- a/apps/web/src/services/analytics/events/transactions.ts +++ b/apps/web/src/services/analytics/events/transactions.ts @@ -13,6 +13,7 @@ export enum TX_TYPES { // Transfers transfer_token = 'transfer_token', + batch_transfer_token = 'batch_transfer_token', transfer_nft = 'transfer_nft', // Other @@ -24,6 +25,8 @@ export enum TX_TYPES { custom = 'custom', native_bridge = 'native_bridge', native_swap = 'native_swap', + native_earn = 'native_earn', + native_swap_lifi = 'native_swap_lifi', bulk_execute = 'bulk_execute', // Counterfactual diff --git a/apps/web/src/services/analytics/events/txList.ts b/apps/web/src/services/analytics/events/txList.ts index d6d52c8996..aa2d2c2959 100644 --- a/apps/web/src/services/analytics/events/txList.ts +++ b/apps/web/src/services/analytics/events/txList.ts @@ -2,6 +2,11 @@ import { EventType } from '@/services/analytics/types' const TX_LIST_CATEGORY = 'tx-list' +export enum CopyDeeplinkLabels { + shareBlock = 'share-block', + button = 'button', +} + export const TX_LIST_EVENTS = { QUEUED_TXS: { event: EventType.META, @@ -19,6 +24,7 @@ export const TX_LIST_EVENTS = { COPY_DEEPLINK: { action: 'Copy deeplink', category: TX_LIST_CATEGORY, + label: CopyDeeplinkLabels.shareBlock, }, OPEN_SHARE_BLOCK: { action: 'Open share block', diff --git a/apps/web/src/services/analytics/gtm.ts b/apps/web/src/services/analytics/gtm.ts index 978d090f5e..e71b020db1 100644 --- a/apps/web/src/services/analytics/gtm.ts +++ b/apps/web/src/services/analytics/gtm.ts @@ -7,15 +7,9 @@ * This service should NOT be used directly by components. Use the `analytics` service instead. */ -import type { TagManagerArgs } from './TagManager' -import TagManager from './TagManager' -import { - IS_PRODUCTION, - GOOGLE_TAG_MANAGER_ID, - GOOGLE_TAG_MANAGER_AUTH_LIVE, - GOOGLE_TAG_MANAGER_AUTH_LATEST, - GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH, -} from '@/config/constants' +import { sendGAEvent } from '@next/third-parties/google' +import Cookies from 'js-cookie' +import { SAFE_APPS_GA_TRACKING_ID, GA_TRACKING_ID, IS_PRODUCTION } from '@/config/constants' import type { AnalyticsEvent, EventLabel, SafeAppSDKEvent } from './types' import { EventType, DeviceType } from './types' import { SAFE_APPS_SDK_CATEGORY } from './events' @@ -24,24 +18,6 @@ import type { AbTest } from '../tracking/abTesting' import { AppRoutes } from '@/config/routes' import packageJson from '../../../package.json' -type GTMEnvironment = 'LIVE' | 'LATEST' | 'DEVELOPMENT' -type GTMEnvironmentArgs = Required> - -const GTM_ENV_AUTH: Record = { - LIVE: { - auth: GOOGLE_TAG_MANAGER_AUTH_LIVE, - preview: 'env-1', - }, - LATEST: { - auth: GOOGLE_TAG_MANAGER_AUTH_LATEST, - preview: 'env-2', - }, - DEVELOPMENT: { - auth: GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH, - preview: 'env-3', - }, -} - const commonEventParams = { appVersion: packageJson.version, chainId: '', @@ -61,23 +37,42 @@ export const gtmSetSafeAddress = (safeAddress: string): void => { commonEventParams.safeAddress = safeAddress.slice(2) // Remove 0x prefix } -export const gtmInit = (): void => { - const GTM_ENVIRONMENT = IS_PRODUCTION ? GTM_ENV_AUTH.LIVE : GTM_ENV_AUTH.DEVELOPMENT +export const gtmEnableCookies = () => { + window.gtag?.('consent', 'update', { + analytics_storage: 'granted', + }) +} + +export const gtmDisableCookies = () => { + window.gtag?.('consent', 'update', { + analytics_storage: 'denied', + }) - if (!GOOGLE_TAG_MANAGER_ID || !GTM_ENVIRONMENT.auth) { - console.warn('[GTM] - Unable to initialize Google Tag Manager. `id` or `gtm_auth` missing.') - return - } + const GA_COOKIE_LIST = ['_ga', '_gat', '_gid'] + const GA_PREFIX = '_ga_' + const allCookies = document.cookie.split(';').map((cookie) => cookie.split('=')[0].trim()) + const gaCookies = allCookies.filter((cookie) => cookie.startsWith(GA_PREFIX)) - TagManager.initialize({ - gtmId: GOOGLE_TAG_MANAGER_ID, - ...GTM_ENVIRONMENT, + GA_COOKIE_LIST.concat(gaCookies).forEach((cookie) => { + Cookies.remove(cookie, { + path: '/', + domain: `.${location.host.split('.').slice(-2).join('.')}`, + }) }) + + // Injected script will remain in memory until new session + location.reload() } -export const gtmEnableCookies = TagManager.enableCookies -export const gtmDisableCookies = TagManager.disableCookies -export const gtmSetUserProperty = TagManager.setUserProperty +export const gtmSetUserProperty = (name: string, value: string) => { + window.gtag?.('set', 'user_properties', { + [name]: value, + }) + + if (!IS_PRODUCTION) { + console.info('[GTM] -', 'set user_properties', name, '=', value) + } +} type GtmEvent = { event: EventType @@ -89,13 +84,15 @@ type GtmEvent = { type ActionGtmEvent = GtmEvent & { eventCategory: string eventAction: string + send_to: string eventLabel?: EventLabel eventType?: string } type PageviewGtmEvent = GtmEvent & { - pageLocation: string - pagePath: string + page_location: string + page_path: string + send_to: string } type SafeAppGtmEvent = ActionGtmEvent & { @@ -103,10 +100,9 @@ type SafeAppGtmEvent = ActionGtmEvent & { safeAppMethod?: string safeAppEthMethod?: string safeAppSDKVersion?: string + send_to: string } -const gtmSend = TagManager.dataLayer - export const gtmTrack = (eventData: AnalyticsEvent): void => { const gtmEvent: ActionGtmEvent = { ...commonEventParams, @@ -114,6 +110,7 @@ export const gtmTrack = (eventData: AnalyticsEvent): void => { eventCategory: eventData.category, eventAction: eventData.action, chainId: eventData.chainId || commonEventParams.chainId, + send_to: GA_TRACKING_ID, } if (eventData.event) { @@ -135,18 +132,19 @@ export const gtmTrack = (eventData: AnalyticsEvent): void => { gtmEvent.abTest = abTest } - gtmSend(gtmEvent) + sendEvent(gtmEvent.event, gtmEvent) } export const gtmTrackPageview = (pagePath: string, pathWithQuery: string): void => { const gtmEvent: PageviewGtmEvent = { ...commonEventParams, event: EventType.PAGEVIEW, - pageLocation: `${location.origin}${pathWithQuery}`, - pagePath, + page_location: `${location.origin}${pathWithQuery}`, + page_path: pagePath, + send_to: GA_TRACKING_ID, } - gtmSend(gtmEvent) + sendEvent('page_view', gtmEvent) } export const normalizeAppName = (appName?: string): string => { @@ -172,6 +170,7 @@ export const gtmTrackSafeApp = (eventData: AnalyticsEvent, appName?: string, sdk safeAppEthMethod: '', safeAppMethod: '', safeAppSDKVersion: '', + send_to: SAFE_APPS_GA_TRACKING_ID, } if (eventData.category === SAFE_APPS_SDK_CATEGORY) { @@ -184,5 +183,13 @@ export const gtmTrackSafeApp = (eventData: AnalyticsEvent, appName?: string, sdk safeAppGtmEvent.eventLabel = eventData.label } - gtmSend(safeAppGtmEvent) + sendEvent('safeAppEvent', safeAppGtmEvent) +} + +const sendEvent = (eventName: string, data: object) => { + sendGAEvent('event', eventName, data) + + if (!IS_PRODUCTION) { + console.info('[GA] -', data) + } } diff --git a/apps/web/src/services/analytics/index.ts b/apps/web/src/services/analytics/index.ts index 5bb16d7dd4..a15fae6365 100644 --- a/apps/web/src/services/analytics/index.ts +++ b/apps/web/src/services/analytics/index.ts @@ -12,9 +12,14 @@ * `trackEvent(ADDRESS_BOOK_EVENTS.EXPORT)` */ import { gtmTrack, gtmTrackSafeApp } from './gtm' +import { mixpanelTrack, safeAppToMixPanelEventProperties } from './mixpanel' export const trackEvent = gtmTrack export const trackSafeAppEvent = gtmTrackSafeApp +export const trackMixPanelEvent = mixpanelTrack +export { safeAppToMixPanelEventProperties } + export * from './types' export * from './events' +export * from './mixpanel-events' diff --git a/apps/web/src/services/analytics/mixpanel-events.ts b/apps/web/src/services/analytics/mixpanel-events.ts new file mode 100644 index 0000000000..c6a9ad54c4 --- /dev/null +++ b/apps/web/src/services/analytics/mixpanel-events.ts @@ -0,0 +1,30 @@ +export enum MixPanelEvent { + SAFE_APP_LAUNCHED = 'Safe App Launched', +} + +export enum MixPanelUserProperty { + WALLET_LABEL = 'Wallet Label', + WALLET_ADDRESS = 'Wallet Address', + SAFE_ADDRESS = 'Safe Address', + SAFE_VERSION = 'Safe Version', + NUM_SIGNERS = 'Number of Signers', + THRESHOLD = 'Threshold', + NETWORKS = 'Networks', + TOTAL_TX_COUNT = 'Total Transaction Count', + LAST_TX_AT = 'Last Transaction at', +} + +export enum MixPanelEventParams { + APP_VERSION = 'App Version', + BLOCKCHAIN_NETWORK = 'Blockchain Network', + DEVICE_TYPE = 'Device Type', + SAFE_ADDRESS = 'Safe Address', + EOA_WALLET_LABEL = 'EOA Wallet Label', + EOA_WALLET_ADDRESS = 'EOA Wallet Address', + EOA_WALLET_NETWORK = 'EOA Wallet Network', +} + +export enum SafeAppLaunchLocation { + PREVIEW_DRAWER = 'Preview Drawer', + SAFE_APPS_LIST = 'Safe Apps List', +} diff --git a/apps/web/src/services/analytics/mixpanel.ts b/apps/web/src/services/analytics/mixpanel.ts new file mode 100644 index 0000000000..e2542b1471 --- /dev/null +++ b/apps/web/src/services/analytics/mixpanel.ts @@ -0,0 +1,133 @@ +import mixpanel from 'mixpanel-browser' +import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' +import { IS_PRODUCTION, MIXPANEL_TOKEN } from '@/config/constants' +import { DeviceType } from './types' +import { MixPanelEventParams } from './mixpanel-events' +import packageJson from '../../../package.json' + +let isMixPanelInitialized = false + +const safeMixPanelRegister = (properties: Record): void => { + if (isMixPanelInitialized) { + mixpanel.register(properties) + } +} + +const safeMixPanelPeopleSet = (properties: Record): void => { + if (isMixPanelInitialized) { + mixpanel.people.set(properties) + } +} + +const safeMixPanelTrack = (eventName: string, properties?: Record): void => { + if (isMixPanelInitialized) { + mixpanel.track(eventName, properties) + } +} + +const safeMixPanelIdentify = (userId: string): void => { + if (isMixPanelInitialized) { + mixpanel.identify(userId) + } +} + +export const mixpanelInit = (): void => { + if (typeof window === 'undefined' || isMixPanelInitialized) return + + if (!MIXPANEL_TOKEN) { + if (!IS_PRODUCTION) { + console.warn('[MixPanel] - No token provided') + } + return + } + + try { + mixpanel.init(MIXPANEL_TOKEN, { + debug: !IS_PRODUCTION, + persistence: 'localStorage', + autocapture: false, + batch_requests: true, + ip: false, + opt_out_tracking_by_default: true, + }) + + isMixPanelInitialized = true + + mixpanel.register({ + [MixPanelEventParams.APP_VERSION]: packageJson.version, + [MixPanelEventParams.DEVICE_TYPE]: DeviceType.DESKTOP, + }) + + if (!IS_PRODUCTION) { + console.info('[MixPanel] - Initialized (opted out by default)') + } + } catch (error) { + console.error('[MixPanel] - Initialization failed:', error) + } +} + +export const mixpanelSetBlockchainNetwork = (networkName: string): void => { + safeMixPanelRegister({ [MixPanelEventParams.BLOCKCHAIN_NETWORK]: networkName }) +} + +export const mixpanelSetDeviceType = (type: DeviceType): void => { + safeMixPanelRegister({ [MixPanelEventParams.DEVICE_TYPE]: type }) +} + +export const mixpanelSetSafeAddress = (safeAddress: string): void => { + safeMixPanelRegister({ [MixPanelEventParams.SAFE_ADDRESS]: safeAddress }) +} + +export const mixpanelSetUserProperties = (properties: Record): void => { + safeMixPanelPeopleSet(properties) + + if (!IS_PRODUCTION && isMixPanelInitialized) { + console.info('[MixPanel] - User properties set:', properties) + } +} + +export const mixpanelSetEOAWalletLabel = (label: string): void => { + safeMixPanelRegister({ [MixPanelEventParams.EOA_WALLET_LABEL]: label }) +} + +export const mixpanelSetEOAWalletAddress = (address: string): void => { + safeMixPanelRegister({ [MixPanelEventParams.EOA_WALLET_ADDRESS]: address }) +} + +export const mixpanelSetEOAWalletNetwork = (network: string): void => { + safeMixPanelRegister({ [MixPanelEventParams.EOA_WALLET_NETWORK]: network }) +} + +export const safeAppToMixPanelEventProperties = ( + safeApp: SafeAppData, + options?: { + launchLocation?: string + }, +): Record => { + const properties: Record = { + 'Safe App Name': safeApp.name, + 'Safe App Tags': safeApp.tags, + } + + if (options?.launchLocation) { + properties['Launch Location'] = options.launchLocation + } + + return properties +} + +export const mixpanelTrack = (eventName: string, properties?: Record): void => { + safeMixPanelTrack(eventName, properties) + + if (!IS_PRODUCTION && isMixPanelInitialized) { + console.info('[MixPanel] - Event tracked:', eventName, properties) + } +} + +export const mixpanelIdentify = (userId: string): void => { + safeMixPanelIdentify(userId) + + if (!IS_PRODUCTION && isMixPanelInitialized) { + console.info('[MixPanel] - User identified:', userId) + } +} diff --git a/apps/web/src/services/analytics/tx-tracking.ts b/apps/web/src/services/analytics/tx-tracking.ts index fa2d20c913..a8bc9b15db 100644 --- a/apps/web/src/services/analytics/tx-tracking.ts +++ b/apps/web/src/services/analytics/tx-tracking.ts @@ -10,10 +10,19 @@ import { isSwapOrderTxInfo, isAnyStakingTxInfo, isNestedConfirmationTxInfo, + isAnyEarnTxInfo, } from '@/utils/transaction-guards' import { BRIDGE_WIDGET_URL } from '@/features/bridge/components/BridgeWidget' +import { SWAP_WIDGET_URL } from '@/features/swap/components/FallbackSwapWidget' +export const getTransactionTrackingType = ( + details: TransactionDetails | undefined, + origin?: string, + isMassPayout?: boolean, +): string => { + if (isMassPayout) { + return TX_TYPES.batch_transfer_token + } -export const getTransactionTrackingType = (details: TransactionDetails | undefined, origin?: string): string => { if (!details) { return TX_TYPES.custom } @@ -21,10 +30,16 @@ export const getTransactionTrackingType = (details: TransactionDetails | undefin const { txInfo } = details const isNativeBridge = origin?.includes(BRIDGE_WIDGET_URL) + const isLiFiSwap = origin?.includes(SWAP_WIDGET_URL) + if (isNativeBridge) { return TX_TYPES.native_bridge } + if (isLiFiSwap) { + return TX_TYPES.native_swap_lifi + } + if (isTransferTxInfo(txInfo)) { if (isERC721Transfer(txInfo.transferInfo)) { return TX_TYPES.transfer_nft @@ -40,6 +55,11 @@ export const getTransactionTrackingType = (details: TransactionDetails | undefin return txInfo.type } + //@ts-ignore TODO: Fix types after removing old sdk + if (isAnyEarnTxInfo(txInfo)) { + return TX_TYPES.native_earn + } + if (isSettingsChangeTxInfo(txInfo)) { switch (txInfo.settingsInfo?.type) { case SettingsInfoType.ADD_OWNER: { diff --git a/apps/web/src/services/analytics/types.ts b/apps/web/src/services/analytics/types.ts index 855b791403..bbbe4fe33b 100644 --- a/apps/web/src/services/analytics/types.ts +++ b/apps/web/src/services/analytics/types.ts @@ -41,3 +41,28 @@ export enum AnalyticsUserProperties { WALLET_LABEL = 'walletLabel', WALLET_ADDRESS = 'walletAddress', } + +// These are used for the generic stepper flow events (Next, Back) +export enum TxFlowType { + ADD_OWNER = 'add-owner', + CANCEL_RECOVERY = 'cancel-recovery', + CHANGE_THRESHOLD = 'change-threshold', + CONFIRM_BATCH = 'confirm-batch', + CONFIRM_TX = 'confirm-tx', + NFT_TRANSFER = 'nft-transfer', + REJECT_TX = 'reject-tx', + REMOVE_GUARD = 'remove-guard', + REMOVE_MODULE = 'remove-module', + REMOVE_OWNER = 'remove-owner', + REMOVE_RECOVERY = 'remove-recovery', + REMOVE_SPENDING_LIMIT = 'remove-spending-limit', + REPLACE_OWNER = 'replace-owner', + SAFE_APPS_TX = 'safe-apps-tx', + SETUP_RECOVERY = 'setup-recovery', + SETUP_SPENDING_LIMIT = 'setup-spending-limit', + SIGN_MESSAGE_ON_CHAIN = 'sign-message-on-chain', + SIGNERS_STRUCTURE = 'signers-structure', + START_RECOVERY = 'propose-recovery', + TOKEN_TRANSFER = 'token-transfer', + UPDATE_SAFE = 'update-safe', +} diff --git a/apps/web/src/services/analytics/useGtm.ts b/apps/web/src/services/analytics/useGtm.ts index 91fcffda1b..2fb3d7636b 100644 --- a/apps/web/src/services/analytics/useGtm.ts +++ b/apps/web/src/services/analytics/useGtm.ts @@ -4,7 +4,6 @@ import { useEffect, useState } from 'react' import { useTheme } from '@mui/material/styles' import { - gtmInit, gtmTrackPageview, gtmSetChainId, gtmEnableCookies, @@ -26,6 +25,7 @@ import { AnalyticsUserProperties, DeviceType } from './types' import useSafeAddress from '@/hooks/useSafeAddress' import useWallet from '@/hooks/wallets/useWallet' import { OVERVIEW_EVENTS } from './events' +import { useIsSpaceRoute } from '@/hooks/useIsSpaceRoute' const useGtm = () => { const chainId = useChainId() @@ -38,10 +38,10 @@ const useGtm = () => { const deviceType = isMobile ? DeviceType.MOBILE : isTablet ? DeviceType.TABLET : DeviceType.DESKTOP const safeAddress = useSafeAddress() const wallet = useWallet() + const isSpaceRoute = useIsSpaceRoute() // Initialize GTM and Spindl useEffect(() => { - gtmInit() spindlInit() }, []) @@ -74,18 +74,18 @@ const useGtm = () => { useEffect(() => { gtmSetSafeAddress(safeAddress) - if (safeAddress) { + if (safeAddress && !isSpaceRoute) { gtmTrack(OVERVIEW_EVENTS.SAFE_VIEWED) } - }, [safeAddress]) + }, [safeAddress, isSpaceRoute]) // Track page views – anonymized by default. useEffect(() => { // Don't track 404 because it's not a real page, it immediately does a client-side redirect - if (router.pathname === AppRoutes['404']) return + if (router.pathname === AppRoutes['404'] || isSpaceRoute) return gtmTrackPageview(router.pathname, router.asPath) - }, [router.asPath, router.pathname]) + }, [router.asPath, router.pathname, isSpaceRoute]) useEffect(() => { if (wallet?.label) { @@ -95,7 +95,7 @@ const useGtm = () => { useEffect(() => { if (wallet?.address) { - gtmSetUserProperty(AnalyticsUserProperties.WALLET_ADDRESS, wallet.address.slice(2)) // Remove 0x prefix + gtmSetUserProperty(AnalyticsUserProperties.WALLET_ADDRESS, wallet.address.slice(2)) // Remove 0x prefix because GA converts it to a number otherwise spindlAttribute(wallet.address) } }, [wallet?.address]) diff --git a/apps/web/src/services/analytics/useMetaEvents.ts b/apps/web/src/services/analytics/useMetaEvents.ts index 96b6ef0f9f..33c6cec370 100644 --- a/apps/web/src/services/analytics/useMetaEvents.ts +++ b/apps/web/src/services/analytics/useMetaEvents.ts @@ -7,43 +7,46 @@ import useChainId from '@/hooks/useChainId' import useBalances from '@/hooks/useBalances' import useSafeInfo from '@/hooks/useSafeInfo' import useHiddenTokens from '@/hooks/useHiddenTokens' +import { useIsSpaceRoute } from '@/hooks/useIsSpaceRoute' // Track meta events on app load const useMetaEvents = () => { const chainId = useChainId() const { safeAddress } = useSafeInfo() + const isSpaceRoute = useIsSpaceRoute() // Queue size const queue = useAppSelector(selectQueuedTransactions) // eslint-disable-next-line react-hooks/exhaustive-deps const safeQueue = useMemo(() => queue, [safeAddress, queue !== undefined]) useEffect(() => { - if (!safeQueue) return + if (!safeQueue || isSpaceRoute) return gtmTrack({ ...TX_LIST_EVENTS.QUEUED_TXS, label: safeQueue.length.toString(), }) - }, [safeQueue]) + }, [safeQueue, isSpaceRoute]) // Tokens const { balances } = useBalances() const totalTokens = balances?.items.length ?? 0 useEffect(() => { - if (!safeAddress || totalTokens <= 0) return + if (!safeAddress || totalTokens <= 0 || isSpaceRoute) return gtmTrack({ ...ASSETS_EVENTS.DIFFERING_TOKENS, label: totalTokens }) - }, [totalTokens, safeAddress, chainId]) + }, [totalTokens, safeAddress, chainId, isSpaceRoute]) // Manually hidden tokens const hiddenTokens = useHiddenTokens() const totalHiddenFromBalance = balances?.items.filter((item) => hiddenTokens.includes(item.tokenInfo.address)).length ?? 0 + useEffect(() => { - if (!safeAddress || totalTokens <= 0) return + if (!safeAddress || totalTokens <= 0 || isSpaceRoute) return gtmTrack({ ...ASSETS_EVENTS.HIDDEN_TOKENS, label: totalHiddenFromBalance }) - }, [safeAddress, totalHiddenFromBalance, totalTokens]) + }, [safeAddress, totalHiddenFromBalance, totalTokens, isSpaceRoute]) } export default useMetaEvents diff --git a/apps/web/src/services/analytics/useMixPanelUserProperties.ts b/apps/web/src/services/analytics/useMixPanelUserProperties.ts new file mode 100644 index 0000000000..5717f1f30b --- /dev/null +++ b/apps/web/src/services/analytics/useMixPanelUserProperties.ts @@ -0,0 +1,72 @@ +import { useMemo } from 'react' +import { useChain } from '@/hooks/useChains' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useAppSelector } from '@/store' +import { selectTxHistory } from '@/store/txHistorySlice' +import { isTransactionListItem } from '@/utils/transaction-guards' +import { MixPanelUserProperty } from '@/services/analytics/mixpanel-events' +import { useNetworksOfSafe } from '@/features/myAccounts/hooks/useNetworksOfSafe' + +export interface MixPanelUserProperties { + safe_address: string + safe_version: string + num_signers: number + threshold: number + networks: string[] + total_tx_count: number + last_tx_at: Date | null +} + +export interface MixPanelUserPropertiesFormatted { + properties: Record + networks: string[] +} + +/** + * Hook to get formatted user properties for MixPanel tracking + * + * This hook collects Safe-related user properties that can be used for + * MixPanel user attribute tracking and cohort analysis. + * Returns both regular properties and networks separately for different MixPanel operations. + */ +export const useMixPanelUserProperties = (): MixPanelUserPropertiesFormatted | null => { + const { safe, safeLoaded } = useSafeInfo() + const currentChain = useChain(safe?.chainId || '') + const txHistory = useAppSelector(selectTxHistory) + const allNetworks = useNetworksOfSafe(safe?.address?.value || '') + + return useMemo(() => { + if (!safeLoaded || !safe || !currentChain) { + return null + } + + const networks = allNetworks.length > 0 ? allNetworks : [currentChain.chainName] + + const totalTxCount = safe.nonce + + let lastTxAt: Date | null = null + + if (txHistory.data?.results) { + const transactions = txHistory.data.results.filter(isTransactionListItem).map((item) => item.transaction) + + if (transactions.length > 0 && transactions[0].timestamp) { + lastTxAt = new Date(transactions[0].timestamp) + } + } + + const properties = { + [MixPanelUserProperty.SAFE_ADDRESS]: safe.address.value, + [MixPanelUserProperty.SAFE_VERSION]: safe.version || 'unknown', + [MixPanelUserProperty.NUM_SIGNERS]: safe.owners.length, + [MixPanelUserProperty.THRESHOLD]: safe.threshold, + [MixPanelUserProperty.TOTAL_TX_COUNT]: totalTxCount, + [MixPanelUserProperty.LAST_TX_AT]: lastTxAt?.toISOString() || null, + [MixPanelUserProperty.NETWORKS]: networks, + } + + return { + properties, + networks, + } + }, [safe, safeLoaded, currentChain, txHistory, allNetworks]) +} diff --git a/apps/web/src/services/analytics/useMixpanel.ts b/apps/web/src/services/analytics/useMixpanel.ts new file mode 100644 index 0000000000..ecf761f271 --- /dev/null +++ b/apps/web/src/services/analytics/useMixpanel.ts @@ -0,0 +1,125 @@ +import { useEffect, useMemo } from 'react' +import { useTheme } from '@mui/material/styles' +import mixpanel from 'mixpanel-browser' +import { + mixpanelInit, + mixpanelSetBlockchainNetwork, + mixpanelSetDeviceType, + mixpanelSetSafeAddress, + mixpanelSetUserProperties, + mixpanelIdentify, + mixpanelSetEOAWalletLabel, + mixpanelSetEOAWalletAddress, + mixpanelSetEOAWalletNetwork, +} from './mixpanel' +import { useAppSelector } from '@/store' +import { CookieAndTermType, hasConsentFor } from '@/store/cookiesAndTermsSlice' +import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' +import { IS_PRODUCTION } from '@/config/constants' +import { useMediaQuery } from '@mui/material' +import { DeviceType } from './types' +import { MixPanelUserProperty } from './mixpanel-events' +import useSafeAddress from '@/hooks/useSafeAddress' +import useWallet from '@/hooks/wallets/useWallet' +import { useIsSpaceRoute } from '@/hooks/useIsSpaceRoute' +import { useMixPanelUserProperties } from './useMixPanelUserProperties' +import { useChain } from '@/hooks/useChains' +import useSafeInfo from '@/hooks/useSafeInfo' + +const useMixpanel = () => { + const isMixpanelEnabled = useHasFeature(FEATURES.MIXPANEL) + const isAnalyticsEnabled = useAppSelector((state) => hasConsentFor(state, CookieAndTermType.ANALYTICS)) + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + const isTablet = useMediaQuery(theme.breakpoints.down('md')) + const deviceType = useMemo(() => { + return isMobile ? DeviceType.MOBILE : isTablet ? DeviceType.TABLET : DeviceType.DESKTOP + }, [isMobile, isTablet]) + const safeAddress = useSafeAddress() + const wallet = useWallet() + const isSpaceRoute = useIsSpaceRoute() + const userProperties = useMixPanelUserProperties() + const { safe } = useSafeInfo() + const currentChain = useChain(safe?.chainId || '') + const walletChain = useChain(wallet?.chainId || '') + + useEffect(() => { + if (isMixpanelEnabled) { + mixpanelInit() + } + }, [isMixpanelEnabled]) + + useEffect(() => { + if (!isMixpanelEnabled) return + + if (isAnalyticsEnabled) { + mixpanel.opt_in_tracking() + if (!IS_PRODUCTION) { + console.info('[MixPanel] - User opted in') + } + } else { + mixpanel.opt_out_tracking() + if (!IS_PRODUCTION) { + console.info('[MixPanel] - User opted out') + } + } + }, [isMixpanelEnabled, isAnalyticsEnabled]) + + useEffect(() => { + if (currentChain) { + mixpanelSetBlockchainNetwork(currentChain.chainName) + } + }, [currentChain]) + + useEffect(() => { + mixpanelSetDeviceType(deviceType) + }, [deviceType]) + + useEffect(() => { + mixpanelSetSafeAddress(safeAddress) + + if (safeAddress && !isSpaceRoute) { + mixpanelIdentify(safeAddress) + } + }, [safeAddress, isSpaceRoute]) + + useEffect(() => { + if (wallet) { + const walletProperties: Record = {} + + if (wallet.label) { + walletProperties[MixPanelUserProperty.WALLET_LABEL] = wallet.label + } + if (wallet.address) { + walletProperties[MixPanelUserProperty.WALLET_ADDRESS] = wallet.address + } + + if (Object.keys(walletProperties).length > 0) { + mixpanelSetUserProperties(walletProperties) + } + + if (wallet.label) { + mixpanelSetEOAWalletLabel(wallet.label) + } + if (wallet.address) { + mixpanelSetEOAWalletAddress(wallet.address) + } + if (walletChain) { + mixpanelSetEOAWalletNetwork(walletChain.chainName) + } + } else { + mixpanelSetEOAWalletLabel('') + mixpanelSetEOAWalletAddress('') + mixpanelSetEOAWalletNetwork('') + } + }, [wallet, walletChain]) + + useEffect(() => { + if (!userProperties) return + + mixpanelSetUserProperties(userProperties.properties) + }, [userProperties]) +} + +export default useMixpanel diff --git a/apps/web/src/services/contracts/__tests__/deployments.test.ts b/apps/web/src/services/contracts/__tests__/deployments.test.ts index 38912aa1c7..deeafdaece 100644 --- a/apps/web/src/services/contracts/__tests__/deployments.test.ts +++ b/apps/web/src/services/contracts/__tests__/deployments.test.ts @@ -1,9 +1,9 @@ import * as safeDeployments from '@safe-global/safe-deployments' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -import * as deployments from '../deployments' -import { getLatestSafeVersion } from '@/utils/chains' +import * as deployments from '@safe-global/utils/services/contracts/deployments' import { chainBuilder } from '@/tests/builders/chains' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' const mainnetInfo = chainBuilder().with({ chainId: '1', l2: false, recommendedMasterCopyVersion: '1.4.1' }).build() const l2ChainInfo = chainBuilder().with({ chainId: '137', l2: true, recommendedMasterCopyVersion: '1.4.1' }).build() diff --git a/apps/web/src/services/contracts/__tests__/safeContracts.test.ts b/apps/web/src/services/contracts/__tests__/safeContracts.test.ts index d81dcc21c1..bc46d35c8b 100644 --- a/apps/web/src/services/contracts/__tests__/safeContracts.test.ts +++ b/apps/web/src/services/contracts/__tests__/safeContracts.test.ts @@ -1,10 +1,10 @@ import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' +import { _getMinimumMultiSendCallOnlyVersion } from '../safeContracts' import { - _getValidatedGetContractProps, isValidMasterCopy, - _getMinimumMultiSendCallOnlyVersion, + _getValidatedGetContractProps, isMigrationToL2Possible, -} from '../safeContracts' +} from '@safe-global/utils/services/contracts/safeContracts' import { safeInfoBuilder } from '@/tests/builders/safe' describe('safeContracts', () => { diff --git a/apps/web/src/services/contracts/safeContracts.ts b/apps/web/src/services/contracts/safeContracts.ts index 8a545fae69..ae4a59cb62 100644 --- a/apps/web/src/services/contracts/safeContracts.ts +++ b/apps/web/src/services/contracts/safeContracts.ts @@ -1,6 +1,6 @@ -import { _isL2 } from '@/services/contracts/deployments' +import { _isL2 } from '@safe-global/utils/services/contracts/deployments' import { getSafeProvider } from '@/services/tx/tx-sender/sdk' -import { type GetContractProps, SafeProvider } from '@safe-global/protocol-kit' +import { SafeProvider } from '@safe-global/protocol-kit' import { getCompatibilityFallbackHandlerContractInstance, getMultiSendCallOnlyContractInstance, @@ -9,45 +9,16 @@ import { getSignMessageLibContractInstance, } from '@safe-global/protocol-kit/dist/src/contracts/contractInstances' import type SafeBaseContract from '@safe-global/protocol-kit/dist/src/contracts/Safe/SafeBaseContract' -import { type ChainInfo, ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeVersion } from '@safe-global/safe-core-sdk-types' -import { assertValidSafeVersion, getSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { getSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' import semver from 'semver' -import { getLatestSafeVersion } from '@/utils/chains' -import { getSafeToL2MigrationDeployment } from '@safe-global/safe-deployments' - -// `UNKNOWN` is returned if the mastercopy does not match supported ones -// @see https://github.com/safe-global/safe-client-gateway/blob/main/src/routes/safes/handlers/safes.rs#L28-L31 -// https://github.com/safe-global/safe-client-gateway/blob/main/src/routes/safes/converters.rs#L77-L79 -export const isValidMasterCopy = (implementationVersionState: SafeInfo['implementationVersionState']): boolean => { - return implementationVersionState !== ImplementationVersionState.UNKNOWN -} - -export const isMigrationToL2Possible = (safe: SafeInfo): boolean => { - return ( - safe.nonce === 0 && - Boolean(getSafeToL2MigrationDeployment({ network: safe.chainId })?.networkAddresses[safe.chainId]) - ) -} - -export const _getValidatedGetContractProps = ( - safeVersion: SafeInfo['version'], -): Pick => { - assertValidSafeVersion(safeVersion) - - // SDK request here: https://github.com/safe-global/safe-core-sdk/issues/261 - // Remove '+L2'/'+Circles' metadata from version - const [noMetadataVersion] = safeVersion.split('+') - - return { - safeVersion: noMetadataVersion as SafeVersion, - } -} +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' +import { _getValidatedGetContractProps } from '@safe-global/utils/services/contracts/safeContracts' // GnosisSafe -const getGnosisSafeContract = async (safe: SafeInfo, safeProvider: SafeProvider) => { +const getGnosisSafeContract = async (safe: SafeState, safeProvider: SafeProvider) => { return getSafeContractInstance( _getValidatedGetContractProps(safe.version).safeVersion, safeProvider, @@ -55,7 +26,7 @@ const getGnosisSafeContract = async (safe: SafeInfo, safeProvider: SafeProvider) ) } -export const getReadOnlyCurrentGnosisSafeContract = async (safe: SafeInfo): Promise> => { +export const getReadOnlyCurrentGnosisSafeContract = async (safe: SafeState): Promise> => { const safeSDK = getSafeSDK() if (!safeSDK) { throw new Error('Safe SDK not found.') @@ -66,7 +37,7 @@ export const getReadOnlyCurrentGnosisSafeContract = async (safe: SafeInfo): Prom return getGnosisSafeContract(safe, safeProvider) } -export const getCurrentGnosisSafeContract = async (safe: SafeInfo, provider: string) => { +export const getCurrentGnosisSafeContract = async (safe: SafeState, provider: string) => { const safeProvider = new SafeProvider({ provider }) return getGnosisSafeContract(safe, safeProvider) @@ -74,7 +45,7 @@ export const getCurrentGnosisSafeContract = async (safe: SafeInfo, provider: str export const getReadOnlyGnosisSafeContract = async ( chain: ChainInfo, - safeVersion: SafeInfo['version'], + safeVersion: SafeState['version'], isL1?: boolean, ) => { const version = safeVersion ?? getLatestSafeVersion(chain) @@ -94,7 +65,7 @@ export const getReadOnlyGnosisSafeContract = async ( // MultiSend -export const _getMinimumMultiSendCallOnlyVersion = (safeVersion: SafeInfo['version']) => { +export const _getMinimumMultiSendCallOnlyVersion = (safeVersion: SafeState['version']) => { const INITIAL_CALL_ONLY_VERSION = '1.3.0' if (!safeVersion) { @@ -104,7 +75,7 @@ export const _getMinimumMultiSendCallOnlyVersion = (safeVersion: SafeInfo['versi return semver.gte(safeVersion, INITIAL_CALL_ONLY_VERSION) ? safeVersion : INITIAL_CALL_ONLY_VERSION } -export const getReadOnlyMultiSendCallOnlyContract = async (safeVersion: SafeInfo['version']) => { +export const getReadOnlyMultiSendCallOnlyContract = async (safeVersion: SafeState['version']) => { const safeSDK = getSafeSDK() if (!safeSDK) { throw new Error('Safe SDK not found.') @@ -117,20 +88,19 @@ export const getReadOnlyMultiSendCallOnlyContract = async (safeVersion: SafeInfo // GnosisSafeProxyFactory -export const getReadOnlyProxyFactoryContract = async (safeVersion: SafeInfo['version'], contractAddress?: string) => { +export const getReadOnlyProxyFactoryContract = async (safeVersion: SafeState['version'], contractAddress?: string) => { const safeProvider = getSafeProvider() return getSafeProxyFactoryContractInstance( _getValidatedGetContractProps(safeVersion).safeVersion, safeProvider, - safeProvider.getExternalProvider(), contractAddress, ) } // Fallback handler -export const getReadOnlyFallbackHandlerContract = async (safeVersion: SafeInfo['version']) => { +export const getReadOnlyFallbackHandlerContract = async (safeVersion: SafeState['version']) => { const safeProvider = getSafeProvider() return getCompatibilityFallbackHandlerContractInstance( @@ -141,7 +111,7 @@ export const getReadOnlyFallbackHandlerContract = async (safeVersion: SafeInfo[' // Sign messages deployment -export const getReadOnlySignMessageLibContract = async (safeVersion: SafeInfo['version']) => { +export const getReadOnlySignMessageLibContract = async (safeVersion: SafeState['version']) => { const safeSDK = getSafeSDK() if (!safeSDK) { throw new Error('Safe SDK not found.') diff --git a/apps/web/src/services/contracts/spendingLimitContracts.ts b/apps/web/src/services/contracts/spendingLimitContracts.ts index ba41f6c34b..044c2bd12b 100644 --- a/apps/web/src/services/contracts/spendingLimitContracts.ts +++ b/apps/web/src/services/contracts/spendingLimitContracts.ts @@ -1,10 +1,10 @@ import { getAllowanceModuleDeployment } from '@safe-global/safe-modules-deployments' -import type { AllowanceModule } from '@/types/contracts' -import { AllowanceModule__factory } from '@/types/contracts' +import type { AllowanceModule } from '@safe-global/utils/types/contracts' +import { AllowanceModule__factory } from '@safe-global/utils/types/contracts' import type { JsonRpcProvider, JsonRpcSigner } from 'ethers' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { sameAddress } from '@/utils/addresses' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { sameAddress } from '@safe-global/utils/utils/addresses' enum ALLOWANCE_MODULE_VERSIONS { '0.1.0' = '0.1.0', @@ -13,7 +13,7 @@ enum ALLOWANCE_MODULE_VERSIONS { const ALL_VERSIONS = [ALLOWANCE_MODULE_VERSIONS['0.1.0'], ALLOWANCE_MODULE_VERSIONS['0.1.1']] -const getDeployment = (chainId: string, modules: SafeInfo['modules']) => { +const getDeployment = (chainId: string, modules: SafeState['modules']) => { if (!modules?.length) return for (let version of ALL_VERSIONS) { const deployment = getAllowanceModuleDeployment({ network: chainId, version }) @@ -31,7 +31,7 @@ export const getLatestSpendingLimitAddress = (chainId: string): string | undefin export const getDeployedSpendingLimitModuleAddress = ( chainId: string, - modules: SafeInfo['modules'], + modules: SafeState['modules'], ): string | undefined => { const deployment = getDeployment(chainId, modules) return deployment?.networkAddresses[chainId] @@ -40,7 +40,7 @@ export const getDeployedSpendingLimitModuleAddress = ( // SDK request here: https://github.com/safe-global/safe-core-sdk/issues/263 export const getSpendingLimitContract = ( chainId: string, - modules: SafeInfo['modules'], + modules: SafeState['modules'], provider: JsonRpcProvider | JsonRpcSigner, ): AllowanceModule => { const allowanceModuleDeployment = getDeployment(chainId, modules) diff --git a/apps/web/src/services/datadog/index.ts b/apps/web/src/services/datadog/index.ts new file mode 100644 index 0000000000..903dc8637b --- /dev/null +++ b/apps/web/src/services/datadog/index.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react' +import { datadogLogs } from '@datadog/browser-logs' +import { DATADOG_CLIENT_TOKEN, IS_PRODUCTION } from '@/config/constants' + +let isDatadogInitialized = false + +function initDatadog() { + if (isDatadogInitialized) { + return + } + if (!DATADOG_CLIENT_TOKEN) { + console.warn('Datadog client token is not set. Skipping Datadog initialization.') + return + } + if (!IS_PRODUCTION) { + console.warn('Datadog is not initialized in non-production environments. Skipping Datadog initialization.') + return + } + + datadogLogs.init({ + clientToken: DATADOG_CLIENT_TOKEN, + site: 'datadoghq.eu', + forwardErrorsToLogs: true, + sessionSampleRate: 100, + }) + + isDatadogInitialized = true +} + +export function useDatadog() { + useEffect(() => { + initDatadog() + }, []) +} + +export const logger = datadogLogs.logger diff --git a/apps/web/src/services/ens/index.ts b/apps/web/src/services/ens/index.ts index 60990a26c7..b4bba85213 100644 --- a/apps/web/src/services/ens/index.ts +++ b/apps/web/src/services/ens/index.ts @@ -1,6 +1,6 @@ import { type Provider } from 'ethers' import { logError } from '../exceptions' -import ErrorCodes from '../exceptions/ErrorCodes' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' type EthersError = Error & { reason?: string diff --git a/apps/web/src/services/exceptions/__tests__/utils.test.ts b/apps/web/src/services/exceptions/__tests__/utils.test.ts index 40984da620..14be5aeb38 100644 --- a/apps/web/src/services/exceptions/__tests__/utils.test.ts +++ b/apps/web/src/services/exceptions/__tests__/utils.test.ts @@ -1,4 +1,4 @@ -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' describe('utils', () => { describe('asError', () => { diff --git a/apps/web/src/services/exceptions/index.ts b/apps/web/src/services/exceptions/index.ts index 5166028b2b..2f8195d650 100644 --- a/apps/web/src/services/exceptions/index.ts +++ b/apps/web/src/services/exceptions/index.ts @@ -1,7 +1,8 @@ import { sentryCaptureException } from '@/services/sentry' import { IS_PRODUCTION } from '@/config/constants' -import ErrorCodes from './ErrorCodes' -import { asError } from './utils' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' +import { asError } from '@safe-global/utils/services/exceptions/utils' +import { logger } from '../datadog' export class CodedException extends Error { public readonly code: number @@ -39,6 +40,13 @@ export class CodedException extends Error { // Log only the message on prod, and the full error on dev console.error(IS_PRODUCTION ? this.message : this) + + if (IS_PRODUCTION) { + // Log to Datadog + logger.error(this.message, { + code: this.code, + }) + } } public track(): void { diff --git a/apps/web/src/services/local-storage/Storage.ts b/apps/web/src/services/local-storage/Storage.ts index f315f9dd84..8413c563bf 100644 --- a/apps/web/src/services/local-storage/Storage.ts +++ b/apps/web/src/services/local-storage/Storage.ts @@ -1,6 +1,6 @@ import { LS_NAMESPACE } from '@/config/constants' import { Errors, logError } from '@/services/exceptions' -import { asError } from '../exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { reviver, replacer } from './storageHelpers' type BrowserStorage = typeof localStorage | typeof sessionStorage diff --git a/apps/web/src/services/local-storage/useLocalStorage.ts b/apps/web/src/services/local-storage/useLocalStorage.ts index 3c65e4b0c7..9a9c97137c 100644 --- a/apps/web/src/services/local-storage/useLocalStorage.ts +++ b/apps/web/src/services/local-storage/useLocalStorage.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect } from 'react' -import ExternalStore from '../ExternalStore' +import ExternalStore from '@safe-global/utils/services/ExternalStore' import session from './session' import local from './local' import type Storage from './Storage' diff --git a/apps/web/src/services/ls-migration/addedSafes.ts b/apps/web/src/services/ls-migration/addedSafes.ts index 143f93ee23..148fdb4b25 100644 --- a/apps/web/src/services/ls-migration/addedSafes.ts +++ b/apps/web/src/services/ls-migration/addedSafes.ts @@ -1,7 +1,7 @@ import { type AddedSafesState, type AddedSafesOnChain } from '@/store/addedSafesSlice' import type { LOCAL_STORAGE_DATA } from './common' import { parseLsValue } from './common' -import { isChecksummedAddress } from '@/utils/addresses' +import { isChecksummedAddress } from '@safe-global/utils/utils/addresses' import isObject from 'lodash/isObject' import type { AddressEx } from '@safe-global/safe-gateway-typescript-sdk' diff --git a/apps/web/src/services/ls-migration/addressBook.ts b/apps/web/src/services/ls-migration/addressBook.ts index dfe4ba6304..681f131028 100644 --- a/apps/web/src/services/ls-migration/addressBook.ts +++ b/apps/web/src/services/ls-migration/addressBook.ts @@ -1,6 +1,6 @@ import chains from '@/config/chains' import { type AddressBookState } from '@/store/addressBookSlice' -import { isChecksummedAddress } from '@/utils/addresses' +import { isChecksummedAddress } from '@safe-global/utils/utils/addresses' import type { LOCAL_STORAGE_DATA } from './common' import { parseLsValue } from './common' diff --git a/apps/web/src/services/onboard.ts b/apps/web/src/services/onboard.ts index 7ed0c0bb30..3beeb182cc 100644 --- a/apps/web/src/services/onboard.ts +++ b/apps/web/src/services/onboard.ts @@ -2,9 +2,9 @@ import Onboard, { type OnboardAPI } from '@web3-onboard/core' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { getAllWallets } from '@/hooks/wallets/wallets' import { getRpcServiceUrl } from '@/hooks/wallets/web3' -import type { EnvState } from '@/store/settingsSlice' import { numberToHex } from '@/utils/hex' import { BRAND_NAME } from '@/config/constants' +import type { EnvState } from '@safe-global/store/settingsSlice' let onboard: OnboardAPI | null = null diff --git a/apps/web/src/services/onboard/ledger-module.ts b/apps/web/src/services/onboard/ledger-module.ts new file mode 100644 index 0000000000..beb1487c67 --- /dev/null +++ b/apps/web/src/services/onboard/ledger-module.ts @@ -0,0 +1,402 @@ +import type { DmkError, ExecuteDeviceActionReturnType } from '@ledgerhq/device-management-kit' +import { makeError } from 'ethers' +import type { + GetAddressDAOutput, + SignPersonalMessageDAOutput, + SignTransactionDAOutput, + SignTypedDataDAOutput, + TypedData, +} from '@ledgerhq/device-signer-kit-ethereum' +import type { Chain, WalletInit, WalletInterface } from '@web3-onboard/common' +import type { Account, Asset, BasePath, DerivationPath, ScanAccountsOptions } from '@web3-onboard/hw-common' +import type { Subscription } from 'rxjs' + +const LEDGER_LIVE_PATH: DerivationPath = "44'/60'" +const LEDGER_LEGACY_PATH: DerivationPath = "44'/60'/0'" + +const DEFAULT_BASE_PATHS: Array = [ + { + label: 'Ledger Live', + value: LEDGER_LIVE_PATH, + }, + { + label: 'Ledger Legacy', + value: LEDGER_LEGACY_PATH, + }, +] + +const DEFAULT_ASSETS: Array = [ + { + label: 'ETH', + }, +] + +export function ledgerModule(): WalletInit { + return () => { + return { + label: 'Ledger', + getIcon: async (): Promise => ` + + + +`, + getInterface: async ({ chains, EventEmitter }): Promise => { + const DEFAULT_CHAIN = chains[0] + + const { BigNumber } = await import('@ethersproject/bignumber') + const { hexaStringToBuffer } = await import('@ledgerhq/device-management-kit') + const { createEIP1193Provider, ProviderRpcError, ProviderRpcErrorCode } = await import('@web3-onboard/common') + const { accountSelect, getHardwareWalletProvider } = await import('@web3-onboard/hw-common') + const { Signature, Transaction, JsonRpcProvider } = await import('ethers') + + const eventEmitter = new EventEmitter() + const ledgerSdk = await getLedgerSdk() + + /* -------------------------------------------------------------------------- */ + /* State */ + /* -------------------------------------------------------------------------- */ + + let currentChain = DEFAULT_CHAIN + let currentAccount: Account | null = null + + // Sets the current chain and emits the chainChanged event + function setCurrentChain(chainId: Chain['id']): void { + const newChain = chains.find((chain) => chain.id === chainId) + if (!newChain) { + throw new ProviderRpcError({ + code: ProviderRpcErrorCode.UNRECOGNIZED_CHAIN_ID, + message: `Unrecognized chain ID: ${chainId}`, + }) + } + currentChain = newChain + eventEmitter.emit('chainChanged', currentChain.id) + } + + // Sets the current account and emits the accountsChanged event + function setCurrentAccount(account: Account): void { + currentAccount = account + eventEmitter.emit('accountsChanged', [currentAccount.address]) + } + + // Clears the current account and emits the accountsChanged event + function clearCurrentAccount(): void { + currentAccount = null + eventEmitter.emit('accountsChanged', []) + } + + // Clears the current chain and emits the chainChanged event + function clearCurrentChain(): void { + currentChain = DEFAULT_CHAIN + eventEmitter.emit('chainChanged', currentChain.id) + } + + // Gets the asserted derivation path from the current account + function getAssertedDerivationPath(): DerivationPath { + if (!currentAccount?.derivationPath) { + throw new ProviderRpcError({ + code: -32000, // Method handler crashed + message: 'No derivation path found', + }) + } + return currentAccount.derivationPath + } + + /* -------------------------------------------------------------------------- */ + /* EIP-1193 provider */ + /* -------------------------------------------------------------------------- */ + + const eip1193Provider = createEIP1193Provider( + getHardwareWalletProvider(() => { + const rpcUrl = currentChain.rpcUrl + if (!rpcUrl) { + throw new ProviderRpcError({ + code: ProviderRpcErrorCode.UNRECOGNIZED_CHAIN_ID, + message: `No RPC found for chain ID: ${currentChain.id}`, + }) + } + return rpcUrl + }), + { + eth_requestAccounts: async () => { + const accounts = await getAccounts() + return [accounts[0].address] + }, + eth_selectAccounts: async () => { + const accounts = await getAccounts() + return accounts.map((account) => account.address) + }, + eth_accounts: async () => { + if (!currentAccount) { + return [] + } + return [currentAccount.address] + }, + eth_chainId: async () => { + return currentChain.id + }, + eth_signTransaction: async (args) => { + const txParams = args.params[0] + + const gasLimit = txParams.gas ?? txParams.gasLimit + const nonce = + txParams.nonce ?? + // Safe creation does not provide nonce + ((await eip1193Provider.request({ + method: 'eth_getTransactionCount', + // Take pending transactions into account + params: [currentAccount!.address, 'pending'], + })) as string) + + const transaction = Transaction.from({ + chainId: BigInt(currentChain.id), + data: txParams.data, + gasLimit: gasLimit ? BigInt(gasLimit) : null, + gasPrice: txParams.gasPrice ? BigInt(txParams.gasPrice) : null, + maxFeePerGas: txParams.maxFeePerGas ? BigInt(txParams.maxFeePerGas) : null, + maxPriorityFeePerGas: txParams.maxPriorityFeePerGas ? BigInt(txParams.maxPriorityFeePerGas) : null, + nonce: parseInt(nonce, 16), + to: txParams.to, + value: txParams.value ? BigInt(txParams.value) : null, + }) + + transaction.signature = await ledgerSdk.signTransaction( + getAssertedDerivationPath(), + hexaStringToBuffer(transaction.unsignedSerialized)!, + ) + + return transaction.serialized + }, + eth_sendTransaction: async (args) => { + const signedTransaction = await eip1193Provider.request({ + method: 'eth_signTransaction', + params: args.params, + }) + return (await eip1193Provider.request({ + method: 'eth_sendRawTransaction', + params: [signedTransaction], + })) as string + }, + eth_sign: async (args) => { + // The Safe requires transactions be signed as bytes, but eth_sign is only used by + // the Transaction Service, e.g. notification registration. We therefore sign + // messages as is to avoid unreadable byte notation (e.g. \xef\xbe\xad\xde). Instead, + // the Ledger device shows plain hex (e.g. 0xdeadbeef). + const message = args.params[1] + const signature = await ledgerSdk.signMessage(getAssertedDerivationPath(), message) + return Signature.from(signature).serialized + }, + personal_sign: async (args) => { + // personal_sign params are the inverse of eth_sign + const [message, address] = args.params + return await eip1193Provider.request({ + method: 'eth_sign', + params: [address, message], + }) + }, + eth_signTypedData: async (args) => { + const typedData = JSON.parse(args.params[1]) + const signature = await ledgerSdk.signTypedData(getAssertedDerivationPath(), typedData) + return Signature.from(signature).serialized + }, + // @ts-expect-error createEIP1193Provider does not allow overriding eth_signTypedData_v3 + eth_signTypedData_v3: async (args) => { + return await eip1193Provider.request({ method: 'eth_signTypedData', params: args.params }) + }, + // @ts-expect-error createEIP1193Provider does not allow overriding eth_signTypedData_v4 + eth_signTypedData_v4: async (args) => { + return await eip1193Provider.request({ method: 'eth_signTypedData', params: args.params }) + }, + wallet_switchEthereumChain: async (args) => { + const chainId = args.params[0].chainId + setCurrentChain(chainId) + return null + }, + }, + ) + + // Disconnects Ledger device and clears current account and chain + eip1193Provider.disconnect = async () => { + await ledgerSdk.disconnect() + clearCurrentAccount() + clearCurrentChain() + } + + // createEIP1193Provider does not bind EventEmitter + eip1193Provider.on = eventEmitter.on.bind(eventEmitter) + eip1193Provider.removeListener = eventEmitter.removeListener.bind(eventEmitter) + + /* -------------------------------------------------------------------------- */ + /* Web3-Onboard account selection */ + /* -------------------------------------------------------------------------- */ + + /** + * Gets a list of derived accounts from Ledger device for selection + * and sets the first account as the current account + */ + async function getAccounts(): Promise> { + const accounts = await accountSelect({ + basePaths: DEFAULT_BASE_PATHS, + assets: DEFAULT_ASSETS, + chains, + scanAccounts: deriveAccounts, + }) + + if (accounts.length > 0) { + setCurrentAccount(accounts[0]) + } + + return accounts + } + + /** + * Gets a list of derived accounts from Ledger device for selection + * If a custom derivation path is provided, one account is returned + * otherwise a minimum of 5 accounts are returned + */ + async function deriveAccounts(args: ScanAccountsOptions): Promise> { + const MAX_ZERO_BALANCE_ACCOUNTS = 5 + + setCurrentChain(args.chainId) + + const provider = new JsonRpcProvider(currentChain.rpcUrl) + + // Only return exact account from custom derivation + if (args.derivationPath !== LEDGER_LIVE_PATH && args.derivationPath !== LEDGER_LEGACY_PATH) { + const account = await deriveAccount({ ...args, provider }) + return [account] + } + + const accounts = [] + + let zeroBalanceAccounts = 0 + let index = 0 + + // Iterates until 0 balance account, then add 4 more 0 balance accounts after + while (zeroBalanceAccounts < MAX_ZERO_BALANCE_ACCOUNTS) { + const account = await deriveAccount({ + derivationPath: + args.derivationPath === LEDGER_LIVE_PATH + ? `${args.derivationPath}/${index}'/0/0` + : `${args.derivationPath}/${index}`, + provider, + asset: args.asset, + }) + accounts.push(account) + + if (account.balance.value.isZero()) { + zeroBalanceAccounts++ + } else { + zeroBalanceAccounts = 0 + } + + index++ + } + + return accounts + } + + // Gets derived account from Ledger device for selection in Web3-Onboard + async function deriveAccount(args: { + derivationPath: string + provider: InstanceType + asset: Asset + }): Promise { + const { address } = await ledgerSdk.getAddress(args.derivationPath) + const balance = await args.provider.getBalance(address) + + return { + derivationPath: args.derivationPath, + address, + balance: { + asset: args.asset.label, + value: BigNumber.from(balance), + }, + } + } + + return { + provider: eip1193Provider, + } + }, + } + } +} + +const enum LedgerErrorCode { + REJECTED = '6985', +} + +// Promisified Ledger SDK +async function getLedgerSdk() { + const { DeviceManagementKitBuilder } = await import('@ledgerhq/device-management-kit') + const { webHidTransportFactory, webHidIdentifier } = await import('@ledgerhq/device-transport-kit-web-hid') + const { SignerEthBuilder } = await import('@ledgerhq/device-signer-kit-ethereum') + const { lastValueFrom } = await import('rxjs') + + // Get connected device and create signer + const dmk = new DeviceManagementKitBuilder().addTransport(webHidTransportFactory).build() + const device = await lastValueFrom(dmk.startDiscovering({ transport: webHidIdentifier })) + const sessionId = await dmk.connect({ device }) + const signer = new SignerEthBuilder({ dmk, sessionId }).build() + + return { + disconnect: async (): Promise => { + return dmk.disconnect({ sessionId }) + }, + getAddress: async (derivationPath: string): Promise => { + return waitForAction(signer.getAddress(derivationPath, { checkOnDevice: false })) + }, + signMessage: async (derivationPath: string, message: string | Uint8Array): Promise => { + return waitForAction(signer.signMessage(derivationPath, message)) + }, + signTransaction: async (derivationPath: string, transaction: Uint8Array): Promise => { + return waitForAction(signer.signTransaction(derivationPath, transaction)) + }, + signTypedData: async (derivationPath: string, typedData: TypedData): Promise => { + return waitForAction(signer.signTypedData(derivationPath, typedData)) + }, + } +} + +async function waitForAction({ + observable, +}: ExecuteDeviceActionReturnType): Promise { + const { DeviceActionStatus } = await import('@ledgerhq/device-management-kit') + + let subscription: Subscription | undefined + + try { + return await new Promise((resolve, reject) => { + subscription = observable.subscribe({ + next: (actionState) => { + if (actionState.status === DeviceActionStatus.Completed) { + resolve(actionState.output) + } else if (actionState.status === DeviceActionStatus.Error) { + reject(mapEthersError(actionState.error)) + } else { + // Awaiting user action, e.g. device to be unlocked. We could throw + // an explicit error message but we keep the signing request alive + } + }, + }) + }) + } finally { + subscription?.unsubscribe() + } +} + +function mapEthersError(error: DmkError) { + const isRejection = 'errorCode' in error ? error.errorCode === LedgerErrorCode.REJECTED : false + + if (!isRejection) { + return makeError(error.message ?? 'unknown', 'UNKNOWN_ERROR', { + info: error, + }) + } + + return makeError('user rejected action', 'ACTION_REJECTED', { + action: 'unknown', + reason: 'rejected', + info: error, + }) +} diff --git a/apps/web/src/services/private-key-module/index.ts b/apps/web/src/services/private-key-module/index.ts index 303617128c..f5ad1719c3 100644 --- a/apps/web/src/services/private-key-module/index.ts +++ b/apps/web/src/services/private-key-module/index.ts @@ -100,8 +100,7 @@ const PrivateKeyModule = (chainId: ChainInfo['chainId'], rpcUri: ChainInfo['rpcU }, personal_sign: async ({ params }) => { - const signedMessage = wallet.signingKey.sign(params[0]) - return signedMessage.serialized + return wallet.signMessage(params[0]) }, eth_signTypedData: async ({ params }) => { @@ -116,10 +115,26 @@ const PrivateKeyModule = (chainId: ChainInfo['chainId'], rpcUri: ChainInfo['rpcU // @ts-ignore eth_signTypedData_v4: async ({ params }) => { const [, typedData] = params + + let parsedTypedData + try { + parsedTypedData = JSON.parse(typedData) + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error('Failed to parse typedData: ' + error.message) + } else { + throw new Error('Failed to parse typedData: Unknown error') + } + } + + if (!parsedTypedData || !parsedTypedData.domain || !parsedTypedData.types || !parsedTypedData.message) { + throw new Error('Invalid parameters for eth_signTypedData_v4') + } + return await wallet.signTypedData( - typedData.domain, - { [typedData.primaryType]: typedData.types[typedData.primaryType] }, - typedData.message, + parsedTypedData.domain, + { [parsedTypedData.primaryType]: parsedTypedData.types[parsedTypedData.primaryType] }, + parsedTypedData.message, ) }, diff --git a/apps/web/src/services/private-key-module/pk-popup-store.ts b/apps/web/src/services/private-key-module/pk-popup-store.ts index 5779540b50..9d77db7708 100644 --- a/apps/web/src/services/private-key-module/pk-popup-store.ts +++ b/apps/web/src/services/private-key-module/pk-popup-store.ts @@ -1,4 +1,4 @@ -import ExternalStore from '@/services/ExternalStore' +import ExternalStore from '@safe-global/utils/services/ExternalStore' import { sessionItem } from '@/services/local-storage/session' type PkModulePopupStore = { diff --git a/apps/web/src/services/safe-apps/AppCommunicator.ts b/apps/web/src/services/safe-apps/AppCommunicator.ts index 913db1709d..d286087be4 100644 --- a/apps/web/src/services/safe-apps/AppCommunicator.ts +++ b/apps/web/src/services/safe-apps/AppCommunicator.ts @@ -1,7 +1,7 @@ import type { RefObject } from 'react' import type { SDKMessageEvent, MethodToResponse, ErrorResponse, RequestId } from '@safe-global/safe-apps-sdk' import { getSDKVersion, Methods, MessageFormatter } from '@safe-global/safe-apps-sdk' -import { asError } from '../exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' type MessageHandler = ( msg: SDKMessageEvent, diff --git a/apps/web/src/services/safe-apps/manifest.ts b/apps/web/src/services/safe-apps/manifest.ts index b4fa97fdb2..6d058279d3 100644 --- a/apps/web/src/services/safe-apps/manifest.ts +++ b/apps/web/src/services/safe-apps/manifest.ts @@ -1,5 +1,5 @@ import type { AllowedFeatures, SafeAppDataWithPermissions } from '@/components/safe-apps/types' -import { isRelativeUrl, trimTrailingSlash } from '@/utils/url' +import { isRelativeUrl, trimTrailingSlash, stripUrlParams } from '@/utils/url' import { SafeAppAccessPolicyTypes } from '@safe-global/safe-gateway-typescript-sdk' type AppManifestIcon = { @@ -56,7 +56,9 @@ const getAppLogoUrl = (appUrl: string, { icons = [], iconPath = '' }: AppManifes } const fetchAppManifest = async (appUrl: string, timeout = 5000): Promise => { - const normalizedUrl = trimTrailingSlash(appUrl) + // Strip URL parameters for fetching the manifest + const baseUrl = stripUrlParams(appUrl) + const normalizedUrl = trimTrailingSlash(baseUrl) const manifestUrl = `${normalizedUrl}/manifest.json` // A lot of apps are hosted on IPFS and IPFS never times out, so we add our own timeout @@ -89,7 +91,10 @@ const fetchSafeAppFromManifest = async ( appUrl: string, currentChainId: string, ): Promise => { - const normalizedAppUrl = trimTrailingSlash(appUrl) + // Strip URL parameters for the normalized app URL but keep the original URL for the iframe + const baseUrl = stripUrlParams(appUrl) + const normalizedAppUrl = trimTrailingSlash(baseUrl) + // Use the base URL to fetch the manifest const appManifest = await fetchAppManifest(appUrl) if (!isAppManifestValid(appManifest)) { @@ -98,10 +103,14 @@ const fetchSafeAppFromManifest = async ( const iconUrl = getAppLogoUrl(normalizedAppUrl, appManifest) + // Preserve the original URL with parameters + const originalUrl = appUrl + return { // Must satisfy https://docs.djangoproject.com/en/5.0/ref/models/fields/#positiveintegerfield id: Math.round(Math.random() * 1e9 + 1e6), - url: normalizedAppUrl, + url: normalizedAppUrl, // Store the base URL without parameters for matching + originalUrl, // Store the original URL with parameters name: appManifest.name, description: appManifest.description, accessControl: { type: SafeAppAccessPolicyTypes.NoRestrictions }, diff --git a/apps/web/src/services/safe-messages/__tests__/safeMsgSender.test.ts b/apps/web/src/services/safe-messages/__tests__/safeMsgSender.test.ts index c8a92b0cbf..2324285c82 100644 --- a/apps/web/src/services/safe-messages/__tests__/safeMsgSender.test.ts +++ b/apps/web/src/services/safe-messages/__tests__/safeMsgSender.test.ts @@ -4,7 +4,7 @@ import type { JsonRpcSigner } from 'ethers' import { zeroPadBytes } from 'ethers' import { dispatchSafeMsgConfirmation, dispatchSafeMsgProposal } from '@/services/safe-messages/safeMsgSender' -import * as utils from '@/utils/safe-messages' +import * as utils from '@safe-global/utils/utils/safe-messages' import * as events from '@/services/safe-messages/safeMsgEvents' import * as sdk from '@/services/tx/tx-sender/sdk' import { zeroPadValue } from 'ethers' @@ -74,7 +74,7 @@ describe('safeMsgSender', () => { types: { [type: string]: { name: string; type: string }[] } domain: any message: any - primaryType?: string + primaryType: string } = { types: { Test: [{ name: 'test', type: 'string' }], @@ -87,6 +87,7 @@ describe('safeMsgSender', () => { message: { test: 'Hello World!', }, + primaryType: 'Test', } const origin = 'http://example.com' diff --git a/apps/web/src/services/safe-messages/safeMsgNotifications.ts b/apps/web/src/services/safe-messages/safeMsgNotifications.ts index 13ffc4892c..f01597895c 100644 --- a/apps/web/src/services/safe-messages/safeMsgNotifications.ts +++ b/apps/web/src/services/safe-messages/safeMsgNotifications.ts @@ -1,7 +1,7 @@ -import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { safeMsgDispatch, SafeMsgEvent } from './safeMsgEvents' -const isMessageFullySigned = (message: SafeMessage): message is SafeMessage & { preparedSignature: string } => { +const isMessageFullySigned = (message: MessageItem): message is MessageItem & { preparedSignature: string } => { return message.confirmationsSubmitted >= message.confirmationsRequired && !!message.preparedSignature } @@ -14,7 +14,7 @@ const isMessageFullySigned = (message: SafeMessage): message is SafeMessage & { * @param requestId */ export const dispatchPreparedSignature = async ( - message: SafeMessage, + message: MessageItem, safeMessageHash: string, onClose: () => void, requestId?: string, diff --git a/apps/web/src/services/safe-messages/safeMsgSender.ts b/apps/web/src/services/safe-messages/safeMsgSender.ts index 8d1f1265ca..6396ba5594 100644 --- a/apps/web/src/services/safe-messages/safeMsgSender.ts +++ b/apps/web/src/services/safe-messages/safeMsgSender.ts @@ -1,12 +1,17 @@ +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { proposeSafeMessage, confirmSafeMessage } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeInfo, SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import type { Eip1193Provider } from 'ethers' import { safeMsgDispatch, SafeMsgEvent } from './safeMsgEvents' -import { generateSafeMessageHash, isEIP712TypedData, tryOffChainMsgSigning } from '@/utils/safe-messages' -import { normalizeTypedData } from '@/utils/web3' +import { + generateSafeMessageHash, + isEIP712TypedData, + tryOffChainMsgSigning, +} from '@safe-global/utils/utils/safe-messages' +import { normalizeTypedData } from '@safe-global/utils/utils/web3' import { getAssertedChainSigner } from '@/services/tx/tx-sender/sdk' -import { asError } from '../exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' export const dispatchSafeMsgProposal = async ({ provider, @@ -15,8 +20,8 @@ export const dispatchSafeMsgProposal = async ({ origin = '', }: { provider: Eip1193Provider - safe: SafeInfo - message: SafeMessage['message'] + safe: SafeState + message: MessageItem['message'] origin: string | undefined }): Promise => { const messageHash = generateSafeMessageHash(safe, message) @@ -55,8 +60,8 @@ export const dispatchSafeMsgConfirmation = async ({ message, }: { provider: Eip1193Provider - safe: SafeInfo - message: SafeMessage['message'] + safe: SafeState + message: MessageItem['message'] }): Promise => { const messageHash = generateSafeMessageHash(safe, message) diff --git a/apps/web/src/services/safe-wallet-provider/index.test.ts b/apps/web/src/services/safe-wallet-provider/index.test.ts index d59cd5c741..c2e5985c61 100644 --- a/apps/web/src/services/safe-wallet-provider/index.test.ts +++ b/apps/web/src/services/safe-wallet-provider/index.test.ts @@ -1,7 +1,7 @@ // Unit tests for the SafeWalletProvider class import { faker } from '@faker-js/faker' import { SafeWalletProvider } from '.' -import { ERC20__factory } from '@/types/contracts' +import { ERC20__factory } from '@safe-global/utils/types/contracts' import { numberToHex } from '@/utils/hex' import type { TransactionReceipt } from 'ethers' @@ -520,9 +520,9 @@ describe('SafeWalletProvider', () => { const params = [ { - chainId: 1, + chainId: '0x1', version: '1.0', - from: faker.finance.ethereumAddress(), + from: safe.safeAddress, calls: [ { data: '0x123', to: faker.finance.ethereumAddress(), value: '0x123' }, { data: '0x456', to: faker.finance.ethereumAddress(), value: '0x1' }, @@ -570,7 +570,7 @@ describe('SafeWalletProvider', () => { const params = [ { - chainId: 1, + chainId: '0x1', version: '1.0', from: safe.safeAddress, calls: [ @@ -656,18 +656,20 @@ describe('SafeWalletProvider', () => { id: 1, jsonrpc: '2.0', result: { + id: params[0], + chainId: '0x1', receipts: [ { blockHash: receipt.blockHash, blockNumber: receipt.blockNumber, - chainId: '0x1', gasUsed: receipt.gasUsed, logs: receipt.logs, status: '0x1', transactionHash: '0x123', }, ], - status: 'CONFIRMED', + status: 200, + version: '1.0', }, }) }) @@ -708,18 +710,20 @@ describe('SafeWalletProvider', () => { id: 1, jsonrpc: '2.0', result: { + chainId: '0x1', + id: params[0], receipts: [ { blockHash: numberToHex(Number(receipt.blockHash)), blockNumber: numberToHex(Number(receipt.blockNumber)), - chainId: '0x1', gasUsed: numberToHex(receipt.gasUsed), logs: receipt.logs, status: '0x1', transactionHash: '0x123', }, ], - status: 'CONFIRMED', + status: 200, + version: '1.0', }, }) }) @@ -748,7 +752,10 @@ describe('SafeWalletProvider', () => { id: 1, jsonrpc: '2.0', result: { - status: 'PENDING', + chainId: '0x1', + id: params[0], + status: 100, + version: '1.0', }, }) }) @@ -783,7 +790,10 @@ describe('SafeWalletProvider', () => { id: 1, jsonrpc: '2.0', result: { - status: 'PENDING', + chainId: '0x1', + id: params[0], + status: 100, + version: '1.0', }, }) }) diff --git a/apps/web/src/services/safe-wallet-provider/index.ts b/apps/web/src/services/safe-wallet-provider/index.ts index 9598a23fc9..0269575f9e 100644 --- a/apps/web/src/services/safe-wallet-provider/index.ts +++ b/apps/web/src/services/safe-wallet-provider/index.ts @@ -12,6 +12,48 @@ type SafeSettings = { offChainSigning?: boolean } +type Capability = { + [key: string]: unknown + optional?: boolean +} + +type SendCallsParams = { + version: '1.0' + id?: string + from?: `0x${string}` + chainId: `0x${string}` + calls: Array<{ + to?: `0x${string}` + data?: `0x${string}` + value?: `0x${string}` + capabilities?: Record + }> + capabilities?: Record +} + +type SendCallsResult = { + id: string + capabilities?: Record +} + +type GetCallsParams = `0x${string}` + +type GetCallsResult = { + version: string + id: `0x${string}` + chainId: `0x${string}` + status: number // See "Status Codes" + receipts?: Array<{ + logs: TransactionReceipt['logs'] + status: `0x${string}` // Hex 1 or 0 for success or failure, respectively + blockHash: `0x${string}` + blockNumber: `0x${string}` + gasUsed: `0x${string}` + transactionHash: `0x${string}` + }> + capabilities?: Record +} + type GetCapabilitiesResult = Record<`0x${string}`, Record> export type AppInfo = { @@ -54,16 +96,19 @@ export enum RpcErrorCode { } enum BundleStatus { - PENDING = 'PENDING', - CONFIRMED = 'CONFIRMED', + PENDING = 100, // Batch has been received by the wallet but has not completed execution onchain (pending) + CONFIRMED = 200, // Batch has been included onchain without reverts, receipts array contains info of all calls (confirmed) + OFFCHAIN_FAILURE = 400, // Batch has not been included onchain and wallet will not retry (offchain failure) + REVERTED = 500, // Batch reverted completely and only changes related to gas charge may have been included onchain (chain rules failure) + PARTIALLY_REVERTED = 600, // Batch reverted partially and some changes related to batch calls may have been included onchain (partial chain rules failure) } const BundleTxStatuses: Record = { [TransactionStatus.AWAITING_CONFIRMATIONS]: BundleStatus.PENDING, [TransactionStatus.AWAITING_EXECUTION]: BundleStatus.PENDING, - [TransactionStatus.CANCELLED]: BundleStatus.CONFIRMED, - [TransactionStatus.FAILED]: BundleStatus.CONFIRMED, [TransactionStatus.SUCCESS]: BundleStatus.CONFIRMED, + [TransactionStatus.CANCELLED]: BundleStatus.OFFCHAIN_FAILURE, + [TransactionStatus.FAILED]: BundleStatus.REVERTED, } class RpcError extends Error { @@ -137,22 +182,11 @@ export class SafeWalletProvider { // EIP-5792 // @see https://eips.ethereum.org/EIPS/eip-5792 case 'wallet_sendCalls': { - return this.wallet_sendCalls( - ...(params as [ - { - version: string - chainId: string - from: string - calls: Array<{ data: string; to?: string; value?: string }> - capabilities?: Record | undefined - }, - ]), - appInfo, - ) + return this.wallet_sendCalls(...(params as [SendCallsParams]), appInfo) } case 'wallet_getCallsStatus': { - return this.wallet_getCallsStatus(...(params as [string])) + return this.wallet_getCallsStatus(...(params as [GetCallsParams])) } case 'wallet_showCallsStatus': { @@ -328,14 +362,15 @@ export class SafeWalletProvider { // EIP-5792 // @see https://eips.ethereum.org/EIPS/eip-5792 - async wallet_sendCalls( - bundle: { - chainId: string - from: string - calls: Array<{ data?: string; to?: string; value?: string }> - }, - appInfo: AppInfo, - ): Promise { + async wallet_sendCalls(bundle: SendCallsParams, appInfo: AppInfo): Promise { + if (bundle.chainId !== numberToHex(this.safe.chainId)) { + throw new Error(`Safe is not on chain ${this.safe.chainId}`) + } + + if (bundle.from !== this.safe.safeAddress) { + throw Error('Invalid from address') + } + const txs = bundle.calls.map((call) => { if (!call.to && !call.value && !call.data) { throw new RpcError(RpcErrorCode.INVALID_PARAMS, 'Invalid call parameters.') @@ -363,20 +398,9 @@ export class SafeWalletProvider { appInfo, ) - return safeTxHash + return { id: safeTxHash } } - async wallet_getCallsStatus(safeTxHash: string): Promise<{ - status: BundleStatus - receipts?: Array<{ - logs: TransactionReceipt['logs'] - status: `0x${string}` // Hex 1 or 0 for success or failure, respectively - chainId: `0x${string}` - blockHash: `0x${string}` - blockNumber: `0x${string}` - gasUsed: `0x${string}` - transactionHash: `0x${string}` - }> - }> { + async wallet_getCallsStatus(safeTxHash: GetCallsParams): Promise { let tx: TransactionDetails | undefined try { @@ -387,19 +411,22 @@ export class SafeWalletProvider { throw new Error('Transaction not found') } - const status = BundleTxStatuses[tx.txStatus] + const result: GetCallsResult = { + version: '1.0', + id: safeTxHash, + chainId: numberToHex(this.safe.chainId), + status: BundleTxStatuses[tx.txStatus], + } if (!tx.txHash) { - return { - status, - } + return result } const receipt = await (this.sdk.proxy('eth_getTransactionReceipt', [ tx.txHash, ]) as Promise) if (!receipt) { - return { status } + return result } const calls = tx.txData?.dataDecoded?.parameters?.[0].valueDecoded?.length ?? 1 @@ -408,20 +435,16 @@ export class SafeWalletProvider { const blockNumber = Number(receipt.blockNumber) const gasUsed = Number(receipt.gasUsed) - const receipts = Array.from({ length: calls }, () => ({ + result.receipts = Array.from({ length: calls }, () => ({ logs: receipt.logs, status: numberToHex(tx.txStatus === TransactionStatus.SUCCESS ? 1 : 0), - chainId: numberToHex(this.safe.chainId), blockHash: receipt.blockHash as `0x${string}`, blockNumber: numberToHex(blockNumber), gasUsed: numberToHex(gasUsed), transactionHash: tx.txHash as `0x${string}`, })) - return { - status, - receipts, - } + return result } async wallet_showCallsStatus(txHash: string): Promise { this.sdk.showTxStatus(txHash) diff --git a/apps/web/src/services/safe-wallet-provider/useSafeWalletProvider.test.tsx b/apps/web/src/services/safe-wallet-provider/useSafeWalletProvider.test.tsx index a9c85cf4cd..640fa34d0c 100644 --- a/apps/web/src/services/safe-wallet-provider/useSafeWalletProvider.test.tsx +++ b/apps/web/src/services/safe-wallet-provider/useSafeWalletProvider.test.tsx @@ -1,5 +1,5 @@ import { Provider } from 'react-redux' -import type { ExtendedSafeInfo } from '@/store/safeInfoSlice' +import type { ExtendedSafeInfo } from '@safe-global/store/slices/SafeInfo/types' import * as gateway from '@safe-global/safe-gateway-typescript-sdk' import * as router from 'next/router' @@ -11,11 +11,11 @@ import useSafeWalletProvider, { useTxFlowApi } from './useSafeWalletProvider' import { SafeWalletProvider } from '.' import type { RootState } from '@/store' import { makeStore } from '@/store' -import * as messages from '@/utils/safe-messages' +import * as messages from '@safe-global/utils/utils/safe-messages' import { faker } from '@faker-js/faker' import { Interface } from 'ethers' import { getCreateCallDeployment } from '@safe-global/safe-deployments' -import { useCurrentChain } from '@/hooks/useChains' +import * as chainHooks from '@/hooks/useChains' import { chainBuilder } from '@/tests/builders/chains' const appInfo = { @@ -33,21 +33,13 @@ jest.mock('./notifications', () => { } }) -jest.mock('@/hooks/useChains', () => ({ - __esModule: true, - ...jest.requireActual('@/hooks/useChains'), - useCurrentChain: jest.fn(), -})) - describe('useSafeWalletProvider', () => { - const mockUseCurrentChain = useCurrentChain as jest.MockedFunction - beforeEach(() => { jest.clearAllMocks() - mockUseCurrentChain.mockReturnValue( - chainBuilder().with({ chainId: '1', recommendedMasterCopyVersion: '1.4.1' }).build(), - ) + jest.spyOn(chainHooks, 'useCurrentChain').mockImplementation(() => { + return chainBuilder().with({ chainId: '1', recommendedMasterCopyVersion: '1.4.1' }).build() + }) }) describe('useSafeWalletProvider', () => { diff --git a/apps/web/src/services/safe-wallet-provider/useSafeWalletProvider.tsx b/apps/web/src/services/safe-wallet-provider/useSafeWalletProvider.tsx index 20daa9781d..145ef7e041 100644 --- a/apps/web/src/services/safe-wallet-provider/useSafeWalletProvider.tsx +++ b/apps/web/src/services/safe-wallet-provider/useSafeWalletProvider.tsx @@ -11,7 +11,8 @@ import { safeMsgSubscribe, SafeMsgEvent } from '@/services/safe-messages/safeMsg import { SafeAppsTxFlow } from '@/components/tx-flow/flows' import { TxEvent, txSubscribe } from '@/services/tx/txEvents' import { Methods } from '@safe-global/safe-apps-sdk' -import type { EIP712TypedData, SafeSettings } from '@safe-global/safe-apps-sdk' +import type { SafeSettings } from '@safe-global/safe-apps-sdk' +import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { getTransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { Interface, getAddress } from 'ethers' @@ -21,8 +22,8 @@ import { NotificationMessages, showNotification } from './notifications' import { SignMessageOnChainFlow } from '@/components/tx-flow/flows' import { useAppSelector } from '@/store' import { selectOnChainSigning } from '@/store/settingsSlice' -import { isOffchainEIP1271Supported } from '@/utils/safe-messages' -import { getCreateCallContractDeployment } from '../contracts/deployments' +import { isOffchainEIP1271Supported } from '@safe-global/utils/utils/safe-messages' +import { getCreateCallContractDeployment } from '@safe-global/utils/services/contracts/deployments' export const useTxFlowApi = (chainId: string, safeAddress: string): WalletSDK | undefined => { const { safe } = useSafeInfo() @@ -50,7 +51,7 @@ export const useTxFlowApi = (chainId: string, safeAddress: string): WalletSDK | if (!chainId || !safeAddress) return const signMessage = ( - message: string | EIP712TypedData, + message: string | TypedData, appInfo: AppInfo, method: Methods.signMessage | Methods.signTypedMessage, ): Promise<{ signature: string }> => { @@ -108,7 +109,7 @@ export const useTxFlowApi = (chainId: string, safeAddress: string): WalletSDK | }, async signTypedMessage(typedData, appInfo) { - return await signMessage(typedData as EIP712TypedData, appInfo, Methods.signTypedMessage) + return await signMessage(typedData as TypedData, appInfo, Methods.signTypedMessage) }, async send(params: { txs: any[]; params: { safeTxGas: number } }, appInfo) { diff --git a/apps/web/src/services/siwe/index.ts b/apps/web/src/services/siwe/index.ts deleted file mode 100644 index cc051b6606..0000000000 --- a/apps/web/src/services/siwe/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getAuthNonce, verifyAuth } from '@safe-global/safe-gateway-typescript-sdk' -import type { BrowserProvider } from 'ethers' - -/** - * Prompt the user to sign in with their wallet and set an access_token cookie - * @param provider - */ -export const signInWithEthereum = async (provider: BrowserProvider) => { - const { nonce } = await getAuthNonce() - - const [network, signer] = await Promise.all([provider.getNetwork(), provider.getSigner()]) - - const message = { - domain: window.location.host, - address: signer.address as `0x${string}`, - // Results in special signing window in MetaMask - statement: - 'By signing, you are agreeing to store this data on the Safe Cloud. This does not initiate a transaction or cost any fees.', - uri: window.location.origin, - version: '1', - chainId: Number(network.chainId), - nonce, - issuedAt: new Date(), - } - - const signableMessage = `${message.domain} wants you to sign in with your Ethereum account: -${message.address} - -${message.statement} - -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt.toISOString()}` - - const signature = await signer.signMessage(signableMessage) - - return verifyAuth({ message: signableMessage, signature }) -} diff --git a/apps/web/src/services/siwe/useSiwe.tsx b/apps/web/src/services/siwe/useSiwe.tsx new file mode 100644 index 0000000000..f52f4e0310 --- /dev/null +++ b/apps/web/src/services/siwe/useSiwe.tsx @@ -0,0 +1,43 @@ +import { useWeb3 } from '@/hooks/wallets/web3' +import { useAuthVerifyV1Mutation, useLazyAuthGetNonceV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/auth' +import { useCallback } from 'react' +import { getSignableMessage } from './utils' +import { logError } from '../exceptions' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' +import useWallet from '@/hooks/wallets/useWallet' +import { isPKWallet } from '@/utils/wallets' + +export const useSiwe = () => { + const wallet = useWallet() + const provider = useWeb3() + const [fetchNonce] = useLazyAuthGetNonceV1Query() + const [verifyAuthMutation] = useAuthVerifyV1Mutation() + + const signIn = useCallback(async () => { + if (!provider || !wallet) return + + try { + const { data } = await fetchNonce() + if (!data) return + + const [network, signer] = await Promise.all([provider.getNetwork(), provider.getSigner()]) + const signableMessage = getSignableMessage(signer.address, network.chainId, data.nonce) + + let signature + // Using the signer.signMessage hexlifies the message which doesn't work with the personal_sign of the PK module + if (isPKWallet(wallet)) { + signature = await provider.send('personal_sign', [signableMessage, signer.address.toLowerCase()]) + } else { + signature = await signer.signMessage(signableMessage) + } + + return verifyAuthMutation({ siweDto: { message: signableMessage, signature } }) + } catch (error) { + logError(ErrorCodes._640) + } + }, [fetchNonce, provider, verifyAuthMutation, wallet]) + + return { + signIn, + } +} diff --git a/apps/web/src/services/siwe/utils/index.ts b/apps/web/src/services/siwe/utils/index.ts new file mode 100644 index 0000000000..3f3c7f97f6 --- /dev/null +++ b/apps/web/src/services/siwe/utils/index.ts @@ -0,0 +1,27 @@ +const getSignableMessage = (address: string, chainId: bigint, nonce: string) => { + const message = { + domain: window.location.host, + address, + statement: + 'By signing, you are agreeing to store this data in the Safe infrastructure. This does not initiate a transaction or cost any fees.', + uri: window.location.origin, + version: '1', + chainId: Number(chainId), + nonce, + issuedAt: new Date(), + } + const signableMessage = `${message.domain} wants you to sign in with your Ethereum account: +${message.address} + +${message.statement} + +URI: ${message.uri} +Version: ${message.version} +Chain ID: ${message.chainId} +Nonce: ${message.nonce} +Issued At: ${message.issuedAt.toISOString()}` + + return signableMessage +} + +export { getSignableMessage } diff --git a/apps/web/src/services/tx/__tests__/encodeSignatures.test.ts b/apps/web/src/services/tx/__tests__/encodeSignatures.test.ts index fccfdf2391..574b940f23 100644 --- a/apps/web/src/services/tx/__tests__/encodeSignatures.test.ts +++ b/apps/web/src/services/tx/__tests__/encodeSignatures.test.ts @@ -1,4 +1,4 @@ -import type { SafeSignature, SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeSignature, SafeTransaction } from '@safe-global/types-kit' import { encodeSignatures } from '../encodeSignatures' const createSafeTx = (): SafeTransaction => { diff --git a/apps/web/src/services/tx/__tests__/extractTxInfo.test.ts b/apps/web/src/services/tx/__tests__/extractTxInfo.test.ts index 4a01fc28d9..cdaf3de878 100644 --- a/apps/web/src/services/tx/__tests__/extractTxInfo.test.ts +++ b/apps/web/src/services/tx/__tests__/extractTxInfo.test.ts @@ -6,6 +6,7 @@ describe('extractTxInfo', () => { const txDetails = { txData: { operation: 'CALL', + to: { value: '0x1234567890123456789012345678901234567890' }, value: '1000000000000000000', data: '0x1234567890123456789012345678901234567890', }, @@ -36,9 +37,7 @@ describe('extractTxInfo', () => { }, } as unknown as TransactionDetails - const safeAddress = '0x1234567890123456789012345678901234567890' - - expect(extractTxInfo(txDetails, safeAddress)).toEqual({ + expect(extractTxInfo(txDetails)).toEqual({ txParams: { data: '0x', baseGas: '21000', @@ -60,6 +59,7 @@ describe('extractTxInfo', () => { it('should extract tx info for an ERC20 token transfer', () => { const txDetails = { txData: { + to: { value: '0xa74476443119A942dE498590Fe1f2454d7D4aC0d' }, operation: 'CALL', value: '0x0', hexData: '0x546785', @@ -92,9 +92,7 @@ describe('extractTxInfo', () => { }, } as unknown as TransactionDetails - const safeAddress = '0x1234567890123456789012345678901234567890' - - expect(extractTxInfo(txDetails, safeAddress)).toEqual({ + expect(extractTxInfo(txDetails)).toEqual({ txParams: { data: '0x546785', baseGas: '21000', @@ -201,9 +199,7 @@ describe('extractTxInfo', () => { }, } as unknown as TransactionDetails - const safeAddress = '0xF979f34D16d865f51e2eC7baDEde4f3735DaFb7d' - - expect(extractTxInfo(txDetails, safeAddress)).toEqual({ + expect(extractTxInfo(txDetails)).toEqual({ signatures: { '0xbbeedB6d8e56e23f5812e59d1b6602F15957271F': '0xb10e0605bd27c42af87ff36d690a5594d13a4a7029ea5f080dde917d0781f97901958195d0d99c7805419adb636ccdc579e9aa200ee0443dd10c4f5885f37f081b', diff --git a/apps/web/src/services/tx/__tests__/safeUpdateParams.test.ts b/apps/web/src/services/tx/__tests__/safeUpdateParams.test.ts index 314f9cfd5a..b506160d3a 100644 --- a/apps/web/src/services/tx/__tests__/safeUpdateParams.test.ts +++ b/apps/web/src/services/tx/__tests__/safeUpdateParams.test.ts @@ -1,5 +1,5 @@ import * as sdkHelpers from '@/services/tx/tx-sender/sdk' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import type { SafeProvider } from '@safe-global/protocol-kit' import { getFallbackHandlerDeployment, @@ -10,8 +10,8 @@ import { type SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { Interface, JsonRpcProvider } from 'ethers' import { createUpdateSafeTxs } from '../safeUpdateParams' import * as web3 from '@/hooks/wallets/web3' -import { getLatestSafeVersion } from '@/utils/chains' import { chainBuilder } from '@/tests/builders/chains' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' const MOCK_SAFE_ADDRESS = '0x0000000000000000000000000000000000005AFE' diff --git a/apps/web/src/services/tx/__tests__/txEvents.test.ts b/apps/web/src/services/tx/__tests__/txEvents.test.ts index be2f36b426..c56ddbb644 100644 --- a/apps/web/src/services/tx/__tests__/txEvents.test.ts +++ b/apps/web/src/services/tx/__tests__/txEvents.test.ts @@ -1,4 +1,4 @@ -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction } from '@safe-global/types-kit' import { txDispatch, txSubscribe, TxEvent } from '../txEvents' import { faker } from '@faker-js/faker' diff --git a/apps/web/src/services/tx/encodeSignatures.ts b/apps/web/src/services/tx/encodeSignatures.ts index 18f0098992..bc80b9a35c 100644 --- a/apps/web/src/services/tx/encodeSignatures.ts +++ b/apps/web/src/services/tx/encodeSignatures.ts @@ -1,4 +1,4 @@ -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction } from '@safe-global/types-kit' import { generatePreValidatedSignature } from '@safe-global/protocol-kit/dist/src/utils/signatures' export const encodeSignatures = ( diff --git a/apps/web/src/services/tx/extractTxInfo.ts b/apps/web/src/services/tx/extractTxInfo.ts index c40e6862de..5c0a5e6528 100644 --- a/apps/web/src/services/tx/extractTxInfo.ts +++ b/apps/web/src/services/tx/extractTxInfo.ts @@ -1,116 +1,43 @@ -import type { OperationType } from '@safe-global/safe-core-sdk-types' -import { type SafeTransactionData } from '@safe-global/safe-core-sdk-types' +import type { OperationType } from '@safe-global/types-kit' +import { type SafeTransactionData } from '@safe-global/types-kit' import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { Operation } from '@safe-global/safe-gateway-typescript-sdk' -import { isMultisigDetailedExecutionInfo, isNativeTokenTransfer } from '@/utils/transaction-guards' +import { isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards' const ZERO_ADDRESS: string = '0x0000000000000000000000000000000000000000' -const EMPTY_DATA: string = '0x' /** * Convert the CGW tx type to a Safe Core SDK tx */ const extractTxInfo = ( txDetails: TransactionDetails, - safeAddress: string, ): { txParams: SafeTransactionData; signatures: Record } => { - // Format signatures into a map - let signatures: Record = {} - if (isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo)) { - signatures = txDetails.detailedExecutionInfo.confirmations.reduce((result, item) => { - result[item.signer.value] = item.signature ?? '' - return result - }, signatures) - } - - const data = txDetails.txData?.hexData ?? EMPTY_DATA - - const baseGas = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) - ? txDetails.detailedExecutionInfo.baseGas - : '0' - - const gasPrice = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) - ? txDetails.detailedExecutionInfo.gasPrice - : '0' - - const safeTxGas = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) - ? txDetails.detailedExecutionInfo.safeTxGas - : '0' + const execInfo = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) + ? txDetails.detailedExecutionInfo + : undefined + const txData = txDetails?.txData - const gasToken = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) - ? txDetails.detailedExecutionInfo.gasToken - : ZERO_ADDRESS - - const nonce = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) - ? txDetails.detailedExecutionInfo.nonce - : 0 - - const refundReceiver = isMultisigDetailedExecutionInfo(txDetails.detailedExecutionInfo) - ? txDetails.detailedExecutionInfo.refundReceiver.value - : ZERO_ADDRESS - - const value = (() => { - switch (txDetails.txInfo.type) { - case 'Transfer': - if (isNativeTokenTransfer(txDetails.txInfo.transferInfo)) { - return txDetails.txInfo.transferInfo.value - } else { - return txDetails.txData?.value ?? '0' - } - case 'TwapOrder': - return txDetails.txData?.value ?? '0' - case 'SwapOrder': - return txDetails.txData?.value ?? '0' - case 'NativeStakingDeposit': - case 'NativeStakingValidatorsExit': - case 'NativeStakingWithdraw': - return txDetails.txData?.value ?? '0' - case 'Custom': - return txDetails.txInfo.value - case 'Creation': - case 'SettingsChange': - return '0' - default: { - throw new Error(`Unknown transaction type: ${txDetails.txInfo.type}`) - } - } - })() - - const to = (() => { - switch (txDetails.txInfo.type) { - case 'Transfer': - if (isNativeTokenTransfer(txDetails.txInfo.transferInfo)) { - return txDetails.txInfo.recipient.value - } else { - return txDetails.txInfo.transferInfo.tokenAddress - } - case 'SwapOrder': - case 'TwapOrder': - const orderTo = txDetails.txData?.to.value - if (!orderTo) { - throw new Error('Order tx data does not have a `to` field') - } - return orderTo - case 'NativeStakingDeposit': - case 'NativeStakingValidatorsExit': - case 'NativeStakingWithdraw': - const stakingTo = txDetails.txData?.to.value - if (!stakingTo) { - throw new Error('Staking tx data does not have a `to` field') - } - return stakingTo - case 'Custom': - return txDetails.txInfo.to.value - case 'Creation': - case 'SettingsChange': - return safeAddress - default: { - throw new Error(`Unknown transaction type: ${txDetails.txInfo.type}`) - } - } - })() - - const operation = (txDetails.txData?.operation ?? Operation.CALL) as unknown as OperationType + // Format signatures into a map + const signatures = + execInfo?.confirmations.reduce( + (result, item) => { + result[item.signer.value] = item.signature ?? '' + return result + }, + {} as Record, + ) ?? {} + + const nonce = execInfo?.nonce ?? 0 + const baseGas = execInfo?.baseGas ?? '0' + const gasPrice = execInfo?.gasPrice ?? '0' + const safeTxGas = execInfo?.safeTxGas ?? '0' + const gasToken = execInfo?.gasToken ?? ZERO_ADDRESS + const refundReceiver = execInfo?.refundReceiver.value ?? ZERO_ADDRESS + + const to = txData?.to.value ?? ZERO_ADDRESS + const value = txData?.value ?? '0' + const data = txData?.hexData ?? '0x' + const operation = (txData?.operation ?? Operation.CALL) as unknown as OperationType return { txParams: { diff --git a/apps/web/src/services/tx/proposeTransaction.ts b/apps/web/src/services/tx/proposeTransaction.ts index 353a12b7f1..67574c1cdb 100644 --- a/apps/web/src/services/tx/proposeTransaction.ts +++ b/apps/web/src/services/tx/proposeTransaction.ts @@ -1,6 +1,6 @@ import type { Operation, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { proposeTransaction } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction } from '@safe-global/types-kit' const proposeTx = async ( chainId: string, diff --git a/apps/web/src/services/tx/safeUpdateParams.ts b/apps/web/src/services/tx/safeUpdateParams.ts index 1eda91e09b..185b130e76 100644 --- a/apps/web/src/services/tx/safeUpdateParams.ts +++ b/apps/web/src/services/tx/safeUpdateParams.ts @@ -1,14 +1,22 @@ import type { SafeContractImplementationType } from '@safe-global/protocol-kit/dist/src/types/contracts' -import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' -import { OperationType } from '@safe-global/safe-core-sdk-types' -import type { ChainInfo, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { MetaTransactionData, SafeVersion } from '@safe-global/types-kit' +import { OperationType } from '@safe-global/types-kit' +import type { ChainInfo, TransactionData } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import semverSatisfies from 'semver/functions/satisfies' import { getReadOnlyFallbackHandlerContract, getReadOnlyGnosisSafeContract } from '@/services/contracts/safeContracts' -import { assertValidSafeVersion } from '@/hooks/coreSDK/safeCoreSDK' import { SAFE_FEATURES } from '@safe-global/protocol-kit/dist/src/utils/safeVersions' import { hasSafeFeature } from '@/utils/safe-versions' -import { getLatestSafeVersion } from '@/utils/chains' import { createUpdateMigration } from '@/utils/safe-migrations' +import { isMultiSendCalldata } from '@/utils/transaction-calldata' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' +import { Gnosis_safe__factory } from '@safe-global/utils/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.1.1' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import { determineMasterCopyVersion } from '@/features/counterfactual/utils' +import { getSafeMigrationDeployment } from '@safe-global/safe-deployments' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' +import { assertValidSafeVersion } from '@safe-global/utils/services/contracts/utils' +import { SAFE_TO_L2_MIGRATION_VERSION } from '@safe-global/utils/config/constants' const getChangeFallbackHandlerCallData = async ( safeContractInstance: SafeContractImplementationType, @@ -18,9 +26,7 @@ const getChangeFallbackHandlerCallData = async ( return '0x' } - const fallbackHandlerAddress = await ( - await getReadOnlyFallbackHandlerContract(getLatestSafeVersion(chain)) - ).getAddress() + const fallbackHandlerAddress = (await getReadOnlyFallbackHandlerContract(getLatestSafeVersion(chain))).getAddress() // @ts-ignore return safeContractInstance.encode('setFallbackHandler', [fallbackHandlerAddress]) } @@ -32,7 +38,7 @@ const getChangeFallbackHandlerCallData = async ( * - change the mastercopy address * - set the fallback handler address */ -export const createUpdateSafeTxs = async (safe: SafeInfo, chain: ChainInfo): Promise => { +export const createUpdateSafeTxs = async (safe: SafeState, chain: ChainInfo): Promise => { assertValidSafeVersion(safe.version) // 1.3.0 Safes are updated using a delegate call to a migration contract @@ -41,12 +47,10 @@ export const createUpdateSafeTxs = async (safe: SafeInfo, chain: ChainInfo): Pro } // For older Safes, we need to create two transactions - const latestMasterCopyAddress = await ( - await getReadOnlyGnosisSafeContract(chain, getLatestSafeVersion(chain)) - ).getAddress() + const latestMasterCopyAddress = (await getReadOnlyGnosisSafeContract(chain, getLatestSafeVersion(chain))).getAddress() const currentReadOnlySafeContract = await getReadOnlyGnosisSafeContract(chain, safe.version) - const updatedReadOnlySafeContract = await getReadOnlyGnosisSafeContract(chain, getLatestSafeVersion(chain, true)) + const updatedReadOnlySafeContract = await getReadOnlyGnosisSafeContract(chain, getLatestSafeVersion(chain)) // @ts-expect-error this was removed in 1.3.0 but we need to support it for older safe versions const changeMasterCopyCallData = currentReadOnlySafeContract.encode('changeMasterCopy', [latestMasterCopyAddress]) @@ -69,3 +73,49 @@ export const createUpdateSafeTxs = async (safe: SafeInfo, chain: ChainInfo): Pro return txs } +const SAFE_1_1_1_INTERFACE = Gnosis_safe__factory.createInterface() + +export const extractTargetVersionFromUpdateSafeTx = ( + txData: TransactionData | undefined, + safe: SafeState, +): SafeVersion | undefined => { + if (!txData) { + return + } + const data = txData.hexData ?? '0x' + let migrationTxData: MetaTransactionData = { + to: txData.to.value, + data, + value: txData.value ?? '0', + operation: txData.operation as number, + } + if (isMultiSendCalldata(data)) { + // Decode multisend and check the first call + const txs = decodeMultiSendData(data) + if (txs.length === 2) { + // First tx is the upgrade. Second sets the fallback handler + migrationTxData = txs[0] + } + } + + // Below Safe 1.3.0 the call will be to the Safe itself and call changeMasterCopy + if ( + sameAddress(migrationTxData.to, safe.address.value) && + migrationTxData.data.startsWith(SAFE_1_1_1_INTERFACE.getFunction('changeMasterCopy').selector) + ) { + // Decode call and check which Safe version it is + const decodedData = SAFE_1_1_1_INTERFACE.decodeFunctionData('changeMasterCopy', migrationTxData.data) + return determineMasterCopyVersion(decodedData[0], safe.chainId) + } + + const safeMigrationAddress = getSafeMigrationDeployment({ + version: SAFE_TO_L2_MIGRATION_VERSION, + network: safe.chainId, + })?.networkAddresses[safe.chainId] + + // Otherwise it must be a delegate call to the SafeMigration 1.4.1 contract + if (migrationTxData.operation === 1 && sameAddress(safeMigrationAddress, migrationTxData.to)) { + // This contract can only migrate to 1.4.1 + return SAFE_TO_L2_MIGRATION_VERSION + } +} diff --git a/apps/web/src/services/tx/spendingLimitParams.ts b/apps/web/src/services/tx/spendingLimitParams.ts index e1ea0f9e9c..0f646889b6 100644 --- a/apps/web/src/services/tx/spendingLimitParams.ts +++ b/apps/web/src/services/tx/spendingLimitParams.ts @@ -1,5 +1,5 @@ import { getReadOnlyGnosisSafeContract } from '@/services/contracts/safeContracts' -import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' +import type { MetaTransactionData } from '@safe-global/types-kit' import { getSpendingLimitInterface } from '@/services/contracts/spendingLimitContracts' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' diff --git a/apps/web/src/services/tx/tokenTransferParams.ts b/apps/web/src/services/tx/tokenTransferParams.ts index e64ee02ba8..496c148719 100644 --- a/apps/web/src/services/tx/tokenTransferParams.ts +++ b/apps/web/src/services/tx/tokenTransferParams.ts @@ -1,8 +1,8 @@ -import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' +import type { MetaTransactionData } from '@safe-global/types-kit' import { ConfirmationViewTypes, type BaselineConfirmationView } from '@safe-global/safe-gateway-typescript-sdk' -import { safeParseUnits } from '@/utils/formatters' +import { safeParseUnits } from '@safe-global/utils/utils/formatters' import { Interface } from 'ethers' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' // CryptoKitties Contract Addresses by network // This is an exception made for a popular NFT that's not ERC721 standard-compatible, @@ -25,7 +25,7 @@ const encodeERC721TransferData = (from: string, to: string, tokenId: string): st export const createTokenTransferParams = ( recipient: string, amount: string, - decimals: number, + decimals: number | null | undefined, tokenAddress: string, ): MetaTransactionData => { const isNativeToken = parseInt(tokenAddress, 16) === 0 diff --git a/apps/web/src/services/tx/tx-sender/__tests__/ts-sender.test.ts b/apps/web/src/services/tx/tx-sender/__tests__/ts-sender.test.ts index 32fc249c9e..6e67fd86c0 100644 --- a/apps/web/src/services/tx/tx-sender/__tests__/ts-sender.test.ts +++ b/apps/web/src/services/tx/tx-sender/__tests__/ts-sender.test.ts @@ -163,7 +163,7 @@ describe('txSender', () => { describe('createExistingTx', () => { it('should create a tx from an existing proposal', async () => { - const tx = await createExistingTx('4', '0x123', '0x345') + const tx = await createExistingTx('4', '0x345') expect(getTransactionDetails).toHaveBeenCalledWith('4', '0x345') expect(extractTxInfo).toHaveBeenCalled() @@ -478,13 +478,9 @@ describe('txSender', () => { const expectedData = '0xfefe' const multisendContractMock = { - contract: { - interface: { - encodeFunctionData: jest.fn(() => expectedData), - }, - } as any, - getAddress: async () => mockMultisendAddress, - } as MultiSendCallOnlyContractImplementationType + encode: jest.fn(() => expectedData), + getAddress: () => mockMultisendAddress, + } as unknown as MultiSendCallOnlyContractImplementationType jest .spyOn(safeContracts, 'getReadOnlyMultiSendCallOnlyContract') diff --git a/apps/web/src/services/tx/tx-sender/create.ts b/apps/web/src/services/tx/tx-sender/create.ts index 7152601bde..6ace4e0f24 100644 --- a/apps/web/src/services/tx/tx-sender/create.ts +++ b/apps/web/src/services/tx/tx-sender/create.ts @@ -3,7 +3,7 @@ import { SENTINEL_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/const import type { ChainInfo, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { getTransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import type { AddOwnerTxParams, RemoveOwnerTxParams, SwapOwnerTxParams } from '@safe-global/protocol-kit' -import type { MetaTransactionData, SafeTransaction, SafeTransactionDataPartial } from '@safe-global/safe-core-sdk-types' +import type { MetaTransactionData, SafeTransaction, SafeTransactionDataPartial } from '@safe-global/types-kit' import extractTxInfo from '../extractTxInfo' import { getAndValidateSafeSDK } from './sdk' @@ -25,17 +25,6 @@ export const createMultiSendCallOnlyTx = async (txParams: MetaTransactionData[]) return safeSDK.createTransaction({ transactions: txParams, onlyCalls: true }) } -/** - * Create a multiSend transaction from an array of MetaTransactionData and options - * If only one tx is passed it will be created without multiSend and without onlyCalls. - * - * This function can create delegateCalls, which is usually not necessary - */ -export const __unsafe_createMultiSendTx = async (txParams: MetaTransactionData[]): Promise => { - const safeSDK = getAndValidateSafeSDK() - return safeSDK.createTransaction({ transactions: txParams, onlyCalls: false }) -} - export const createRemoveOwnerTx = async (txParams: RemoveOwnerTxParams): Promise => { const safeSDK = getAndValidateSafeSDK() return safeSDK.createRemoveOwnerTx(txParams) @@ -49,7 +38,7 @@ export const createAddOwnerTx = async ( const safeSDK = getAndValidateSafeSDK() if (isDeployed) return safeSDK.createAddOwnerTx(txParams) - const safeVersion = await safeSDK.getContractVersion() + const safeVersion = safeSDK.getContractVersion() const contract = await getReadOnlyGnosisSafeContract(chain, safeVersion) // @ts-ignore @@ -74,7 +63,7 @@ export const createSwapOwnerTx = async ( const safeSDK = getAndValidateSafeSDK() if (isDeployed) return safeSDK.createSwapOwnerTx(txParams) - const safeVersion = await safeSDK.getContractVersion() + const safeVersion = safeSDK.getContractVersion() const contract = await getReadOnlyGnosisSafeContract(chain, safeVersion) // @ts-ignore SwapOwnerTxParams is a union type and the method expects a specific one @@ -119,7 +108,6 @@ export const createRejectTx = async (nonce: number): Promise => */ export const createExistingTx = async ( chainId: string, - safeAddress: string, txId: string, txDetails?: TransactionDetails, ): Promise => { @@ -127,7 +115,7 @@ export const createExistingTx = async ( txDetails = txDetails || (await getTransactionDetails(chainId, txId)) // Convert them to the Core SDK tx params - const { txParams, signatures } = extractTxInfo(txDetails, safeAddress) + const { txParams, signatures } = extractTxInfo(txDetails) // Create a tx and add pre-approved signatures const safeTx = await createTx(txParams, txParams.nonce) diff --git a/apps/web/src/services/tx/tx-sender/dispatch.ts b/apps/web/src/services/tx/tx-sender/dispatch.ts index 9a0005bfa6..706ca2075f 100644 --- a/apps/web/src/services/tx/tx-sender/dispatch.ts +++ b/apps/web/src/services/tx/tx-sender/dispatch.ts @@ -1,20 +1,17 @@ import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' import { isMultisigExecutionInfo } from '@/utils/transaction-guards' -import { isHardwareWallet, isSmartContractWallet } from '@/utils/wallets' +import { isEthSignWallet, isSmartContractWallet } from '@/utils/wallets' import type { MultiSendCallOnlyContractImplementationType } from '@safe-global/protocol-kit' -import { - type ChainInfo, - relayTransaction, - type SafeInfo, - type TransactionDetails, -} from '@safe-global/safe-gateway-typescript-sdk' +import { type ChainInfo, relayTransaction, type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' + import type { SafeSignature, SafeTransaction, Transaction, TransactionOptions, TransactionResult, -} from '@safe-global/safe-core-sdk-types' +} from '@safe-global/types-kit' import { didRevert } from '@/utils/ethers-utils' import { type SpendingLimitTxParams } from '@/components/tx-flow/flows/TokenTransfer/ReviewSpendingLimitTx' import { getSpendingLimitContract } from '@/services/contracts/spendingLimitContracts' @@ -33,10 +30,11 @@ import { prepareApproveTxHash, } from './sdk' import { createWeb3, getUserNonce } from '@/hooks/wallets/web3' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import chains from '@/config/chains' import { createExistingTx } from './create' -import { getLatestSafeVersion } from '@/utils/chains' + +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' /** * Propose a transaction @@ -116,7 +114,7 @@ export const dispatchProposerTxSigning = async (safeTx: SafeTransaction, wallet: const sdk = await getSafeSDKWithSigner(wallet.provider) let signature: SafeSignature - if (isHardwareWallet(wallet)) { + if (isEthSignWallet(wallet)) { const txHash = await sdk.getTransactionHash(safeTx) signature = await sdk.signHash(txHash) } else { @@ -137,7 +135,7 @@ export const dispatchOnChainSigning = async ( safeTx: SafeTransaction, txId: string, provider: Eip1193Provider, - chainId: SafeInfo['chainId'], + chainId: SafeState['chainId'], signerAddress: string, safeAddress: string, isNestedSafe: boolean, @@ -146,7 +144,10 @@ export const dispatchOnChainSigning = async ( const safeTxHash = await sdk.getTransactionHash(safeTx) const eventParams = { txId, nonce: safeTx.data.nonce } - const options = chainId === chains.zksync ? { gasLimit: ZK_SYNC_ON_CHAIN_SIGNATURE_GAS_LIMIT } : undefined + const options = + chainId === chains.zksync || chainId === chains.lens + ? { gasLimit: ZK_SYNC_ON_CHAIN_SIGNATURE_GAS_LIMIT } + : undefined let txHashOrParentSafeTxHash: string try { // TODO: This is a workaround until there is a fix for unchecked transactions in the protocol-kit @@ -184,7 +185,7 @@ export const dispatchSafeTxSpeedUp = async ( txOptions: Omit & { nonce: number }, txId: string, provider: Eip1193Provider, - chainId: SafeInfo['chainId'], + chainId: SafeState['chainId'], signerAddress: string, safeAddress: string, nonce: number, @@ -197,7 +198,7 @@ export const dispatchSafeTxSpeedUp = async ( // Execute the tx let result: TransactionResult | undefined try { - const safeTx = await createExistingTx(chainId, safeAddress, txId) + const safeTx = await createExistingTx(chainId, txId) // TODO: This is a workaround until there is a fix for unchecked transactions in the protocol-kit if (isSmartAccount) { @@ -225,7 +226,7 @@ export const dispatchSafeTxSpeedUp = async ( txHash: result.hash, signerAddress, signerNonce, - gasLimit: txOptions.gasLimit, + gasLimit: txOptions.gasLimit?.toString(), txType: 'SafeTx', }) @@ -316,7 +317,7 @@ export const dispatchTxExecution = async ( txHash: result.hash, signerAddress, signerNonce, - gasLimit: txOptions.gasLimit, + gasLimit: txOptions.gasLimit?.toString(), txType: 'SafeTx', }) @@ -326,16 +327,15 @@ export const dispatchTxExecution = async ( export const dispatchBatchExecution = async ( txs: TransactionDetails[], multiSendContract: MultiSendCallOnlyContractImplementationType, - multiSendTxData: string, + multiSendTxData: `0x${string}`, provider: Eip1193Provider, signerAddress: string, - safeAddress: string, overrides: Omit & { nonce: number }, nonce: number, ) => { const groupKey = multiSendTxData - let result: ContractTransactionResponse + let result: TransactionResponse const txIds = txs.map((tx) => tx.txId) let signerNonce = overrides.nonce let txData = multiSendContract.encode('multiSend', [multiSendTxData]) @@ -345,8 +345,13 @@ export const dispatchBatchExecution = async ( signerNonce = await getUserNonce(signerAddress) } const signer = await getUncheckedSigner(provider) - // @ts-ignore - result = await multiSendContract.contract.connect(signer).multiSend(multiSendTxData, overrides) + + result = await signer.sendTransaction({ + to: multiSendContract.getAddress(), + value: '0', + data: txData, + ...overrides, + }) txIds.forEach((txId) => { txDispatch(TxEvent.EXECUTING, { txId, groupKey, nonce }) @@ -357,7 +362,7 @@ export const dispatchBatchExecution = async ( }) throw err } - const txTo = await multiSendContract.getAddress() + const txTo = multiSendContract.getAddress() txIds.forEach((txId) => { txDispatch(TxEvent.PROCESSING, { @@ -428,9 +433,9 @@ export const dispatchSpendingLimitTxExecution = async ( txParams: SpendingLimitTxParams, txOptions: TransactionOptions, provider: Eip1193Provider, - chainId: SafeInfo['chainId'], + chainId: SafeState['chainId'], safeAddress: string, - safeModules: SafeInfo['modules'], + safeModules: SafeState['modules'], ) => { const id = JSON.stringify(txParams) @@ -482,24 +487,32 @@ export const dispatchSpendingLimitTxExecution = async ( return result?.hash } -export const dispatchSafeAppsTx = async ( - safeTx: SafeTransaction, - safeAppRequestId: RequestId, - provider: Eip1193Provider, - txId?: string, -): Promise => { - const sdk = await getSafeSDKWithSigner(provider) - const safeTxHash = await sdk.getTransactionHash(safeTx) +export async function dispatchSafeAppsTx( + args: { safeAppRequestId: RequestId; txId?: string } & ( + | { safeTx: SafeTransaction; provider: Eip1193Provider } + | { safeTxHash: string } + ), +): Promise { + let safeTxHash: string + if ('safeTx' in args && 'provider' in args) { + const { safeTx, provider } = args + const sdk = await getSafeSDKWithSigner(provider) + safeTxHash = await sdk.getTransactionHash(safeTx) + } else { + safeTxHash = args.safeTxHash + } + + const { txId, safeAppRequestId } = args txDispatch(TxEvent.SAFE_APPS_REQUEST, { safeAppRequestId, safeTxHash, txId }) return safeTxHash } export const dispatchTxRelay = async ( safeTx: SafeTransaction, - safe: SafeInfo, + safe: SafeState, txId: string, chain: ChainInfo, - gasLimit?: string | number, + gasLimit?: string | number | bigint, ) => { const readOnlySafeContract = await getReadOnlyCurrentGnosisSafeContract(safe) @@ -543,13 +556,14 @@ export const dispatchTxRelay = async ( export const dispatchBatchExecutionRelay = async ( txs: TransactionDetails[], multiSendContract: MultiSendCallOnlyContractImplementationType, - multiSendTxData: string, + multiSendTxData: `0x${string}`, chainId: string, safeAddress: string, safeVersion: string, ) => { - const to = await multiSendContract.getAddress() - const data = multiSendContract.contract.interface.encodeFunctionData('multiSend', [multiSendTxData]) + const to = multiSendContract.getAddress() + + const data = multiSendContract.encode('multiSend', [multiSendTxData]) const groupKey = multiSendTxData let relayResponse diff --git a/apps/web/src/services/tx/tx-sender/recommendedNonce.ts b/apps/web/src/services/tx/tx-sender/recommendedNonce.ts index aa83ed1f4b..260059ba43 100644 --- a/apps/web/src/services/tx/tx-sender/recommendedNonce.ts +++ b/apps/web/src/services/tx/tx-sender/recommendedNonce.ts @@ -4,9 +4,9 @@ import { getNonces as fetchNonces, type SafeTransactionEstimation, } from '@safe-global/safe-gateway-typescript-sdk' -import type { MetaTransactionData, SafeTransactionDataPartial } from '@safe-global/safe-core-sdk-types' -import { isLegacyVersion } from '@/hooks/coreSDK/safeCoreSDK' +import type { MetaTransactionData, SafeTransactionDataPartial } from '@safe-global/types-kit' import { Errors, logError } from '@/services/exceptions' +import { isLegacyVersion } from '@safe-global/utils/services/contracts/utils' const fetchRecommendedParams = async ( chainId: string, diff --git a/apps/web/src/services/tx/tx-sender/sdk.ts b/apps/web/src/services/tx/tx-sender/sdk.ts index fce8a16a36..1ba41074cc 100644 --- a/apps/web/src/services/tx/tx-sender/sdk.ts +++ b/apps/web/src/services/tx/tx-sender/sdk.ts @@ -8,7 +8,7 @@ import { } from '@safe-global/protocol-kit/dist/src/utils' import type { Eip1193Provider, JsonRpcSigner } from 'ethers' import { isHardwareWallet, isWalletConnect } from '@/utils/wallets' -import { OperationType, type SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { OperationType, type SafeTransaction } from '@safe-global/types-kit' import { getChainConfig } from '@safe-global/safe-gateway-typescript-sdk' import { createWeb3, getWeb3ReadOnly } from '@/hooks/wallets/web3' import { toQuantity } from 'ethers' @@ -17,7 +17,7 @@ import { type OnboardAPI } from '@web3-onboard/core' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' import { UncheckedJsonRpcSigner } from '@/utils/providers/UncheckedJsonRpcSigner' import get from 'lodash/get' -import { maybePlural } from '@/utils/formatters' +import { maybePlural } from '@safe-global/utils/utils/formatters' export const getAndValidateSafeSDK = (): Safe => { const safeSDK = getSafeSDK() diff --git a/apps/web/src/services/tx/tx-sender/spendingLimit.ts b/apps/web/src/services/tx/tx-sender/spendingLimit.ts index 922717541b..7371871a4d 100644 --- a/apps/web/src/services/tx/tx-sender/spendingLimit.ts +++ b/apps/web/src/services/tx/tx-sender/spendingLimit.ts @@ -4,16 +4,17 @@ import { getLatestSpendingLimitAddress, getDeployedSpendingLimitModuleAddress, } from '@/services/contracts/spendingLimitContracts' -import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' +import type { MetaTransactionData } from '@safe-global/types-kit' import { createAddDelegateTx, createEnableModuleTx, createResetAllowanceTx, createSetAllowanceTx, } from '@/services/tx/spendingLimitParams' -import type { ChainInfo, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { parseUnits } from 'ethers' -import { currentMinutes } from '@/utils/date' +import { currentMinutes } from '@safe-global/utils/utils/date' import { createMultiSendCallOnlyTx } from '@/services/tx/tx-sender/create' export type NewSpendingLimitData = { @@ -28,9 +29,9 @@ export const createNewSpendingLimitTx = async ( spendingLimits: SpendingLimitState[], chainId: string, chain: ChainInfo, - safeModules: SafeInfo['modules'], + safeModules: SafeState['modules'], deployed: boolean, - tokenDecimals?: number, + tokenDecimals?: number | null, existingSpendingLimit?: SpendingLimitState, ) => { const sdk = getSafeSDK() @@ -49,7 +50,7 @@ export const createNewSpendingLimitTx = async ( const enableModuleTx = await createEnableModuleTx( chain, await sdk.getAddress(), - await sdk.getContractVersion(), + sdk.getContractVersion(), spendingLimitAddress, ) @@ -85,7 +86,7 @@ export const createNewSpendingLimitTx = async ( const tx = createSetAllowanceTx( data.beneficiary, data.tokenAddress, - parseUnits(data.amount, tokenDecimals).toString(), + parseUnits(data.amount, tokenDecimals ?? undefined).toString(), parseInt(data.resetTime), data.resetTime !== '0' ? currentMinutes() - 30 : 0, spendingLimitAddress, diff --git a/apps/web/src/services/tx/txMonitor.ts b/apps/web/src/services/tx/txMonitor.ts index a6a82317dd..e1c772a260 100644 --- a/apps/web/src/services/tx/txMonitor.ts +++ b/apps/web/src/services/tx/txMonitor.ts @@ -5,7 +5,7 @@ import { txDispatch, TxEvent } from '@/services/tx/txEvents' import { POLLING_INTERVAL } from '@/config/constants' import { Errors, logError } from '@/services/exceptions' import { getSafeTransaction } from '@/utils/transactions' -import { asError } from '../exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { type JsonRpcProvider, type TransactionReceipt } from 'ethers' import { SimpleTxWatcher } from '@/utils/SimpleTxWatcher' diff --git a/apps/web/src/store/__tests__/safeMessagesSlice.test.ts b/apps/web/src/store/__tests__/safeMessagesSlice.test.ts index 7ea60321a3..71a21b9925 100644 --- a/apps/web/src/store/__tests__/safeMessagesSlice.test.ts +++ b/apps/web/src/store/__tests__/safeMessagesSlice.test.ts @@ -1,6 +1,5 @@ +import type { MessageItem, DateLabel } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { createListenerMiddleware } from '@reduxjs/toolkit' -import { SafeMessageListItemType } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeMessage, SafeMessageDateLabel } from '@safe-global/safe-gateway-typescript-sdk' import * as safeMsgEvents from '@/services/safe-messages/safeMsgEvents' import { safeMessagesListener, safeMessagesSlice } from '../safeMessagesSlice' @@ -33,9 +32,9 @@ describe('safeMessagesSlice', () => { } const message = { - type: SafeMessageListItemType.MESSAGE, + type: 'MESSAGE', messageHash: '0x123', - } as SafeMessage + } as MessageItem const action = safeMessagesSlice.actions.set({ loading: false, @@ -85,8 +84,8 @@ describe('safeMessagesSlice', () => { dispatch: jest.fn(), } - const dateLabel: SafeMessageDateLabel = { - type: SafeMessageListItemType.DATE_LABEL, + const dateLabel: DateLabel = { + type: 'DATE_LABEL' as const, timestamp: 0, } @@ -115,9 +114,9 @@ describe('safeMessagesSlice', () => { } const message = { - type: SafeMessageListItemType.MESSAGE, + type: 'MESSAGE', messageHash: '0x456', - } as SafeMessage + } as MessageItem const action = safeMessagesSlice.actions.set({ loading: false, diff --git a/apps/web/src/store/__tests__/txHistorySlice.test.ts b/apps/web/src/store/__tests__/txHistorySlice.test.ts index f307c2f663..68632df397 100644 --- a/apps/web/src/store/__tests__/txHistorySlice.test.ts +++ b/apps/web/src/store/__tests__/txHistorySlice.test.ts @@ -1,12 +1,19 @@ import * as txEvents from '@/services/tx/txEvents' import { pendingTxBuilder } from '@/tests/builders/pendingTx' import { createListenerMiddleware } from '@reduxjs/toolkit' -import type { ConflictHeader, DateLabel, Label, TransactionListItem } from '@safe-global/safe-gateway-typescript-sdk' +import type { + ConflictHeader, + DateLabel, + Label, + TransactionListItem, + TransactionSummary, +} from '@safe-global/safe-gateway-typescript-sdk' import { LabelValue, TransactionListItemType } from '@safe-global/safe-gateway-typescript-sdk' import type { RootState } from '..' import type { PendingTxsState } from '../pendingTxsSlice' import { PendingStatus } from '../pendingTxsSlice' import { txHistoryListener, txHistorySlice } from '../txHistorySlice' +import { faker } from '@faker-js/faker' describe('txHistorySlice', () => { describe('txHistoryListener', () => { @@ -41,7 +48,10 @@ describe('txHistorySlice', () => { type: 'MULTISIG', nonce: 1, }, - }, + txInfo: { + type: 'TRANSFER', + }, + } as unknown as TransactionSummary, } as TransactionListItem const action = txHistorySlice.actions.set({ @@ -121,7 +131,7 @@ describe('txHistorySlice', () => { expect(txDispatchSpy).not.toHaveBeenCalled() }) - it('should not dispatch an event if tx is not pending', () => { + it('should not dispatch an event/invalidate owned Safes if tx is not pending', () => { const state = { pendingTxs: { '0x123': pendingTxBuilder().build(), @@ -137,7 +147,15 @@ describe('txHistorySlice', () => { type: TransactionListItemType.TRANSACTION, transaction: { id: '0x456', - }, + executionInfo: { + nonce: 1, + type: 'MULTISIG', + }, + txInfo: { + type: 'Custom', + methodName: 'createProxyWithNonce', + }, + } as unknown as TransactionSummary, } as TransactionListItem const action = txHistorySlice.actions.set({ @@ -150,13 +168,20 @@ describe('txHistorySlice', () => { listenerMiddlewareInstance.middleware(listenerApi)(jest.fn())(action) expect(txDispatchSpy).not.toHaveBeenCalled() + expect(listenerApi.dispatch).not.toHaveBeenCalled() }) - it('should clear a replaced pending transaction', () => { + it('should clear a replaced pending transaction and invalidate owned Safes for Safe creations', () => { const state = { pendingTxs: { '0x123': pendingTxBuilder().with({ nonce: 1, status: PendingStatus.INDEXING }).build(), } as PendingTxsState, + safeInfo: { + data: { + address: { value: faker.finance.ethereumAddress() }, + chainId: 1, + }, + } as unknown as RootState['safeInfo'], } as RootState const listenerApi = { @@ -172,7 +197,11 @@ describe('txHistorySlice', () => { nonce: 1, type: 'MULTISIG', }, - }, + txInfo: { + type: 'Custom', + methodName: 'createProxyWithNonce', + }, + } as unknown as TransactionSummary, } as TransactionListItem const action = txHistorySlice.actions.set({ @@ -184,7 +213,17 @@ describe('txHistorySlice', () => { listenerMiddlewareInstance.middleware(listenerApi)(jest.fn())(action) - expect(listenerApi.dispatch).toHaveBeenCalledWith({ + expect(listenerApi.dispatch).toHaveBeenCalledTimes(2) + expect(listenerApi.dispatch).toHaveBeenNthCalledWith(1, { + payload: [ + { + id: `${state.safeInfo.data!.chainId}:${state.safeInfo.data!.address.value}`, + type: 'OwnedSafes', + }, + ], + type: 'gatewayApi/invalidateTags', + }) + expect(listenerApi.dispatch).toHaveBeenNthCalledWith(2, { payload: expect.anything(), type: 'pendingTxs/clearPendingTx', }) diff --git a/apps/web/src/store/addedSafesSlice.ts b/apps/web/src/store/addedSafesSlice.ts index a46fa5b353..1c216d938e 100644 --- a/apps/web/src/store/addedSafesSlice.ts +++ b/apps/web/src/store/addedSafesSlice.ts @@ -1,10 +1,10 @@ import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' -import type { AddressEx, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeState, AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import type { RootState } from '.' export type AddedSafesOnChain = { [safeAddress: string]: { - owners: AddressEx[] + owners: AddressInfo[] threshold: number ethBalance?: string } @@ -29,7 +29,7 @@ export const addedSafesSlice = createSlice({ setAddedSafes: (_, action: PayloadAction) => { return action.payload }, - addOrUpdateSafe: (state, { payload }: PayloadAction<{ safe: SafeInfo }>) => { + addOrUpdateSafe: (state, { payload }: PayloadAction<{ safe: SafeState }>) => { const { chainId, address, owners, threshold } = payload.safe state[chainId] ??= {} diff --git a/apps/web/src/store/addressBookSlice.ts b/apps/web/src/store/addressBookSlice.ts index 72c3a975d5..58a3738a69 100644 --- a/apps/web/src/store/addressBookSlice.ts +++ b/apps/web/src/store/addressBookSlice.ts @@ -1,5 +1,5 @@ import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' -import { validateAddress } from '@/utils/validation' +import { validateAddress } from '@safe-global/utils/utils/validation' import pickBy from 'lodash/pickBy' import type { RootState } from '.' diff --git a/apps/web/src/store/api/gateway/index.ts b/apps/web/src/store/api/gateway/index.ts index 302dcde472..a52c6549d2 100644 --- a/apps/web/src/store/api/gateway/index.ts +++ b/apps/web/src/store/api/gateway/index.ts @@ -1,16 +1,15 @@ import { proposerEndpoints } from '@/store/api/gateway/proposers' import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query/react' + import { type AllOwnedSafes, getAllOwnedSafes, getTransactionDetails, type TransactionDetails, - type OwnedSafes, - getOwnedSafes, } from '@safe-global/safe-gateway-typescript-sdk' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' import { safeOverviewEndpoints } from './safeOverviews' -import { createSubmission, getSubmission } from '@safe-global/safe-client-gateway-sdk' +import { createSubmission, getSafe, getSafesByOwner, getSubmission } from '@safe-global/safe-client-gateway-sdk' export async function buildQueryFn(fn: () => Promise) { try { @@ -20,10 +19,14 @@ export async function buildQueryFn(fn: () => Promise) { } } +export function makeSafeTag(chainId: string, address: string): `${number}:0x${string}` { + return `${chainId}:${address}` as `${number}:0x${string}` +} + export const gatewayApi = createApi({ reducerPath: 'gatewayApi', baseQuery: fakeBaseQuery(), - tagTypes: ['Submissions'], + tagTypes: ['OwnedSafes', 'Submissions'], endpoints: (builder) => ({ getTransactionDetails: builder.query({ queryFn({ chainId, txId }) { @@ -35,14 +38,22 @@ export const gatewayApi = createApi({ return buildQueryFn(() => Promise.all(txIds.map((txId) => getTransactionDetails(chainId, txId)))) }, }), + getSafe: builder.query({ + queryFn({ chainId, safeAddress }) { + return buildQueryFn(() => getSafe({ params: { path: { chainId, safeAddress } } })) + }, + }), getAllOwnedSafes: builder.query({ queryFn({ walletAddress }) { return buildQueryFn(() => getAllOwnedSafes(walletAddress)) }, }), - getOwnedSafes: builder.query({ - queryFn({ chainId, walletAddress }) { - return buildQueryFn(() => getOwnedSafes(chainId, walletAddress)) + getOwnedSafes: builder.query({ + queryFn({ chainId, ownerAddress }) { + return buildQueryFn(() => getSafesByOwner({ params: { path: { chainId, ownerAddress } } })) + }, + providesTags: (_res, _err, { chainId, ownerAddress }) => { + return [{ type: 'OwnedSafes', id: makeSafeTag(chainId, ownerAddress) }] }, }), getSubmission: builder.query< @@ -86,6 +97,7 @@ export const { useAddProposerMutation, useGetSubmissionQuery, useCreateSubmissionMutation, + useGetSafeQuery, useGetSafeOverviewQuery, useGetMultipleSafeOverviewsQuery, useGetAllOwnedSafesQuery, diff --git a/apps/web/src/store/api/gateway/proposers.ts b/apps/web/src/store/api/gateway/proposers.ts index 108dba8481..a936410053 100644 --- a/apps/web/src/store/api/gateway/proposers.ts +++ b/apps/web/src/store/api/gateway/proposers.ts @@ -6,7 +6,7 @@ import { getDelegates } from '@safe-global/safe-gateway-typescript-sdk' import type { Delegate, DelegateResponse } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates' export const proposerEndpoints = ( - builder: EndpointBuilder>, 'Submissions', 'gatewayApi'>, + builder: EndpointBuilder>, 'OwnedSafes' | 'Submissions', 'gatewayApi'>, ) => ({ getProposers: builder.query({ queryFn({ chainId, safeAddress }) { @@ -21,16 +21,16 @@ export const proposerEndpoints = ( delegateAddress: string delegator: string signature: string - isHardwareWallet: boolean + shouldEthSign: boolean } >({ - queryFn({ chainId, safeAddress, delegateAddress, delegator, signature, isHardwareWallet }) { + queryFn({ chainId, safeAddress, delegateAddress, delegator, signature, shouldEthSign }) { const options = { params: { path: { chainId, delegateAddress } }, body: { safe: safeAddress, signature, delegator }, } return buildQueryFn(() => - isHardwareWallet + shouldEthSign ? deleteDelegate({ params: options.params, body: { ...options.body, delegate: delegateAddress } }) : deleteDelegateV2(options), ) @@ -60,16 +60,16 @@ export const proposerEndpoints = ( delegator: string label: string signature: string - isHardwareWallet: boolean + shouldEthSign: boolean } >({ - queryFn({ chainId, safeAddress, delegate, delegator, label, signature, isHardwareWallet }) { + queryFn({ chainId, safeAddress, delegate, delegator, label, signature, shouldEthSign }) { const options = { params: { path: { chainId } }, body: { delegate, delegator, label, signature, safe: safeAddress }, } - return buildQueryFn(() => (isHardwareWallet ? postDelegate(options) : postDelegateV2(options))) + return buildQueryFn(() => (shouldEthSign ? postDelegate(options) : postDelegateV2(options))) }, // Optimistically update the cache and roll back in case the mutation fails async onQueryStarted({ chainId, safeAddress, delegate, delegator, label }, { dispatch, queryFulfilled }) { diff --git a/apps/web/src/store/api/gateway/safeOverviews.ts b/apps/web/src/store/api/gateway/safeOverviews.ts index 8bb8aa728f..c60a97ab99 100644 --- a/apps/web/src/store/api/gateway/safeOverviews.ts +++ b/apps/web/src/store/api/gateway/safeOverviews.ts @@ -1,11 +1,12 @@ import { type EndpointBuilder } from '@reduxjs/toolkit/query/react' import { type SafeOverview, getSafeOverviews } from '@safe-global/safe-gateway-typescript-sdk' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import type { RootState } from '../..' import { selectCurrency } from '../../settingsSlice' import { type SafeItem } from '@/features/myAccounts/hooks/useAllSafes' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' +import { makeSafeTag } from '.' type SafeOverviewQueueItem = { safeAddress: string @@ -16,9 +17,7 @@ type SafeOverviewQueueItem = { } const _BATCH_SIZE = 10 -const _FETCH_TIMEOUT = 50 - -const makeSafeId = (chainId: string, address: string) => `${chainId}:${address}` as `${number}:0x${string}` +const _FETCH_TIMEOUT = 300 class SafeOverviewFetcher { private requestQueue: SafeOverviewQueueItem[] = [] @@ -61,7 +60,7 @@ class SafeOverviewFetcher { return } - const safeIds = nextBatch.map((request) => makeSafeId(request.chainId, request.safeAddress)) + const safeIds = nextBatch.map((request) => makeSafeTag(request.chainId, request.safeAddress)) const { walletAddress, currency } = nextBatch[0] overviews = await this.fetchSafeOverviews({ safeIds, currency, walletAddress }) } catch (err) { @@ -117,7 +116,7 @@ type MultiOverviewQueryParams = { safes: SafeItem[] } -export const safeOverviewEndpoints = (builder: EndpointBuilder) => ({ +export const safeOverviewEndpoints = (builder: EndpointBuilder) => ({ getSafeOverview: builder.query( { async queryFn({ safeAddress, walletAddress, chainId }, { getState }) { diff --git a/apps/web/src/store/authSlice.ts b/apps/web/src/store/authSlice.ts new file mode 100644 index 0000000000..baca0bfbc8 --- /dev/null +++ b/apps/web/src/store/authSlice.ts @@ -0,0 +1,51 @@ +import type { listenerMiddlewareInstance, RootState } from '@/store/index' +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { cgwClient } from '@safe-global/store/gateway/cgwClient' + +type AuthPayload = { + sessionExpiresAt: number | null + lastUsedSpace: string | null +} + +const initialState: AuthPayload = { + sessionExpiresAt: null, + lastUsedSpace: null, +} + +export const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setAuthenticated: (state, { payload }: PayloadAction) => { + state.sessionExpiresAt = payload + }, + + setUnauthenticated: (state) => { + state.sessionExpiresAt = null + }, + + setLastUsedSpace: (state, { payload }: PayloadAction) => { + state.lastUsedSpace = payload + }, + }, +}) + +export const { setAuthenticated, setUnauthenticated, setLastUsedSpace } = authSlice.actions + +export const isAuthenticated = (state: RootState): boolean => { + return !!state.auth.sessionExpiresAt && state.auth.sessionExpiresAt > Date.now() +} + +export const lastUsedSpace = (state: RootState) => { + return state.auth.lastUsedSpace +} + +export const authListener = (listenerMiddleware: typeof listenerMiddlewareInstance) => { + listenerMiddleware.startListening({ + actionCreator: authSlice.actions.setUnauthenticated, + effect: (_action, { dispatch }) => { + // @ts-ignore TS2322: Type string is not assignable to type FullTagDescription + dispatch(cgwClient.util.invalidateTags(['spaces'])) + }, + }) +} diff --git a/apps/web/src/store/balancesSlice.ts b/apps/web/src/store/balancesSlice.ts index 0308911958..9f260f2ed5 100644 --- a/apps/web/src/store/balancesSlice.ts +++ b/apps/web/src/store/balancesSlice.ts @@ -1,8 +1,9 @@ -import { type TokenInfo, type SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' +import { type TokenInfo } from '@safe-global/safe-gateway-typescript-sdk' import { createSelector } from '@reduxjs/toolkit' import { makeLoadableSlice } from './common' +import type { Balances } from '@safe-global/store/gateway/AUTO_GENERATED/balances' -export const initialBalancesState: SafeBalanceResponse = { +export const initialBalancesState: Balances = { items: [], fiatTotal: '', } diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts index 34376dc840..02d4dbd6f6 100644 --- a/apps/web/src/store/index.ts +++ b/apps/web/src/store/index.ts @@ -21,6 +21,7 @@ import { swapOrderStatusListener, txHistoryListener, txQueueListener, + authListener, } from './slices' import * as slices from './slices' import * as hydrate from './useHydrateStore' @@ -29,6 +30,7 @@ import { safePassApi } from './api/safePass' import { version as termsVersion } from '@/markdown/terms/version' import { cgwClient, setBaseUrl } from '@safe-global/store/gateway/cgwClient' import { GATEWAY_URL } from '@/config/gateway' +import { setupListeners } from '@reduxjs/toolkit/query' const rootReducer = combineReducers({ [slices.chainsSlice.name]: slices.chainsSlice.reducer, @@ -58,6 +60,7 @@ const rootReducer = combineReducers({ [safePassApi.reducerPath]: safePassApi.reducer, [slices.gatewayApi.reducerPath]: slices.gatewayApi.reducer, [cgwClient.reducerPath]: cgwClient.reducer, + [slices.authSlice.reducerPath]: slices.authSlice.reducer, }) const persistedSlices: (keyof Partial)[] = [ @@ -75,6 +78,7 @@ const persistedSlices: (keyof Partial)[] = [ slices.swapOrderSlice.name, slices.visitedSafesSlice.name, slices.orderByPreferenceSlice.name, + slices.authSlice.name, ] export const getPersistedState = () => { @@ -92,7 +96,14 @@ const middleware: Middleware<{}, RootState>[] = [ slices.gatewayApi.middleware, ] -const listeners = [safeMessagesListener, txHistoryListener, txQueueListener, swapOrderListener, swapOrderStatusListener] +const listeners = [ + safeMessagesListener, + txHistoryListener, + txQueueListener, + swapOrderListener, + swapOrderStatusListener, + authListener, +] export const _hydrationReducer: typeof rootReducer = (state, action) => { if (action.type === hydrate.HYDRATE_ACTION) { @@ -142,6 +153,8 @@ export const makeStore = ( listenToBroadcast(store) } + setupListeners(store.dispatch) + return store } diff --git a/apps/web/src/store/pendingTxsSlice.ts b/apps/web/src/store/pendingTxsSlice.ts index 8eee001fdf..fabe572134 100644 --- a/apps/web/src/store/pendingTxsSlice.ts +++ b/apps/web/src/store/pendingTxsSlice.ts @@ -1,7 +1,7 @@ import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' import type { RootState } from '@/store' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { selectChainIdAndSafeAddress } from '@/store/common' export enum PendingStatus { diff --git a/apps/web/src/store/safeInfoSlice.ts b/apps/web/src/store/safeInfoSlice.ts index d6a122f3a3..abf5e56f58 100644 --- a/apps/web/src/store/safeInfoSlice.ts +++ b/apps/web/src/store/safeInfoSlice.ts @@ -1,26 +1,5 @@ -import { type SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { makeLoadableSlice } from './common' - -export type ExtendedSafeInfo = SafeInfo & { deployed: boolean } - -export const defaultSafeInfo: ExtendedSafeInfo = { - address: { value: '' }, - chainId: '', - nonce: -1, - threshold: 0, - owners: [], - implementation: { value: '' }, - implementationVersionState: '' as SafeInfo['implementationVersionState'], - modules: null, - guard: null, - fallbackHandler: { value: '' }, - version: '', - collectiblesTag: '', - txQueuedTag: '', - txHistoryTag: '', - messagesTag: '', - deployed: true, -} +import type { ExtendedSafeInfo } from '@safe-global/store/slices/SafeInfo/types' const { slice, selector } = makeLoadableSlice('safeInfo', undefined as ExtendedSafeInfo | undefined) diff --git a/apps/web/src/store/safeMessagesSlice.ts b/apps/web/src/store/safeMessagesSlice.ts index bb783b41b2..b7c40e50f9 100644 --- a/apps/web/src/store/safeMessagesSlice.ts +++ b/apps/web/src/store/safeMessagesSlice.ts @@ -1,4 +1,4 @@ -import type { SafeMessageListPage } from '@safe-global/safe-gateway-typescript-sdk' +import type { MessagePage } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import type { listenerMiddlewareInstance } from '.' import { safeMsgDispatch, SafeMsgEvent } from '@/services/safe-messages/safeMsgEvents' @@ -6,7 +6,7 @@ import { isSafeMessageListItem } from '@/utils/safe-message-guards' import { makeLoadableSlice } from '@/store/common' import { selectPendingSafeMessages } from '@/store/pendingSafeMessagesSlice' -const { slice, selector } = makeLoadableSlice('safeMessages', undefined as SafeMessageListPage | undefined) +const { slice, selector } = makeLoadableSlice('safeMessages', undefined as MessagePage | undefined) export const safeMessagesSlice = slice export const selectSafeMessages = selector diff --git a/apps/web/src/store/settingsSlice.ts b/apps/web/src/store/settingsSlice.ts index e977c9d217..9ae4bc1caf 100644 --- a/apps/web/src/store/settingsSlice.ts +++ b/apps/web/src/store/settingsSlice.ts @@ -4,16 +4,7 @@ import merge from 'lodash/merge' import type { RootState } from '@/store' import isEqual from 'lodash/isEqual' - -export type EnvState = { - tenderly: { - url: string - accessToken: string - } - rpc: { - [chainId: string]: string - } -} +import type { EnvState } from '@safe-global/store/settingsSlice' export enum TOKEN_LISTS { TRUSTED = 'TRUSTED', @@ -55,9 +46,13 @@ export const initialState: SettingsState = { hideSuspiciousTransactions: true, + // The `shortName` object contains settings related to short name interactions. + // The `copy` setting determines if the short name can be copied, while the `qr` setting + // determines if a QR code for the short name is displayed. Both are disabled by default + // for consistency and to avoid unintended behavior. shortName: { - copy: true, - qr: true, + copy: false, + qr: false, }, theme: {}, env: { diff --git a/apps/web/src/store/slices.ts b/apps/web/src/store/slices.ts index 7f4dd6cd21..435b3a4a3f 100644 --- a/apps/web/src/store/slices.ts +++ b/apps/web/src/store/slices.ts @@ -23,3 +23,4 @@ export * from './api/gateway' export * from './api/gateway/safeOverviews' export * from './visitedSafesSlice' export * from './orderByPreferenceSlice' +export * from './authSlice' diff --git a/apps/web/src/store/spendingLimitsSlice.ts b/apps/web/src/store/spendingLimitsSlice.ts index f742a352ab..301538a4ef 100644 --- a/apps/web/src/store/spendingLimitsSlice.ts +++ b/apps/web/src/store/spendingLimitsSlice.ts @@ -6,7 +6,7 @@ export type SpendingLimitState = { token: { address: string symbol: string - decimals: number + decimals?: number | null logoUri?: string } amount: string diff --git a/apps/web/src/store/txHistorySlice.ts b/apps/web/src/store/txHistorySlice.ts index 80b1e35aeb..477be380e3 100644 --- a/apps/web/src/store/txHistorySlice.ts +++ b/apps/web/src/store/txHistorySlice.ts @@ -3,6 +3,7 @@ import { createSelector } from '@reduxjs/toolkit' import type { TransactionListPage } from '@safe-global/safe-gateway-typescript-sdk' import { isCreationTxInfo, + isCustomTxInfo, isIncomingTransfer, isMultisigExecutionInfo, isTransactionListItem, @@ -10,6 +11,7 @@ import { import { txDispatch, TxEvent } from '@/services/tx/txEvents' import { clearPendingTx, selectPendingTxs } from './pendingTxsSlice' import { makeLoadableSlice } from './common' +import { gatewayApi, makeSafeTag, selectSafeInfo } from './slices' const { slice, selector } = makeLoadableSlice('txHistory', undefined as TransactionListPage | undefined) @@ -45,6 +47,30 @@ export const txHistoryListener = (listenerMiddleware: typeof listenerMiddlewareI if (!pendingTxByNonce) continue + // Invalidate getOwnedSafe cache as nested Safe was (likely) created + if (isCustomTxInfo(result.transaction.txInfo)) { + const method = result.transaction.txInfo.methodName + const deployedSafe = method === 'createProxyWithNonce' + const likelyDeployedSafe = method === 'multiSend' + + if (deployedSafe || likelyDeployedSafe) { + const safe = selectSafeInfo(listenerApi.getState()) + const safeAddress = safe.data?.address?.value + const chainId = safe.data?.chainId + + if (chainId && safeAddress) { + listenerApi.dispatch( + gatewayApi.util.invalidateTags([ + { + type: 'OwnedSafes', + id: makeSafeTag(chainId, safeAddress), + }, + ]), + ) + } + } + } + const txId = result.transaction.id const [pendingTxId, pendingTx] = pendingTxByNonce diff --git a/apps/web/src/store/txQueueSlice.ts b/apps/web/src/store/txQueueSlice.ts index f5decbd539..fe7f994608 100644 --- a/apps/web/src/store/txQueueSlice.ts +++ b/apps/web/src/store/txQueueSlice.ts @@ -5,7 +5,7 @@ import type { RootState } from '@/store' import { makeLoadableSlice } from './common' import { isMultisigExecutionInfo, isTransactionListItem } from '@/utils/transaction-guards' import { PendingStatus, selectPendingTxs } from './pendingTxsSlice' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import { txDispatch, TxEvent } from '@/services/tx/txEvents' const SIGNING_STATES = [PendingStatus.SIGNING, PendingStatus.NESTED_SIGNING] diff --git a/apps/web/src/styles/vars.css b/apps/web/src/styles/vars.css index 5a01c90fa2..dae50580ad 100644 --- a/apps/web/src/styles/vars.css +++ b/apps/web/src/styles/vars.css @@ -20,11 +20,11 @@ --color-error-background: #ffe6ea; --color-success-dark: #028d4c; --color-success-main: #00b460; - --color-success-light: #72f5b8; + --color-success-light: #d3f2e4; --color-success-background: #effaf1; --color-info-dark: #52bfdc; --color-info-main: #5fddff; - --color-info-light: #b7f0ff; + --color-info-light: #d7f6ff; --color-info-background: #effcff; --color-warning-dark: #c04c32; --color-warning-main: #ff8061; @@ -34,10 +34,12 @@ --color-background-main: #f4f4f4; --color-background-paper: #ffffff; --color-background-light: #effff4; + --color-background-lightGrey: #dddee0; --color-backdrop-main: #636669; --color-logo-main: #121312; --color-logo-background: #eeeff0; --color-static-main: #121312; + --color-static-light: #636669; --space-1: 8px; --space-2: 16px; --space-3: 24px; @@ -61,7 +63,7 @@ --color-primary-light: #a1a3a7; --color-secondary-dark: #636669; --color-secondary-main: #ffffff; - --color-secondary-light: #12ff80; + --color-secondary-light: #b0ffc9; --color-secondary-background: #1b2a22; --color-border-main: #636669; --color-border-light: #303033; @@ -70,7 +72,7 @@ --color-error-main: #ff5f72; --color-error-light: #ffb4bd; --color-error-background: #2f2527; - --color-success-dark: #028d4c; + --color-success-dark: #388e3c; --color-success-main: #00b460; --color-success-light: #81c784; --color-success-background: #1f2920; @@ -86,10 +88,12 @@ --color-background-main: #121312; --color-background-paper: #1c1c1c; --color-background-light: #1b2a22; + --color-background-lightGrey: #303033; --color-backdrop-main: #636669; --color-logo-main: #ffffff; --color-logo-background: #303033; --color-static-main: #121312; + --color-static-light: #636669; } /* The same as above for the brief moment before JS loads */ @@ -103,7 +107,7 @@ --color-primary-light: #a1a3a7; --color-secondary-dark: #636669; --color-secondary-main: #ffffff; - --color-secondary-light: #12ff80; + --color-secondary-light: #b0ffc9; --color-secondary-background: #1b2a22; --color-border-main: #636669; --color-border-light: #303033; @@ -112,7 +116,7 @@ --color-error-main: #ff5f72; --color-error-light: #ffb4bd; --color-error-background: #2f2527; - --color-success-dark: #028d4c; + --color-success-dark: #388e3c; --color-success-main: #00b460; --color-success-light: #81c784; --color-success-background: #1f2920; @@ -128,9 +132,11 @@ --color-background-main: #121312; --color-background-paper: #1c1c1c; --color-background-light: #1b2a22; + --color-background-lightGrey: #303033; --color-backdrop-main: #636669; --color-logo-main: #ffffff; --color-logo-background: #303033; --color-static-main: #121312; + --color-static-light: #636669; } } diff --git a/apps/web/src/tests/builders/chains.ts b/apps/web/src/tests/builders/chains.ts index ed3b333f0e..67d16a2b95 100644 --- a/apps/web/src/tests/builders/chains.ts +++ b/apps/web/src/tests/builders/chains.ts @@ -13,10 +13,10 @@ import type { } from '@safe-global/safe-gateway-typescript-sdk' import { Builder } from '@/tests/Builder' -import { FEATURES } from '@/utils/chains' import { generateRandomArray } from './utils' import type { IBuilder } from '@/tests/Builder' import type useChains from '@/hooks/useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' const rpcUriBuilder = (): IBuilder => { return Builder.new().with({ diff --git a/apps/web/src/tests/builders/messages.ts b/apps/web/src/tests/builders/messages.ts index 52478e1241..a58efb49f4 100644 --- a/apps/web/src/tests/builders/messages.ts +++ b/apps/web/src/tests/builders/messages.ts @@ -1,9 +1,9 @@ +import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { faker } from '@faker-js/faker' -import type { EIP712TypedData } from '@safe-global/safe-gateway-typescript-sdk' import { Builder, type IBuilder } from '../Builder' -export function eip712TypedDataBuilder(): IBuilder { - return Builder.new().with({ +export function eip712TypedDataBuilder(): IBuilder { + return Builder.new().with({ domain: { chainId: faker.number.int({ min: 1, max: 10000 }), name: faker.string.alpha(), diff --git a/apps/web/src/tests/builders/safe.ts b/apps/web/src/tests/builders/safe.ts index e7f9972b6f..27d26aa412 100644 --- a/apps/web/src/tests/builders/safe.ts +++ b/apps/web/src/tests/builders/safe.ts @@ -1,11 +1,14 @@ -import type { ExtendedSafeInfo } from '@/store/safeInfoSlice' +import type { ExtendedSafeInfo } from '@safe-global/store/slices/SafeInfo/types' import { faker } from '@faker-js/faker' import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeInfo, AddressEx } from '@safe-global/safe-gateway-typescript-sdk' +import { + type SafeState as SafeInfo, + type AddressInfo as AddressEx, +} from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { Builder } from '../Builder' import { generateRandomArray } from './utils' -import { checksumAddress } from '@/utils/addresses' +import { checksumAddress } from '@safe-global/utils/utils/addresses' import type { IBuilder } from '../Builder' const MAX_OWNERS_LENGTH = 10 diff --git a/apps/web/src/tests/builders/safeItem.ts b/apps/web/src/tests/builders/safeItem.ts index a6065e3c0b..889b20e93e 100644 --- a/apps/web/src/tests/builders/safeItem.ts +++ b/apps/web/src/tests/builders/safeItem.ts @@ -1,6 +1,6 @@ import type { SafeItem } from '@/features/myAccounts/hooks/useAllSafes' import { Builder, type IBuilder } from '@/tests/Builder' -import { checksumAddress } from '@/utils/addresses' +import { checksumAddress } from '@safe-global/utils/utils/addresses' import { faker } from '@faker-js/faker' export function safeItemBuilder(): IBuilder { diff --git a/apps/web/src/tests/builders/safeMessage.ts b/apps/web/src/tests/builders/safeMessage.ts index b3a9da6a34..1d3552bfdc 100644 --- a/apps/web/src/tests/builders/safeMessage.ts +++ b/apps/web/src/tests/builders/safeMessage.ts @@ -1,12 +1,12 @@ +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { Builder, type IBuilder } from '@/tests/Builder' import { faker } from '@faker-js/faker' -import { SafeMessageListItemType, SafeMessageStatus, type SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' -export function safeMsgBuilder(): IBuilder { - return Builder.new().with({ - type: SafeMessageListItemType.MESSAGE, +export function safeMsgBuilder(): IBuilder { + return Builder.new().with({ + type: 'MESSAGE', messageHash: faker.string.hexadecimal(), - status: SafeMessageStatus.NEEDS_CONFIRMATION, + status: 'NEEDS_CONFIRMATION', logoUri: null, name: null, message: 'Message text', diff --git a/apps/web/src/tests/builders/safeTx.ts b/apps/web/src/tests/builders/safeTx.ts index 449238592a..ba3cac4611 100644 --- a/apps/web/src/tests/builders/safeTx.ts +++ b/apps/web/src/tests/builders/safeTx.ts @@ -1,14 +1,16 @@ import { Builder, type IBuilder } from '@/tests/Builder' import { faker } from '@faker-js/faker' -import { type SafeTransactionData, type SafeSignature, type SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { type SafeTransactionData, type SafeSignature, type SafeTransaction } from '@safe-global/types-kit' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { type Custom, DetailedExecutionInfoType, type MultisigExecutionInfo, + Operation, type TransactionInfo, TransactionInfoType, type TransactionSummary, + type TransactionData, } from '@safe-global/safe-gateway-typescript-sdk' import { TransactionStatus } from '@safe-global/safe-apps-sdk' @@ -111,3 +113,15 @@ export function txInfoBuilder(): IBuilder { value: faker.number.bigInt({ min: 0, max: 10n ** 18n }).toString(), }) } + +export function txDataBuilder(): IBuilder { + return Builder.new().with({ + hexData: faker.string.hexadecimal({ length: faker.number.int({ max: 128 }) }), + to: { value: faker.finance.ethereumAddress() }, + addressInfoIndex: {}, + value: faker.string.numeric({ length: { min: 1, max: 1000 } }), + operation: faker.helpers.enumValue(Operation), + trustedDelegateCallTarget: true, + dataDecoded: undefined, + }) +} diff --git a/apps/web/src/tests/mocks/contractManager.ts b/apps/web/src/tests/mocks/contractManager.ts index 86eb6c8d13..55268adc45 100644 --- a/apps/web/src/tests/mocks/contractManager.ts +++ b/apps/web/src/tests/mocks/contractManager.ts @@ -1,7 +1,7 @@ import { Gnosis_safe__factory, Multi_send__factory, -} from '@/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' +} from '@safe-global/utils/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' import { faker } from '@faker-js/faker' import type { ContractManager } from '@safe-global/protocol-kit' diff --git a/apps/web/src/tests/test-utils.tsx b/apps/web/src/tests/test-utils.tsx index 894d7c9cfe..50a60445e1 100644 --- a/apps/web/src/tests/test-utils.tsx +++ b/apps/web/src/tests/test-utils.tsx @@ -7,12 +7,11 @@ import { ThemeProvider } from '@mui/material/styles' import SafeThemeProvider from '@/components/theme/SafeThemeProvider' import { type RootState, makeStore, useHydrateStore } from '@/store' import * as web3 from '@/hooks/wallets/web3' -import { type JsonRpcProvider, AbiCoder } from 'ethers' -import { id } from 'ethers' import { Provider } from 'react-redux' -import { checksumAddress } from '@/utils/addresses' +import { checksumAddress } from '@safe-global/utils/utils/addresses' import { faker } from '@faker-js/faker' import { userEvent } from '@testing-library/user-event' +import { createMockWeb3Provider, type MockCallImplementation } from '@safe-global/utils/tests/web3Provider' const mockRouter = (props: Partial = {}): NextRouter => ({ asPath: '/', @@ -86,57 +85,6 @@ function customRenderHook( return renderHook(render, { wrapper, ...options }) } -type MockCallImplementation = { - signature: string - returnType: string - returnValue: unknown -} - -/** - * Creates a getWeb3 spy which returns a Web3Provider with a mocked `call` and `resolveName` function. - * - * @param callImplementations list of supported function calls and the mocked return value. i.e. - * ``` - * [{ - * signature: "balanceOf(address)", - * returnType: "uint256", - * returnValue: "200" - * }] - * ``` - * @param resolveName mock ens resolveName implementation - * @returns web3provider jest spy - */ -const mockWeb3Provider = ( - callImplementations: MockCallImplementation[], - resolveName?: (name: string) => string, -): JsonRpcProvider => { - const mockWeb3ReadOnly = { - call: jest.fn((tx: { data: string; to: string }) => { - { - const matchedImplementation = callImplementations.find((implementation) => { - return tx.data.startsWith(id(implementation.signature).slice(0, 10)) - }) - - if (!matchedImplementation) { - throw new Error(`No matcher for call data: ${tx.data}`) - } - - return AbiCoder.defaultAbiCoder().encode( - [matchedImplementation.returnType], - [matchedImplementation.returnValue], - ) - } - }), - estimateGas: jest.fn(() => { - return Promise.resolve(50_000n) - }), - _isProvider: true, - resolveName, - } as unknown as JsonRpcProvider - jest.spyOn(web3, 'useWeb3ReadOnly').mockReturnValue(mockWeb3ReadOnly) - return mockWeb3ReadOnly -} - export const fakerChecksummedAddress = () => checksumAddress(faker.finance.ethereumAddress()) // https://testing-library.com/docs/user-event/intro#writing-tests-with-userevent @@ -153,10 +101,19 @@ export const renderWithUserEvent = ( } } +export const mockWeb3Provider = ( + callImplementations: MockCallImplementation[], + resolveName?: (name: string) => string, + chainId?: string, +) => { + const web3Provider = createMockWeb3Provider(callImplementations, resolveName, chainId) + jest.spyOn(web3, 'useWeb3ReadOnly').mockReturnValue(web3Provider) + jest.spyOn(web3, 'getWeb3ReadOnly').mockReturnValue(web3Provider) + return web3Provider +} // re-export everything export * from '@testing-library/react' // override render method export { customRender as render } export { customRenderHook as renderHook } -export { mockWeb3Provider } diff --git a/apps/web/src/tests/transactions.ts b/apps/web/src/tests/transactions.ts index 3cfdfea9db..d70744921c 100644 --- a/apps/web/src/tests/transactions.ts +++ b/apps/web/src/tests/transactions.ts @@ -1,9 +1,9 @@ import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { solidityPacked, concat } from 'ethers' -import { OperationType } from '@safe-global/safe-core-sdk-types' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { OperationType } from '@safe-global/types-kit' +import type { SafeTransaction } from '@safe-global/types-kit' -import { ERC20__factory, ERC721__factory, Multi_send__factory } from '@/types/contracts' +import { ERC20__factory, ERC721__factory, Multi_send__factory } from '@safe-global/utils/types/contracts' import EthSafeTransaction from '@safe-global/protocol-kit/dist/src/utils/transactions/SafeTransaction' import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { TransactionStatus } from '@safe-global/safe-apps-sdk' diff --git a/apps/web/src/utils/__tests__/chains.test.ts b/apps/web/src/utils/__tests__/chains.test.ts index 3f5d16b260..548d31ace3 100644 --- a/apps/web/src/utils/__tests__/chains.test.ts +++ b/apps/web/src/utils/__tests__/chains.test.ts @@ -1,4 +1,5 @@ -import { hasFeature, getBlockExplorerLink, FEATURES, getLatestSafeVersion } from '@/utils/chains' +import { getBlockExplorerLink } from '@safe-global/utils/utils/chains' +import { FEATURES, getLatestSafeVersion, hasFeature } from '@safe-global/utils/utils/chains' import { CONFIG_SERVICE_CHAINS } from '@/tests/mocks/chains' import { chainBuilder } from '@/tests/builders/chains' @@ -42,34 +43,18 @@ describe('chains', () => { describe('chains', () => { describe('getLatestSafeVersion', () => { it('should return the version from recommendedMasterCopyVersion', () => { - expect( - getLatestSafeVersion( - chainBuilder().with({ chainId: '1', recommendedMasterCopyVersion: '1.4.1' }).build(), - true, - ), - ).toEqual('1.4.1') - expect( - getLatestSafeVersion( - chainBuilder().with({ chainId: '137', recommendedMasterCopyVersion: '1.3.0' }).build(), - true, - ), - ).toEqual('1.3.0') - }) - - it('should always return LATEST_VERSION if true is not passed', () => { expect( getLatestSafeVersion(chainBuilder().with({ chainId: '1', recommendedMasterCopyVersion: '1.4.1' }).build()), ).toEqual('1.4.1') expect( getLatestSafeVersion(chainBuilder().with({ chainId: '137', recommendedMasterCopyVersion: '1.3.0' }).build()), - ).toEqual('1.4.1') + ).toEqual('1.3.0') }) it('should fall back to LATEST_VERSION', () => { expect( getLatestSafeVersion( chainBuilder().with({ chainId: '11155111', recommendedMasterCopyVersion: null }).build(), - true, ), ).toEqual('1.4.1') }) diff --git a/apps/web/src/utils/__tests__/nested-safes.test.ts b/apps/web/src/utils/__tests__/nested-safes.test.ts new file mode 100644 index 0000000000..9d76e437cf --- /dev/null +++ b/apps/web/src/utils/__tests__/nested-safes.test.ts @@ -0,0 +1,188 @@ +import type { TransactionData } from '@safe-global/safe-gateway-typescript-sdk' +import { _getFactoryAddressAndSetupData } from '../nested-safes' + +describe('Nested Safes', () => { + describe('getFactoryAddressAndSetupData', () => { + it('should return the factory address and setup data for direct createProxyWithNonce calls', () => { + const txData = { + dataDecoded: { + method: 'createProxyWithNonce', + parameters: [ + { + name: '_singleton', + type: 'address', + value: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + valueDecoded: null, + }, + { + name: 'initializer', + type: 'bytes', + value: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + valueDecoded: null, + }, + { + name: 'saltNonce', + type: 'uint256', + value: '1739277192719', + valueDecoded: null, + }, + ], + }, + to: { + value: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + name: 'SafeProxyFactory 1.4.1', + logoUri: + 'https://safe-transaction-assets.staging.5afe.dev/contracts/logos/0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67.png', + }, + } as unknown as TransactionData + + const result = _getFactoryAddressAndSetupData(txData) + + expect(result).toEqual({ + factoryAddress: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + singleton: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + initializer: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + saltNonce: '1739277192719', + }) + }) + + it('should return the factory address and setup data for createProxyWithNonce calls in a multiSend', () => { + const txData = { + dataDecoded: { + method: 'multiSend', + parameters: [ + { + name: 'transactions', + type: 'bytes', + value: + '0x004e1dcf7ad4e460cfd30791ccc4f9c8a4f820ec67000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002441688f0b900000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000194f500278f00000000000000000000000000000000000000000000000000000000000001a4b63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c7620000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002df9edaab4aab795339b334c9152eec7f27aeabc0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000', + valueDecoded: [ + { + operation: 0, + to: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + value: '0', + data: '0x1688f0b900000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000194f500278f00000000000000000000000000000000000000000000000000000000000001a4b63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c7620000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + dataDecoded: { + method: 'createProxyWithNonce', + parameters: [ + { + name: '_singleton', + type: 'address', + value: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + }, + { + name: 'initializer', + type: 'bytes', + value: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + }, + { + name: 'saltNonce', + type: 'uint256', + value: '1739277215631', + }, + ], + }, + }, + { + operation: 0, + to: '0x2df9edAAb4Aab795339b334C9152EeC7f27aeAbc', + value: '1000000000000000000', + data: null, + dataDecoded: null, + }, + ], + }, + ], + }, + } as unknown as TransactionData + + const result = _getFactoryAddressAndSetupData(txData) + + expect(result).toEqual({ + factoryAddress: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + singleton: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + initializer: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + saltNonce: '1739277215631', + }) + }) + + it('should throw an error if the method is invalid', () => { + const txData = { + dataDecoded: { + method: 'invalid', + parameters: [], + }, + } as unknown as TransactionData + + expect(() => _getFactoryAddressAndSetupData(txData)).toThrow('Invalid method') + }) + + it('should throw an error if the factory address is missing', () => { + const txData = { + dataDecoded: { + method: 'createProxyWithNonce', + parameters: [], + }, + to: { + value: null, + }, + } as unknown as TransactionData + + expect(() => _getFactoryAddressAndSetupData(txData)).toThrow('Missing factory address') + }) + + it('should throw an error if the parameters are invalid', () => { + const txData = { + dataDecoded: { + method: 'createProxyWithNonce', + }, + to: { + value: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + }, + } as unknown as TransactionData + + expect(() => _getFactoryAddressAndSetupData(txData)).toThrow('Invalid parameters') + }) + + it.each(['_singleton', 'initializer', 'saltNonce'])('should throw an error if the %s is invalid', (name) => { + const txData = { + dataDecoded: { + method: 'createProxyWithNonce', + parameters: [ + { + name: '_singleton', + type: 'address', + value: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + valueDecoded: null, + }, + { + name: 'initializer', + type: 'bytes', + value: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + valueDecoded: null, + }, + { + name: 'saltNonce', + type: 'uint256', + value: '1739277192719', + valueDecoded: null, + }, + ], + }, + to: { + value: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + }, + } as unknown as TransactionData + const index = txData.dataDecoded?.parameters?.findIndex((parameter) => parameter.name === name) + // @ts-expect-error - as txData is const, it expects new value to be string + txData.dataDecoded.parameters[index].value = null + + expect(() => _getFactoryAddressAndSetupData(txData)).toThrow('Invalid parameter values') + }) + }) +}) diff --git a/apps/web/src/utils/__tests__/safe-hashes.test.ts b/apps/web/src/utils/__tests__/safe-hashes.test.ts index 562748ef60..a26b8bf90a 100644 --- a/apps/web/src/utils/__tests__/safe-hashes.test.ts +++ b/apps/web/src/utils/__tests__/safe-hashes.test.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker' import { getDomainHash, getSafeMessageMessageHash, getSafeTxMessageHash } from '../safe-hashes' import { AbiCoder, hashMessage, keccak256, TypedDataEncoder } from 'ethers' -import type { SafeTransactionData, SafeVersion } from '@safe-global/safe-core-sdk-types' +import type { SafeTransactionData, SafeVersion } from '@safe-global/types-kit' // <= 1.2.0 // keccak256("EIP712Domain(address verifyingContract)"); diff --git a/apps/web/src/utils/__tests__/safe-messages.test.ts b/apps/web/src/utils/__tests__/safe-messages.test.ts index d74f720853..40f0e6697f 100644 --- a/apps/web/src/utils/__tests__/safe-messages.test.ts +++ b/apps/web/src/utils/__tests__/safe-messages.test.ts @@ -1,10 +1,14 @@ import { zeroPadValue } from 'ethers' import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { generateSafeMessageTypedData, isBlindSigningPayload, isOffchainEIP1271Supported } from '../safe-messages' +import { + generateSafeMessageTypedData, + isBlindSigningPayload, + isOffchainEIP1271Supported, +} from '@safe-global/utils/utils/safe-messages' import { toBeHex } from 'ethers' -import { FEATURES } from '../chains' import { faker } from '@faker-js/faker' +import { FEATURES } from '@safe-global/utils/utils/chains' const MOCK_ADDRESS = zeroPadValue('0x0123', 20) @@ -31,6 +35,7 @@ describe('safe-messages', () => { types: { SafeMessage: [{ name: 'message', type: 'bytes' }], }, + primaryType: 'SafeMessage', message: { message: '0xaa05af77f274774b8bdc7b61d98bc40da523dc2821fdea555f4d6aa413199bcc', }, @@ -57,6 +62,7 @@ describe('safe-messages', () => { types: { SafeMessage: [{ name: 'message', type: 'bytes' }], }, + primaryType: 'SafeMessage', message: { message: '0xaa05af77f274774b8bdc7b61d98bc40da523dc2821fdea555f4d6aa413199bcc', }, @@ -147,6 +153,7 @@ describe('safe-messages', () => { types: { SafeMessage: [{ name: 'message', type: 'bytes' }], }, + primaryType: 'SafeMessage', message: { message: '0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2', }, @@ -236,6 +243,7 @@ describe('safe-messages', () => { types: { SafeMessage: [{ name: 'message', type: 'bytes' }], }, + primaryType: 'SafeMessage', message: { message: '0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2', }, diff --git a/apps/web/src/utils/__tests__/safe-migrations.test.ts b/apps/web/src/utils/__tests__/safe-migrations.test.ts index 478b346ee3..c454c9eae8 100644 --- a/apps/web/src/utils/__tests__/safe-migrations.test.ts +++ b/apps/web/src/utils/__tests__/safe-migrations.test.ts @@ -1,388 +1,127 @@ -import { - type ChainInfo, - ImplementationVersionState, - type TransactionData, -} from '@safe-global/safe-gateway-typescript-sdk' -import { OperationType } from '@safe-global/safe-core-sdk-types' -import { prependSafeToL2Migration } from '../safe-migrations' -import { extractMigrationL2MasterCopyAddress } from '@/features/multichain/utils/extract-migration-data' -import { extendedSafeInfoBuilder } from '@/tests/builders/safe' -import { chainBuilder } from '@/tests/builders/chains' -import { safeSignatureBuilder, safeTxBuilder, safeTxDataBuilder } from '@/tests/builders/safeTx' -import { - getMultiSendCallOnlyDeployment, - getMultiSendDeployment, - getSafeL2SingletonDeployment, - getSafeSingletonDeployment, - getSafeToL2MigrationDeployment, -} from '@safe-global/safe-deployments' -import type Safe from '@safe-global/protocol-kit' -import { encodeMultiSendData } from '@safe-global/protocol-kit' -import { Multi_send__factory, Safe_to_l2_migration__factory } from '@/types/contracts' +import { type ChainInfo, type TransactionData } from '@safe-global/safe-gateway-typescript-sdk' +import { OperationType } from '@safe-global/types-kit' +import { Safe_migration__factory } from '@safe-global/utils/types/contracts' import { faker } from '@faker-js/faker' -import { getAndValidateSafeSDK } from '@/services/tx/tx-sender/sdk' -import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' -import { checksumAddress } from '../addresses' -import { createUpdateMigration } from '../safe-migrations' -jest.mock('@/services/tx/tx-sender/sdk') - -const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() -const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress -const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() -const multisendInterface = Multi_send__factory.createInterface() - -describe('prependSafeToL2Migration', () => { - const mockGetAndValidateSdk = getAndValidateSafeSDK as jest.MockedFunction - - beforeEach(() => { - // Mock create Tx - mockGetAndValidateSdk.mockReturnValue({ - createTransaction: ({ transactions, onlyCalls }) => { - return Promise.resolve( - safeTxBuilder() - .with({ - data: safeTxDataBuilder() - .with({ - to: onlyCalls - ? (getMultiSendCallOnlyDeployment()?.defaultAddress ?? faker.finance.ethereumAddress()) - : (getMultiSendDeployment()?.defaultAddress ?? faker.finance.ethereumAddress()), - value: '0', - data: Multi_send__factory.createInterface().encodeFunctionData('multiSend', [ - encodeMultiSendData(transactions), - ]), - nonce: 0, - operation: 1, - }) - .build(), - }) - .build(), - ) - }, - } as Safe) - }) - - it('should return undefined for undefined safeTx', () => { - expect( - prependSafeToL2Migration(undefined, extendedSafeInfoBuilder().build(), chainBuilder().build()), - ).resolves.toBeUndefined() - }) - - it('should throw if chain is undefined', () => { - expect(() => prependSafeToL2Migration(undefined, extendedSafeInfoBuilder().build(), undefined)).toThrowError() - }) - - it('should not modify tx if the chain is L1', () => { - const safeTx = safeTxBuilder() - .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) - .build() - - const safeInfo = extendedSafeInfoBuilder() - .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) - .build() - - expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: false }).build())).resolves.toEqual( - safeTx, - ) - }) - - it('should not modify tx if the nonce is > 0', () => { - const safeTx = safeTxBuilder() - .with({ data: safeTxDataBuilder().with({ nonce: 1 }).build() }) - .build() - - const safeInfo = extendedSafeInfoBuilder() - .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) - .build() - - expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true }).build())).resolves.toEqual( - safeTx, - ) - }) - - it('should not modify tx if implementationState is correct', () => { - const safeTx = safeTxBuilder() - .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) - .build() - - const safeInfo = extendedSafeInfoBuilder() - .with({ implementationVersionState: ImplementationVersionState.UP_TO_DATE }) - .build() - expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true }).build())).resolves.toEqual( - safeTx, - ) - }) - - it('should not modify tx if the tx is already signed', () => { - const safeTx = safeTxBuilder() - .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) - .build() - - safeTx.addSignature(safeSignatureBuilder().build()) - - const safeInfo = extendedSafeInfoBuilder() - .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) - .build() - - expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true }).build())).resolves.toEqual( - safeTx, - ) - }) - - it('should not modify tx if the chain has no migration lib deployed', () => { - const safeTx = safeTxBuilder() - .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) - .build() +import { createUpdateMigration, isMigrateL2SingletonCall } from '../safe-migrations' +import { getSafeMigrationDeployment } from '@safe-global/safe-deployments' - const safeInfo = extendedSafeInfoBuilder() - .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) - .build() - - expect( - prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true, chainId: '69420' }).build()), - ).resolves.toEqual(safeTx) - }) +jest.mock('@/services/tx/tx-sender/sdk') - it('should not modify tx if the tx already migrates', () => { - const safeL2SingletonDeployment = getSafeL2SingletonDeployment()?.defaultAddress +const SafeMigrationInterface = Safe_migration__factory.createInterface() +const safeMigrationAddress = getSafeMigrationDeployment({ version: '1.4.1' })?.defaultAddress - const safeTx = safeTxBuilder() - .with({ - data: safeTxDataBuilder() - .with({ - nonce: 0, - to: safeToL2MigrationAddress, - data: - safeL2SingletonDeployment && - safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), - }) - .build(), - }) - .build() - const safeInfo = extendedSafeInfoBuilder() - .with({ - implementationVersionState: ImplementationVersionState.UNKNOWN, - implementation: { - name: '1.3.0', - value: getSafeSingletonDeployment()?.defaultAddress ?? faker.finance.ethereumAddress(), - }, - }) - .build() - expect( - prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true, chainId: '10' }).build()), - ).resolves.toEqual(safeTx) - const multiSendSafeTx = safeTxBuilder() - .with({ - data: safeTxDataBuilder() - .with({ - nonce: 0, - to: getMultiSendDeployment()?.defaultAddress, - data: - safeToL2MigrationAddress && - safeL2SingletonDeployment && - Multi_send__factory.createInterface().encodeFunctionData('multiSend', [ - encodeMultiSendData([ - { - value: '0', - operation: 1, - to: safeToL2MigrationAddress, - data: safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), - }, - ]), - ]), - }) - .build(), - }) - .build() +describe('isMigrateL2SingletonCall', () => { + it('should return false for wrong data', () => { expect( - prependSafeToL2Migration(multiSendSafeTx, safeInfo, chainBuilder().with({ l2: true, chainId: '10' }).build()), - ).resolves.toEqual(multiSendSafeTx) - }) - - it('should modify single txs if applicable', async () => { - const safeTx = safeTxBuilder() - .with({ - data: safeTxDataBuilder() - .with({ - nonce: 0, - to: faker.finance.ethereumAddress(), - data: faker.string.hexadecimal({ length: 10 }), - value: '0', - }) - .build(), - }) - .build() - - const safeInfo = extendedSafeInfoBuilder() - .with({ - implementationVersionState: ImplementationVersionState.UNKNOWN, - implementation: { - name: '1.3.0', - value: getSafeSingletonDeployment()?.defaultAddress ?? faker.finance.ethereumAddress(), - }, - }) - .build() - - const modifiedTx = await prependSafeToL2Migration( - safeTx, - safeInfo, - chainBuilder().with({ l2: true, chainId: '10' }).build(), - ) - - expect(modifiedTx).not.toEqual(safeTx) - expect(modifiedTx?.data.to).toEqual(getMultiSendDeployment()?.defaultAddress) - const decodedMultiSend = decodeMultiSendData(modifiedTx!.data.data) - expect(decodedMultiSend).toHaveLength(2) - const safeL2SingletonDeployment = getSafeL2SingletonDeployment()?.defaultAddress - - expect(decodedMultiSend).toEqual([ - { - to: safeToL2MigrationAddress, - value: '0', - operation: 1, - data: - safeL2SingletonDeployment && - safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), - }, - { - to: checksumAddress(safeTx.data.to), - value: safeTx.data.value, - operation: safeTx.data.operation, - data: safeTx.data.data.toLowerCase(), - }, - ]) + isMigrateL2SingletonCall({ + hexData: faker.string.hexadecimal({ length: 64 }), + to: { value: safeMigrationAddress }, + } as TransactionData), + ).toBeFalsy() }) -}) -describe('extractMigrationL2MasterCopyAddress', () => { - it('should return undefined for non multisend safeTx', () => { + it('should return false for migrateL2 call to wrong contract', () => { expect( - extractMigrationL2MasterCopyAddress({ - hexData: - '0xf8dc5dd9000000000000000000000000000000000000000000000000000000000000000100000000000000000000000065f8236309e5a99ff0d129d04e486ebce20dc7b00000000000000000000000000000000000000000000000000000000000000001', + isMigrateL2SingletonCall({ + hexData: SafeMigrationInterface.encodeFunctionData('migrateL2Singleton'), + to: { value: faker.finance.ethereumAddress() }, } as TransactionData), - ).toBeUndefined() + ).toBeFalsy() }) - it('should return undefined for multisend without migration', () => { + it('should return true for migrateL2 call to correct contract', () => { expect( - extractMigrationL2MasterCopyAddress({ - hexData: multisendInterface.encodeFunctionData('multiSend', [ - encodeMultiSendData([ - { - to: faker.finance.ethereumAddress(), - data: faker.string.hexadecimal({ length: 64 }), - value: '0', - operation: 0, - }, - { - to: faker.finance.ethereumAddress(), - data: faker.string.hexadecimal({ length: 64 }), - value: '0', - operation: 0, - }, - ]), - ]), + isMigrateL2SingletonCall({ + hexData: SafeMigrationInterface.encodeFunctionData('migrateL2Singleton'), + to: { value: safeMigrationAddress }, } as TransactionData), - ).toBeUndefined() + ).toBeTruthy() }) - it('should return migration address for multisend with migration as first tx', () => { - const l2SingletonAddress = getSafeL2SingletonDeployment()?.defaultAddress! + it('should return false for null data', () => { expect( - extractMigrationL2MasterCopyAddress({ - hexData: multisendInterface.encodeFunctionData('multiSend', [ - encodeMultiSendData([ - { - to: safeToL2MigrationAddress!, - data: safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [l2SingletonAddress]), - value: '0', - operation: 1, - }, - { - to: faker.finance.ethereumAddress(), - data: faker.string.hexadecimal({ length: 64 }), - value: '0', - operation: 0, - }, - ]), - ]), + isMigrateL2SingletonCall({ + hexData: undefined, + to: { value: safeMigrationAddress }, + operation: 0, + trustedDelegateCallTarget: true, } as TransactionData), - ).toEqual(l2SingletonAddress) + ).toBeFalsy() }) +}) - describe('createUpdateMigration', () => { - const mockChain = { - chainId: '1', - l2: false, - recommendedMasterCopyVersion: '1.4.1', - } as unknown as ChainInfo - - const mockChainOld = { - chainId: '1', - l2: false, - recommendedMasterCopyVersion: '1.3.0', - } as unknown as ChainInfo - - it('should create a migration transaction for L1 chain', () => { - const result = createUpdateMigration(mockChain, '1.3.0') - - expect(result).toEqual({ - operation: OperationType.DelegateCall, - data: '0xed007fc6', - to: '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6', - value: '0', - }) +describe('createUpdateMigration', () => { + const mockChain = { + chainId: '1', + l2: false, + recommendedMasterCopyVersion: '1.4.1', + } as unknown as ChainInfo + + const mockChainOld = { + chainId: '1', + l2: false, + recommendedMasterCopyVersion: '1.3.0', + } as unknown as ChainInfo + + it('should create a migration transaction for L1 chain', () => { + const result = createUpdateMigration(mockChain, '1.3.0') + + expect(result).toEqual({ + operation: OperationType.DelegateCall, + data: '0xed007fc6', + to: '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6', + value: '0', }) + }) - it('should create a migration transaction for L2 chain', () => { - const l2Chain = { ...mockChain, chainId: '137', l2: true } - const result = createUpdateMigration(l2Chain, '1.3.0+L2') + it('should create a migration transaction for L2 chain', () => { + const l2Chain = { ...mockChain, chainId: '137', l2: true } + const result = createUpdateMigration(l2Chain, '1.3.0+L2') - expect(result).toEqual({ - operation: OperationType.DelegateCall, - data: '0x68cb3d94', - to: '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6', - value: '0', - }) + expect(result).toEqual({ + operation: OperationType.DelegateCall, + data: '0x68cb3d94', + to: '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6', + value: '0', }) + }) - it('should throw an error if deployment is not found', () => { - expect(() => createUpdateMigration(mockChainOld, '1.1.1')).toThrow('Migration deployment not found') - }) + it('should throw an error if deployment is not found', () => { + expect(() => createUpdateMigration(mockChainOld, '1.1.1')).toThrow('Migration deployment not found') + }) - it('should overwrite fallback handler if it is the default one', () => { - const result = createUpdateMigration(mockChain, '1.3.0', '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4') // 1.3.0 compatibility fallback handler + it('should overwrite fallback handler if it is the default one', () => { + const result = createUpdateMigration(mockChain, '1.3.0', '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4') // 1.3.0 compatibility fallback handler - expect(result).toEqual({ - operation: OperationType.DelegateCall, - data: '0xed007fc6', - to: '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6', - value: '0', - }) + expect(result).toEqual({ + operation: OperationType.DelegateCall, + data: '0xed007fc6', + to: '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6', + value: '0', }) + }) - it('should overwrite L2 fallback handler if it is the default one', () => { - const l2Chain = { ...mockChain, chainId: '137', l2: true } - const result = createUpdateMigration(l2Chain, '1.3.0+L2', '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4') // 1.3.0 compatibility fallback handler + it('should overwrite L2 fallback handler if it is the default one', () => { + const l2Chain = { ...mockChain, chainId: '137', l2: true } + const result = createUpdateMigration(l2Chain, '1.3.0+L2', '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4') // 1.3.0 compatibility fallback handler - expect(result).toEqual({ - operation: OperationType.DelegateCall, - data: '0x68cb3d94', - to: '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6', - value: '0', - }) + expect(result).toEqual({ + operation: OperationType.DelegateCall, + data: '0x68cb3d94', + to: '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6', + value: '0', }) + }) - it('should NOT overwrite a custom fallback handler', () => { - const result = createUpdateMigration(mockChain, '1.3.0', '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6') + it('should NOT overwrite a custom fallback handler', () => { + const result = createUpdateMigration(mockChain, '1.3.0', '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6') - expect(result).toEqual({ - operation: OperationType.DelegateCall, - data: '0xf6682ab0', - to: '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6', - value: '0', - }) + expect(result).toEqual({ + operation: OperationType.DelegateCall, + data: '0xf6682ab0', + to: '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6', + value: '0', }) }) }) diff --git a/apps/web/src/utils/__tests__/signers.test.ts b/apps/web/src/utils/__tests__/signers.test.ts index 5ef249b3ed..e22ca95d58 100644 --- a/apps/web/src/utils/__tests__/signers.test.ts +++ b/apps/web/src/utils/__tests__/signers.test.ts @@ -2,8 +2,8 @@ import { getAvailableSigners } from '../signers' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' import { safeInfoBuilder } from '@/tests/builders/safe' import { faker } from '@faker-js/faker' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import { checksumAddress } from '../addresses' +import type { SafeTransaction } from '@safe-global/types-kit' +import { checksumAddress } from '@safe-global/utils/utils/addresses' describe('getAvailableSigners', () => { const mockWallet = { diff --git a/apps/web/src/utils/__tests__/tokens.test.ts b/apps/web/src/utils/__tests__/tokens.test.ts index 0b064499c6..8bacf43d53 100644 --- a/apps/web/src/utils/__tests__/tokens.test.ts +++ b/apps/web/src/utils/__tests__/tokens.test.ts @@ -1,8 +1,7 @@ import * as web3 from '@/hooks/wallets/web3' -import { ERC20__factory } from '@/types/contracts' import { getERC20TokenInfoOnChain } from '@/utils/tokens' import { faker } from '@faker-js/faker' -import { keccak256, toUtf8Bytes } from 'ethers' +import { mockWeb3Provider } from '@/tests/test-utils' describe('tokens', () => { describe('getERC20TokenInfoOnChain', () => { @@ -19,56 +18,78 @@ describe('tokens', () => { }) it('should return symbol and decimals for a token', async () => { - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (tx: { data: string; to: string }) => { - { - const decimalsSigHash = keccak256(toUtf8Bytes('decimals()')).slice(0, 10) - const symbolSigHash = keccak256(toUtf8Bytes('symbol()')).slice(0, 10) + mockWeb3Provider([ + { + signature: 'decimals()', + returnType: 'uint256', + returnValue: '18', + }, + { + signature: 'symbol()', + returnType: 'string', + returnValue: 'UNI', + }, + ]) - if (tx.data.startsWith(decimalsSigHash)) { - return ERC20__factory.createInterface().encodeFunctionResult('decimals', [18]) - } - if (tx.data.startsWith(symbolSigHash)) { - return ERC20__factory.createInterface().encodeFunctionResult('symbol', ['UNI']) - } - } - }, - _isProvider: true, - resolveName: (name: string) => name, - }) as any, - ) - - const result = await getERC20TokenInfoOnChain('0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984') + const result = (await getERC20TokenInfoOnChain('0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'))?.[0] expect(result?.symbol).toEqual('UNI') expect(result?.decimals).toEqual(18) }) - it('should decode bytes32 symbol', async () => { - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (tx: { data: string; to: string }) => { - { - const decimalsSigHash = keccak256(toUtf8Bytes('decimals()')).slice(0, 10) - const symbolSigHash = keccak256(toUtf8Bytes('symbol()')).slice(0, 10) + it('should return symbol and decimals for multiple tokens', async () => { + const token1Address = faker.finance.ethereumAddress() + const token2Address = faker.finance.ethereumAddress() + mockWeb3Provider([ + { + signature: 'decimals()', + returnType: 'uint256', + returnValue: '18', + to: token1Address, + }, + { + signature: 'symbol()', + returnType: 'string', + returnValue: 'UNI', + to: token1Address, + }, + { + signature: 'decimals()', + returnType: 'uint256', + returnValue: '6', + to: token2Address, + }, + { + signature: 'symbol()', + returnType: 'string', + returnValue: 'USDC', + to: token2Address, + }, + ]) + + const result = await getERC20TokenInfoOnChain([token1Address, token2Address]) - if (tx.data.startsWith(decimalsSigHash)) { - return ERC20__factory.createInterface().encodeFunctionResult('decimals', [18]) - } - if (tx.data.startsWith(symbolSigHash)) { - return Promise.reject({ value: '0x4d4b520000000000000000000000000000000000000000000000000000000000' }) - } - } - }, - _isProvider: true, - resolveName: (name: string) => name, - }) as any, - ) + expect(result).toHaveLength(2) + expect(result?.[0].symbol).toEqual('UNI') + expect(result?.[0].decimals).toEqual(18) + expect(result?.[1].symbol).toEqual('USDC') + expect(result?.[1].decimals).toEqual(6) + }) + it('should decode bytes32 symbol', async () => { + mockWeb3Provider([ + { + signature: 'decimals()', + returnType: 'uint256', + returnValue: '18', + }, + { + signature: 'symbol()', + returnType: 'bytes32', + returnValue: '0x4d4b520000000000000000000000000000000000000000000000000000000000', + }, + ]) - const result = await getERC20TokenInfoOnChain('0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984') + const result = (await getERC20TokenInfoOnChain('0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'))?.[0] expect(result?.symbol).toEqual('MKR') expect(result?.decimals).toEqual(18) diff --git a/apps/web/src/utils/__tests__/transaction-guards.test.ts b/apps/web/src/utils/__tests__/transaction-guards.test.ts index 3443e57019..766e0c31f5 100644 --- a/apps/web/src/utils/__tests__/transaction-guards.test.ts +++ b/apps/web/src/utils/__tests__/transaction-guards.test.ts @@ -3,12 +3,15 @@ import { isExecTxInfo, isOnChainConfirmationTxData, isOnChainConfirmationTxInfo, + isOnChainSignMessageTxData, isSafeUpdateTxData, } from '../transaction-guards' import { faker } from '@faker-js/faker' -import { Safe__factory } from '@/types/contracts' +import { Safe__factory, Sign_message_lib__factory } from '@safe-global/utils/types/contracts' import { TransactionInfoType, TransactionTokenType, TransferDirection } from '@safe-global/safe-gateway-typescript-sdk' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { txDataBuilder } from '@/tests/builders/safeTx' +import { getSignMessageLibDeployment } from '@safe-global/safe-deployments' describe('transaction-guards', () => { describe('isOnChainConfirmationTxData', () => { @@ -278,4 +281,68 @@ describe('transaction-guards', () => { expect(isSafeUpdateTxData(mockTxData)).toBeFalsy() }) }) + + describe('isOnChainSignMessageTxData', () => { + it('should return false for undefined', () => { + expect(isOnChainSignMessageTxData(undefined, '1')).toBeFalsy() + }) + + it('should return false for arbitrary txData', () => { + expect(isOnChainSignMessageTxData(txDataBuilder().build(), '1')).toBeFalsy() + }) + + it('should return true for signMessage calls to the SignMessageLib', () => { + const signMessageInterface = Sign_message_lib__factory.createInterface() + const signMessageLibAddress = getSignMessageLibDeployment({ version: '1.3.0' })?.defaultAddress! + + const mockTxData = txDataBuilder() + .with({ + hexData: signMessageInterface.encodeFunctionData('signMessage', [faker.string.hexadecimal({ length: 64 })]), + to: { value: signMessageLibAddress }, + addressInfoIndex: {}, + value: '0', + operation: 1, + trustedDelegateCallTarget: true, + }) + .build() + + expect(isOnChainSignMessageTxData(mockTxData, '1')).toBeTruthy() + }) + + it('should return false for signMessage calls to a random address', () => { + const signMessageInterface = Sign_message_lib__factory.createInterface() + const randomAddress = faker.finance.ethereumAddress() + + const mockTxData = txDataBuilder() + .with({ + hexData: signMessageInterface.encodeFunctionData('signMessage', [faker.string.hexadecimal({ length: 64 })]), + to: { value: randomAddress }, + addressInfoIndex: {}, + value: '0', + operation: 1, + trustedDelegateCallTarget: true, + }) + .build() + + expect(isOnChainSignMessageTxData(mockTxData, '1')).toBeFalsy() + }) + + it('should return false for signMessage calls that are not delegate calls', () => { + const signMessageInterface = Sign_message_lib__factory.createInterface() + const signMessageLibAddress = getSignMessageLibDeployment({ version: '1.3.0' })?.defaultAddress! + + const mockTxData = txDataBuilder() + .with({ + hexData: signMessageInterface.encodeFunctionData('signMessage', [faker.string.hexadecimal({ length: 64 })]), + to: { value: signMessageLibAddress }, + addressInfoIndex: {}, + value: '0', + operation: 0, // Not a delegate call + trustedDelegateCallTarget: true, + }) + .build() + + expect(isOnChainSignMessageTxData(mockTxData, '1')).toBeFalsy() + }) + }) }) diff --git a/apps/web/src/utils/__tests__/tx-history-filter.test.ts b/apps/web/src/utils/__tests__/tx-history-filter.test.ts index a2efaafd7a..0caa161f5e 100644 --- a/apps/web/src/utils/__tests__/tx-history-filter.test.ts +++ b/apps/web/src/utils/__tests__/tx-history-filter.test.ts @@ -214,7 +214,7 @@ describe('tx-history-filter', () => { it('should return incoming filters', () => { const result = txFilter.parseFormData({ execution_date__gte: new Date('1970-01-01'), - value: '123', + value: '123000000000000000000', type: 'Incoming' as TxFilterType, } as TxFilterFormState) @@ -242,7 +242,7 @@ describe('tx-history-filter', () => { filter: { to: '0x1234567890123456789012345678901234567890', execution_date__gte: '1969-12-31T23:00:00.000Z', - value: '123000000000000000000', + value: '123', nonce: '123', }, }) @@ -293,7 +293,7 @@ describe('tx-history-filter', () => { type: 'Outgoing' as TxFilterType, filter: { execution_date__gte: '1970-01-01T00:00:00.000Z', - value: '123000000000000000000', + value: '123', nonce: '123', }, }) diff --git a/apps/web/src/utils/chains.ts b/apps/web/src/utils/chains.ts index 8d7534b7c1..95a9765351 100644 --- a/apps/web/src/utils/chains.ts +++ b/apps/web/src/utils/chains.ts @@ -1,47 +1,7 @@ import { AppRoutes } from '@/config/routes' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { getExplorerLink } from './gateway' -import { type SafeVersion } from '@safe-global/safe-core-sdk-types' -import { getSafeSingletonDeployment } from '@safe-global/safe-deployments' -import semverSatisfies from 'semver/functions/satisfies' -import { LATEST_SAFE_VERSION } from '@/config/constants' - -/** This version is used if a network does not have the LATEST_SAFE_VERSION deployed yet */ -const FALLBACK_SAFE_VERSION = '1.3.0' as const - -export enum FEATURES { - ERC721 = 'ERC721', - SAFE_APPS = 'SAFE_APPS', - DOMAIN_LOOKUP = 'DOMAIN_LOOKUP', - SPENDING_LIMIT = 'SPENDING_LIMIT', - EIP1559 = 'EIP1559', - SAFE_TX_GAS_OPTIONAL = 'SAFE_TX_GAS_OPTIONAL', - TX_SIMULATION = 'TX_SIMULATION', - DEFAULT_TOKENLIST = 'DEFAULT_TOKENLIST', - RELAYING = 'RELAYING', - EIP1271 = 'EIP1271', - RISK_MITIGATION = 'RISK_MITIGATION', - PUSH_NOTIFICATIONS = 'PUSH_NOTIFICATIONS', - NATIVE_WALLETCONNECT = 'NATIVE_WALLETCONNECT', - RECOVERY = 'RECOVERY', - COUNTERFACTUAL = 'COUNTERFACTUAL', - DELETE_TX = 'DELETE_TX', - SPEED_UP_TX = 'SPEED_UP_TX', - SAP_BANNER = 'SAP_BANNER', - NATIVE_SWAPS = 'NATIVE_SWAPS', - NATIVE_SWAPS_USE_COW_STAGING_SERVER = 'NATIVE_SWAPS_USE_COW_STAGING_SERVER', - NATIVE_SWAPS_FEE_ENABLED = 'NATIVE_SWAPS_FEE_ENABLED', - ZODIAC_ROLES = 'ZODIAC_ROLES', - STAKING = 'STAKING', - STAKING_BANNER = 'STAKING_BANNER', - MULTI_CHAIN_SAFE_CREATION = 'MULTI_CHAIN_SAFE_CREATION', - MULTI_CHAIN_SAFE_ADD_NETWORK = 'MULTI_CHAIN_SAFE_ADD_NETWORK', - PROPOSERS = 'PROPOSERS', - TARGETED_SURVEY = 'TARGETED_SURVEY', - BRIDGE = 'BRIDGE', - RENEW_NOTIFICATIONS_TOKEN = 'RENEW_NOTIFICATIONS_TOKEN', - TX_NOTES = 'TX_NOTES', -} +import { getExplorerLink } from '@safe-global/utils/utils/gateway' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' export const FeatureRoutes = { [AppRoutes.apps.index]: FEATURES.SAFE_APPS, @@ -50,10 +10,7 @@ export const FeatureRoutes = { [AppRoutes.balances.nfts]: FEATURES.ERC721, [AppRoutes.settings.notifications]: FEATURES.PUSH_NOTIFICATIONS, [AppRoutes.bridge]: FEATURES.BRIDGE, -} - -export const hasFeature = (chain: ChainInfo, feature: FEATURES): boolean => { - return (chain.features as string[]).includes(feature) + [AppRoutes.earn]: FEATURES.EARN, } export const getBlockExplorerLink = ( @@ -70,20 +27,3 @@ export const isRouteEnabled = (route: string, chain?: ChainInfo) => { const featureRoute = FeatureRoutes[route] return !featureRoute || hasFeature(chain, featureRoute) } - -export const getLatestSafeVersion = (chain: ChainInfo | undefined, isUpgrade = false): SafeVersion => { - const latestSafeVersion = isUpgrade - ? chain?.recommendedMasterCopyVersion || LATEST_SAFE_VERSION // for upgrades, use the recommended version - : LATEST_SAFE_VERSION // for Safe creation, always use the latest version - - // Without version filter it will always return the LATEST_SAFE_VERSION constant to avoid automatically updating to the newest version if the deployments change - const latestDeploymentVersion = (getSafeSingletonDeployment({ network: chain?.chainId, released: true })?.version ?? - FALLBACK_SAFE_VERSION) as SafeVersion - - // The version needs to be smaller or equal to the - if (semverSatisfies(latestDeploymentVersion, `<=${latestSafeVersion}`)) { - return latestDeploymentVersion - } else { - return latestSafeVersion as SafeVersion - } -} diff --git a/apps/web/src/utils/featureToggled.tsx b/apps/web/src/utils/featureToggled.tsx index ef76ace617..29eb3420e8 100644 --- a/apps/web/src/utils/featureToggled.tsx +++ b/apps/web/src/utils/featureToggled.tsx @@ -1,9 +1,7 @@ import type { ComponentType } from 'react' -import type { FEATURES } from '@/utils/chains' import { useHasFeature } from '@/hooks/useChains' - -export { FEATURES } from '@/utils/chains' - +import type { FEATURES } from '@safe-global/utils/utils/chains' +export { FEATURES } from '@safe-global/utils/utils/chains' export const featureToggled =

>(Component: ComponentType

, feature: FEATURES) => { const ToggledComponent = (props: P) => { const hasFeature = useHasFeature(feature) diff --git a/apps/web/src/utils/gateway.ts b/apps/web/src/utils/gateway.ts index 12c657f016..94f5d8e0ae 100644 --- a/apps/web/src/utils/gateway.ts +++ b/apps/web/src/utils/gateway.ts @@ -1,35 +1,8 @@ import type { JsonRpcSigner } from 'ethers' -import { type ChainInfo, deleteTransaction } from '@safe-global/safe-gateway-typescript-sdk' -import { signTypedData } from './web3' +import { deleteTransaction } from '@safe-global/safe-gateway-typescript-sdk' +import { signTypedData } from '@safe-global/utils/utils/web3' -export const _replaceTemplate = (uri: string, data: Record): string => { - // Template syntax returned from gateway is {{this}} - const TEMPLATE_REGEX = /\{\{([^}]+)\}\}/g - - return uri.replace(TEMPLATE_REGEX, (_, key: string) => data[key]) -} - -export const getHashedExplorerUrl = ( - hash: string, - blockExplorerUriTemplate: ChainInfo['blockExplorerUriTemplate'], -): string => { - const isTx = hash.length > 42 - const param = isTx ? 'txHash' : 'address' - - return _replaceTemplate(blockExplorerUriTemplate[param], { [param]: hash }) -} - -export const getExplorerLink = ( - hash: string, - blockExplorerUriTemplate: ChainInfo['blockExplorerUriTemplate'], -): { href: string; title: string } => { - const href = getHashedExplorerUrl(hash, blockExplorerUriTemplate) - const title = `View on ${new URL(href).hostname}` - - return { href, title } -} - -const signTxServiceMessage = async ( +export const signTxServiceMessage = async ( chainId: string, safeAddress: string, safeTxHash: string, @@ -45,13 +18,14 @@ const signTxServiceMessage = async ( domain: { name: 'Safe Transaction Service', version: '1.0', - chainId, + chainId: Number(chainId), verifyingContract: safeAddress, }, message: { safeTxHash, totp: Math.floor(Date.now() / 3600e3), }, + primaryType: 'DeleteRequest', }) } diff --git a/apps/web/src/utils/helpers.ts b/apps/web/src/utils/helpers.ts index eac09b1a51..9e37b9c60a 100644 --- a/apps/web/src/utils/helpers.ts +++ b/apps/web/src/utils/helpers.ts @@ -1,17 +1,10 @@ // `assert` does not work with arrow functions import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction } from '@safe-global/types-kit' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import type { OnboardAPI } from '@web3-onboard/core' import type { Eip1193Provider } from 'ethers' - -export function invariant(condition: T, error: string): asserts condition { - if (condition) { - return - } - - throw new Error(error) -} +import { invariant } from '@safe-global/utils/utils/helpers' export function assertTx(safeTx: SafeTransaction | undefined): asserts safeTx { return invariant(safeTx, 'Transaction not provided') diff --git a/apps/web/src/utils/nested-safe-wallet.ts b/apps/web/src/utils/nested-safe-wallet.ts index 1ac4c4e10f..48e21096b6 100644 --- a/apps/web/src/utils/nested-safe-wallet.ts +++ b/apps/web/src/utils/nested-safe-wallet.ts @@ -8,9 +8,9 @@ import { isSmartContractWallet } from '@/utils/wallets' import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' import { initSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' import { logError } from '@/services/exceptions' -import ErrorCodes from '@/services/exceptions/ErrorCodes' +import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes' import { tryOffChainTxSigning } from '@/services/tx/tx-sender/sdk' -import type { TransactionResult } from '@safe-global/safe-core-sdk-types' +import type { TransactionResult } from '@safe-global/types-kit' export type NestedWallet = { address: string diff --git a/apps/web/src/utils/nested-safes.ts b/apps/web/src/utils/nested-safes.ts new file mode 100644 index 0000000000..d2fc75fdc6 --- /dev/null +++ b/apps/web/src/utils/nested-safes.ts @@ -0,0 +1,67 @@ +import type { DataDecoded, InternalTransaction, TransactionData } from '@safe-global/safe-gateway-typescript-sdk' + +export function isNestedSafeCreation(txData: TransactionData): boolean { + try { + _getFactoryAddressAndSetupData(txData) + return true + } catch { + return false + } +} + +export function _getFactoryAddressAndSetupData(txData: TransactionData): { + factoryAddress: string + singleton: string + initializer: string + saltNonce: string +} { + let factoryAddress: string | undefined + let dataDecoded: DataDecoded | undefined + + if (isCreateProxyWithNonce(txData)) { + factoryAddress = txData.to.value + dataDecoded = txData.dataDecoded + } else if (isMultiSend(txData)) { + const batchTxData = txData.dataDecoded?.parameters + ?.find((parameter) => parameter?.name === 'transactions') + ?.valueDecoded?.find(isCreateProxyWithNonce) + + factoryAddress = batchTxData?.to + dataDecoded = batchTxData?.dataDecoded + } else { + throw new Error('Invalid method') + } + + if (!factoryAddress) { + throw new Error('Missing factory address') + } + + if (!Array.isArray(dataDecoded?.parameters)) { + throw new Error('Invalid parameters') + } + + const [singleton, initializer, saltNonce] = dataDecoded.parameters + + if ( + typeof singleton.value !== 'string' || + typeof initializer.value !== 'string' || + typeof saltNonce.value !== 'string' + ) { + throw new Error('Invalid parameter values') + } + + return { + factoryAddress, + singleton: singleton.value, + initializer: initializer.value, + saltNonce: saltNonce.value, + } +} + +function isCreateProxyWithNonce(txData: TransactionData | InternalTransaction) { + return txData.dataDecoded?.method === 'createProxyWithNonce' +} + +function isMultiSend(txData: TransactionData) { + return txData.dataDecoded?.method === 'multiSend' +} diff --git a/apps/web/src/utils/safe-hashes.ts b/apps/web/src/utils/safe-hashes.ts index 7f87932069..4ec008fdb9 100644 --- a/apps/web/src/utils/safe-hashes.ts +++ b/apps/web/src/utils/safe-hashes.ts @@ -1,9 +1,9 @@ import { TypedDataEncoder } from 'ethers' import semverSatisfies from 'semver/functions/satisfies' import { getEip712MessageTypes, getEip712TxTypes } from '@safe-global/protocol-kit/dist/src/utils' -import type { SafeMessage, SafeTransactionData, SafeVersion } from '@safe-global/safe-core-sdk-types' - -import { generateSafeMessageMessage } from './safe-messages' +import type { SafeTransactionData, SafeVersion } from '@safe-global/types-kit' +import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages' +import { generateSafeMessageMessage } from '@safe-global/utils/utils/safe-messages' const NEW_DOMAIN_TYPE_HASH_VERSION = '>=1.3.0' const NEW_SAFE_TX_TYPE_HASH_VERSION = '>=1.0.0' @@ -51,7 +51,7 @@ export function getSafeMessageMessageHash({ message, safeVersion, }: { - message: SafeMessage['data'] + message: MessageItem['message'] safeVersion: SafeVersion }): string { const SafeMessage = getEip712MessageTypes(safeVersion).SafeMessage diff --git a/apps/web/src/utils/safe-message-guards.ts b/apps/web/src/utils/safe-message-guards.ts index 0bbf6d2ed5..d426f05112 100644 --- a/apps/web/src/utils/safe-message-guards.ts +++ b/apps/web/src/utils/safe-message-guards.ts @@ -1,10 +1,10 @@ -import { SafeMessageListItemType } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeMessageListItem, SafeMessage, SafeMessageDateLabel } from '@safe-global/safe-gateway-typescript-sdk' +import type { MessageItem, DateLabel } from '@safe-global/store/gateway/AUTO_GENERATED/messages' +import type { SafeMessageListItem } from '@safe-global/store/gateway/types' -export const isSafeMessageListDateLabel = (item: SafeMessageListItem): item is SafeMessageDateLabel => { - return item.type === SafeMessageListItemType.DATE_LABEL +export const isSafeMessageListDateLabel = (item: SafeMessageListItem): item is DateLabel => { + return item.type === 'DATE_LABEL' } -export const isSafeMessageListItem = (item: SafeMessageListItem): item is SafeMessage => { - return item.type === SafeMessageListItemType.MESSAGE +export const isSafeMessageListItem = (item: SafeMessageListItem): item is MessageItem => { + return item.type === 'MESSAGE' } diff --git a/apps/web/src/utils/safe-migrations.ts b/apps/web/src/utils/safe-migrations.ts index f9c1b6c7d6..c796f3db40 100644 --- a/apps/web/src/utils/safe-migrations.ts +++ b/apps/web/src/utils/safe-migrations.ts @@ -1,105 +1,11 @@ -import { Safe_to_l2_migration__factory, Safe_migration__factory } from '@/types/contracts' -import { getCompatibilityFallbackHandlerDeployments } from '@safe-global/safe-deployments' -import { type ExtendedSafeInfo } from '@/store/safeInfoSlice' -import { getSafeContractDeployment, hasMatchingDeployment } from '@/services/contracts/deployments' -import { sameAddress } from './addresses' -import { getSafeToL2MigrationDeployment, getSafeMigrationDeployment } from '@safe-global/safe-deployments' -import { - type MetaTransactionData, - OperationType, - type SafeTransaction, - type SafeVersion, -} from '@safe-global/safe-core-sdk-types' -import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { isValidMasterCopy } from '@/services/contracts/safeContracts' -import { isMultiSendCalldata } from './transaction-calldata' -import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' -import { __unsafe_createMultiSendTx } from '@/services/tx/tx-sender' -import { LATEST_SAFE_VERSION } from '@/config/constants' +import { Safe_migration__factory } from '@safe-global/utils/types/contracts' +import { getCompatibilityFallbackHandlerDeployments, getSafeMigrationDeployment } from '@safe-global/safe-deployments' +import { hasMatchingDeployment } from '@safe-global/utils/services/contracts/deployments' +import { type MetaTransactionData, OperationType, type SafeVersion } from '@safe-global/types-kit' +import type { ChainInfo, TransactionData } from '@safe-global/safe-gateway-typescript-sdk' -/** - * - * If the Safe is using a invalid masterCopy this function will modify the passed in `safeTx` by making it a MultiSend that migrates the Safe to L2 as the first action. - * - * This only happens under the conditions that - * - The Safe's nonce is 0 - * - The SafeTx's nonce is 0 - * - The Safe is using an invalid masterCopy - * - The SafeTx is not already including a Migration - * - * @param safeTx original SafeTx - * @param safe - * @param chain - * @returns - */ -export const prependSafeToL2Migration = ( - safeTx: SafeTransaction | undefined, - safe: ExtendedSafeInfo, - chain: ChainInfo | undefined, -): Promise => { - if (!chain) { - throw new Error('No Network information available') - } - - const safeL2Deployment = getSafeContractDeployment(chain, safe.version) - const safeL2DeploymentAddress = safeL2Deployment?.networkAddresses[chain.chainId] - const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment({ network: chain.chainId }) - const safeToL2MigrationAddress = safeToL2MigrationDeployment?.networkAddresses[chain.chainId] - - if ( - !safeTx || - safeTx.signatures.size > 0 || - !chain.l2 || - safeTx.data.nonce > 0 || - isValidMasterCopy(safe.implementationVersionState) || - !safeToL2MigrationAddress || - !safeL2DeploymentAddress - ) { - // We do not migrate on L1s - // We cannot migrate if the nonce is > 0 - // We do not modify already signed txs - // We do not modify supported masterCopies - // We cannot migrate if no migration contract or L2 contract exists - return Promise.resolve(safeTx) - } - - const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() - - if (sameAddress(safe.implementation.value, safeL2DeploymentAddress)) { - // Safe already has the correct L2 masterCopy - // This should in theory never happen if the implementationState is valid - return Promise.resolve(safeTx) - } - - // If the Safe is a L1 masterCopy on a L2 network and still has nonce 0, we prepend a call to the migration contract to the safeTx. - const txData = safeTx.data.data - - let internalTxs: MetaTransactionData[] - if (isMultiSendCalldata(txData)) { - // Check if the first tx is already a call to the migration contract - internalTxs = decodeMultiSendData(txData) - } else { - internalTxs = [{ to: safeTx.data.to, operation: safeTx.data.operation, value: safeTx.data.value, data: txData }] - } - - if (sameAddress(internalTxs[0]?.to, safeToL2MigrationAddress)) { - // We already migrate. Nothing to do. - return Promise.resolve(safeTx) - } - - // Prepend the migration tx - const newTxs: MetaTransactionData[] = [ - { - operation: OperationType.DelegateCall, // DELEGATE CALL REQUIRED - data: safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2DeploymentAddress]), - to: safeToL2MigrationAddress, - value: '0', - }, - ...internalTxs, - ] - - return __unsafe_createMultiSendTx(newTxs) -} +import { LATEST_SAFE_VERSION, SAFE_TO_L2_MIGRATION_VERSION } from '@safe-global/utils/config/constants' +import { sameAddress } from '@safe-global/utils/utils/addresses' export const createUpdateMigration = ( chain: ChainInfo, @@ -144,3 +50,40 @@ export const createUpdateMigration = ( return tx } + +export const createMigrateToL2 = (chain: ChainInfo) => { + const deployment = getSafeMigrationDeployment({ + version: SAFE_TO_L2_MIGRATION_VERSION, // This is the only version that has this contract deployed + released: true, + network: chain.chainId, + }) + + if (!deployment) { + throw new Error('Migration deployment not found') + } + + const interfce = Safe_migration__factory.createInterface() + + const tx: MetaTransactionData = { + operation: OperationType.DelegateCall, // delegate call required + data: interfce.encodeFunctionData('migrateL2Singleton'), + to: deployment.defaultAddress, + value: '0', + } + + return tx +} + +export const isMigrateL2SingletonCall = (txData: TransactionData): boolean => { + // We always use the 1.4.1 version for this contract as it is only deployed for 1.4.1 Safes + const safeMigrationDeployment = getSafeMigrationDeployment({ version: SAFE_TO_L2_MIGRATION_VERSION }) + const safeMigrationAddress = safeMigrationDeployment?.defaultAddress + const safeMigrationInterface = Safe_migration__factory.createInterface() + + return ( + txData.hexData !== undefined && + txData.hexData !== null && + txData.hexData.startsWith(safeMigrationInterface.getFunction('migrateL2Singleton').selector) && + sameAddress(txData.to.value, safeMigrationAddress) + ) +} diff --git a/apps/web/src/utils/safe-versions.ts b/apps/web/src/utils/safe-versions.ts index 536f3e8208..4d785ef34b 100644 --- a/apps/web/src/utils/safe-versions.ts +++ b/apps/web/src/utils/safe-versions.ts @@ -2,10 +2,10 @@ import { hasSafeFeature as sdkHasSafeFeature, type SAFE_FEATURES, } from '@safe-global/protocol-kit/dist/src/utils/safeVersions' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' // Note: backend returns `SafeInfo['version']` as `null` for unsupported contracts -export const hasSafeFeature = (feature: SAFE_FEATURES, version: SafeInfo['version']): boolean => { +export const hasSafeFeature = (feature: SAFE_FEATURES, version: SafeState['version']): boolean => { if (!version) { return false } diff --git a/apps/web/src/utils/signers.ts b/apps/web/src/utils/signers.ts index a8babd9767..6bfc5ecf78 100644 --- a/apps/web/src/utils/signers.ts +++ b/apps/web/src/utils/signers.ts @@ -1,12 +1,12 @@ import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { checksumAddress } from './addresses' +import type { SafeTransaction } from '@safe-global/types-kit' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { checksumAddress } from '@safe-global/utils/utils/addresses' export const getAvailableSigners = ( wallet: ConnectedWallet | null | undefined, nestedSafeOwners: string[] | null, - safe: SafeInfo, + safe: SafeState, tx: SafeTransaction | undefined, ) => { if (!wallet || !nestedSafeOwners || !tx) { diff --git a/apps/web/src/utils/tokens.ts b/apps/web/src/utils/tokens.ts index 02ce17a72c..2af879bb56 100644 --- a/apps/web/src/utils/tokens.ts +++ b/apps/web/src/utils/tokens.ts @@ -1,40 +1,63 @@ import { getWeb3ReadOnly } from '@/hooks/wallets/web3' -import { ERC20__factory, ERC721__factory } from '@/types/contracts' +import { ERC20__factory, ERC721__factory } from '@safe-global/utils/types/contracts' import { parseBytes32String } from '@ethersproject/strings' -import { type TokenInfo, TokenType } from '@safe-global/safe-gateway-typescript-sdk' - -export const UNLIMITED_APPROVAL_AMOUNT = 2n ** 256n - 1n -export const UNLIMITED_PERMIT2_AMOUNT = 2n ** 160n - 1n - -// As per https://eips.ethereum.org/EIPS/eip-721#specification -export const ERC721_IDENTIFIER = '0x80ac58cd' +import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' +import { ERC721_IDENTIFIER } from '@safe-global/utils/utils/tokens' +import { type Erc20Token } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { multicall } from '../../../../packages/utils/src/utils/multicall' +import { type BytesLike } from 'ethers' /** * Fetches ERC20 token symbol and decimals from on-chain. * @param address address of erc20 token */ export const getERC20TokenInfoOnChain = async ( - address: string, -): Promise | undefined> => { + address: string | string[], +): Promise[] | undefined> => { const web3 = getWeb3ReadOnly() if (!web3) return - const erc20 = ERC20__factory.connect(address, web3) + let tokenAddresses = Array.isArray(address) ? address : [address] + + const erc20_interface = ERC20__factory.createInterface() - const symbol = await erc20 - .symbol() - .then((symbol) => symbol) - .catch((error) => parseBytes32String(error.value)) // Some older contracts use bytes32 instead of string - .finally(() => '') + const calls = tokenAddresses.flatMap((address) => [ + { + to: address, + data: erc20_interface.encodeFunctionData('symbol'), + }, + { + to: address, + data: erc20_interface.encodeFunctionData('decimals'), + }, + ]) - const decimals = await erc20.decimals() + const results = await multicall(web3, calls) - return { - address, - symbol, - decimals: Number(decimals), - type: TokenType.ERC20, + const tokenInfos: Omit[] = [] + for (let i = 0; i < results.length; i += 2) { + const address = tokenAddresses[i / 2] + if (!address) break + let symbol: string + try { + symbol = erc20_interface.decodeFunctionResult('symbol', results[i].returnData)[0] + } catch (error) { + // Some older contracts use bytes32 instead of string + symbol = parseBytes32String( + error && typeof error === 'object' && 'value' in error ? (error.value as BytesLike) : '', + ) + } + const decimals = Number(erc20_interface.decodeFunctionResult('decimals', results[i + 1].returnData)[0]) + + tokenInfos.push({ + address, + symbol, + decimals, + type: TokenType.ERC20, + }) } + + return tokenInfos } export const getErc721Symbol = async (address: string) => { diff --git a/apps/web/src/utils/transaction-calldata.ts b/apps/web/src/utils/transaction-calldata.ts index 2d91e6250d..eb6630e4aa 100644 --- a/apps/web/src/utils/transaction-calldata.ts +++ b/apps/web/src/utils/transaction-calldata.ts @@ -2,10 +2,10 @@ import { id } from 'ethers' import type { FunctionFragment } from 'ethers' import type { BaseTransaction } from '@safe-global/safe-apps-sdk' -import { Multi_send__factory } from '@/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' -import { ERC20__factory } from '@/types/contracts/factories/@openzeppelin/contracts/build/contracts/ERC20__factory' -import { ERC721__factory } from '@/types/contracts/factories/@openzeppelin/contracts/build/contracts/ERC721__factory' -import { Safe__factory } from '@/types/contracts' +import { Multi_send__factory } from '@safe-global/utils/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' +import { ERC20__factory } from '@safe-global/utils/types/contracts/factories/@openzeppelin/contracts/build/contracts/ERC20__factory' +import { ERC721__factory } from '@safe-global/utils/types/contracts/factories/@openzeppelin/contracts/build/contracts/ERC721__factory' +import { Safe__factory } from '@safe-global/utils/types/contracts' import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' export const isCalldata = (data: string, fragment: FunctionFragment): boolean => { diff --git a/apps/web/src/utils/transaction-guards.ts b/apps/web/src/utils/transaction-guards.ts index 85941900d9..63e96efd13 100644 --- a/apps/web/src/utils/transaction-guards.ts +++ b/apps/web/src/utils/transaction-guards.ts @@ -1,5 +1,4 @@ import type { - AddressEx, BaselineConfirmationView, Cancellation, ConflictHeader, @@ -42,6 +41,7 @@ import type { StakingTxInfo, TransactionData, } from '@safe-global/safe-gateway-typescript-sdk' +import { type AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { ConfirmationViewTypes, ConflictType, @@ -54,7 +54,7 @@ import { TransferDirection, } from '@safe-global/safe-gateway-typescript-sdk' import { getDeployedSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts' -import { sameAddress } from '@/utils/addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import type { NamedAddress } from '@/components/new-safe/create/types' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' import { ethers } from 'ethers' @@ -62,14 +62,25 @@ import { getSafeToL2MigrationDeployment, getSafeMigrationDeployment, getMultiSendDeployments, + getSignMessageLibDeployments, } from '@safe-global/safe-deployments' -import { Safe__factory, Safe_to_l2_migration__factory } from '@/types/contracts' -import { hasMatchingDeployment } from '@/services/contracts/deployments' +import { + Safe__factory, + Safe_to_l2_migration__factory, + Sign_message_lib__factory, +} from '@safe-global/utils/types/contracts' +import { hasMatchingDeployment } from '@safe-global/utils/services/contracts/deployments' import { isMultiSendCalldata } from './transaction-calldata' import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' -import { OperationType } from '@safe-global/safe-core-sdk-types' -import { LATEST_SAFE_VERSION } from '@/config/constants' -import { extractMigrationL2MasterCopyAddress } from '@/features/multichain/utils/extract-migration-data' +import { OperationType } from '@safe-global/types-kit' +import { LATEST_SAFE_VERSION } from '@safe-global/utils/config/constants' +import type { + BridgeAndSwapTransactionInfo, + SwapTransactionInfo, + TransactionDetails, + VaultDepositTransactionInfo, + VaultRedeemTransactionInfo, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' export const isTxQueued = (value: TransactionStatus): boolean => { return [TransactionStatus.AWAITING_CONFIRMATIONS, TransactionStatus.AWAITING_EXECUTION].includes(value) @@ -78,11 +89,11 @@ export const isTxQueued = (value: TransactionStatus): boolean => { export const isAwaitingExecution = (txStatus: TransactionStatus): boolean => TransactionStatus.AWAITING_EXECUTION === txStatus -const isAddressEx = (owners: AddressEx[] | NamedAddress[]): owners is AddressEx[] => { - return (owners as AddressEx[]).every((owner) => owner.value !== undefined) +const isAddressEx = (owners: AddressInfo[] | NamedAddress[]): owners is AddressInfo[] => { + return (owners as AddressInfo[]).every((owner) => owner.value !== undefined) } -export const isOwner = (safeOwners: AddressEx[] | NamedAddress[] = [], walletAddress?: string) => { +export const isOwner = (safeOwners: AddressInfo[] | NamedAddress[] = [], walletAddress?: string) => { if (isAddressEx(safeOwners)) { return safeOwners.some((owner) => sameAddress(owner.value, walletAddress)) } @@ -188,6 +199,14 @@ export const isSwapOrderTxInfo = (value: TransactionInfo): value is SwapOrder => return value.type === TransactionInfoType.SWAP_ORDER } +export const isBridgeOrderTxInfo = (value: any): value is BridgeAndSwapTransactionInfo => { + return (value.type as string) === 'SwapAndBridge' +} + +export const isLifiSwapTxInfo = (value: any): value is SwapTransactionInfo => { + return (value.type as string) === 'Swap' +} + export const isTwapOrderTxInfo = (value: TransactionInfo): value is TwapOrder => { return value.type === TransactionInfoType.TWAP_ORDER } @@ -353,7 +372,11 @@ export const isConfirmableBy = (txSummary: TransactionSummary, walletAddress: st ) } -export const isExecutable = (txSummary: TransactionSummary, walletAddress: string, safe: SafeInfo): boolean => { +export const isExecutable = ( + txSummary: TransactionSummary, + walletAddress: string, + safe: Pick, +): boolean => { if ( !txSummary.executionInfo || !isMultisigExecutionInfo(txSummary.executionInfo) || @@ -420,6 +443,7 @@ export const isERC721Transfer = (value: TransferInfo): value is Erc721Transfer = } const safeInterface = Safe__factory.createInterface() +const signMessageInterface = Sign_message_lib__factory.createInterface() /** * True if the tx calls `approveHash` */ @@ -435,6 +459,16 @@ export const isOnChainConfirmationTxInfo = (info: TransactionInfo): info is Cust return false } +export const isOnChainSignMessageTxData = (data: TransactionData | undefined, chainId: string): boolean => { + const signMessageSelector = signMessageInterface.getFunction('signMessage').selector + const toAddress = data?.to.value + const isDelegateCall = data?.operation === Operation.DELEGATE + const isSignMessageLib = + toAddress !== undefined && + hasMatchingDeployment(getSignMessageLibDeployments, toAddress, chainId, ['1.3.0', '1.4.1']) + return Boolean(data && data.hexData?.startsWith(signMessageSelector) && isSignMessageLib && isDelegateCall) +} + /** * True if the tx calls `execTransaction` */ @@ -477,7 +511,25 @@ export const isSafeUpdateTxData = (data?: TransactionData): boolean => { ) } -export const isSafeToL2MigrationTxData = (data?: TransactionData): boolean => { +export const isSafeMigrationTxData = (data?: TransactionData): boolean => { if (!data) return false - return !!extractMigrationL2MasterCopyAddress(data) + return isMigrateToL2CallData({ + data: data.hexData, + to: data.to.value, + operation: data.operation as number, + }) +} + +export const isVaultDepositTxInfo = (value: TransactionDetails['txInfo']): value is VaultDepositTransactionInfo => { + return value.type === 'VaultDeposit' +} + +export const isVaultRedeemTxInfo = (value: TransactionDetails['txInfo']): value is VaultRedeemTransactionInfo => { + return value.type === 'VaultRedeem' +} + +export const isAnyEarnTxInfo = ( + value: TransactionDetails['txInfo'], +): value is VaultDepositTransactionInfo | VaultRedeemTransactionInfo => { + return isVaultDepositTxInfo(value) || isVaultRedeemTxInfo(value) } diff --git a/apps/web/src/utils/transactions.ts b/apps/web/src/utils/transactions.ts index fda843b7d3..309e2fd50f 100644 --- a/apps/web/src/utils/transactions.ts +++ b/apps/web/src/utils/transactions.ts @@ -19,13 +19,11 @@ import { isTransferTxInfo, isTxQueued, } from './transaction-guards' -import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types/dist/src/types' -import { OperationType } from '@safe-global/safe-core-sdk-types/dist/src/types' import { getReadOnlyGnosisSafeContract } from '@/services/contracts/safeContracts' import extractTxInfo from '@/services/tx/extractTxInfo' import type { AdvancedParameters } from '@/components/tx/AdvancedParams' -import type { SafeTransaction, TransactionOptions } from '@safe-global/safe-core-sdk-types' -import { FEATURES, hasFeature } from '@/utils/chains' +import type { SafeTransaction, TransactionOptions, MetaTransactionData } from '@safe-global/types-kit' +import { OperationType } from '@safe-global/types-kit' import uniqBy from 'lodash/uniqBy' import { Errors, logError } from '@/services/exceptions' import { type BaseTransaction } from '@safe-global/safe-apps-sdk' @@ -33,6 +31,7 @@ import { isEmptyHexData } from '@/utils/hex' import { isMultiSendCalldata } from './transaction-calldata' import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' import { getOriginPath } from './url' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' export const makeTxFromDetails = (txDetails: TransactionDetails): Transaction => { const getMissingSigners = ({ @@ -91,6 +90,14 @@ export const makeTxFromDetails = (txDetails: TransactionDetails): Transaction => } } +export const getSafeTxHashFromTxId = (txId: string) => { + if (txId.startsWith('multisig_')) { + return txId.slice(-66) + } + + return +} + const getSignatures = (confirmations: Record) => { return Object.entries(confirmations) .filter(([, signature]) => Boolean(signature)) @@ -112,7 +119,7 @@ export const getMultiSendTxs = async ( .map((tx) => { if (!isMultisigDetailedExecutionInfo(tx.detailedExecutionInfo)) return - const args = extractTxInfo(tx, safeAddress) + const args = extractTxInfo(tx) const sigs = getSignatures(args.signatures) // @ts-ignore diff --git a/apps/web/src/utils/tx-history-filter.ts b/apps/web/src/utils/tx-history-filter.ts index 62788697d3..fbe9f3d3f9 100644 --- a/apps/web/src/utils/tx-history-filter.ts +++ b/apps/web/src/utils/tx-history-filter.ts @@ -11,7 +11,6 @@ import type { ParsedUrlQuery } from 'querystring' import { startOfDay, endOfDay } from 'date-fns' import type { TxFilterFormState } from '@/components/transactions/TxFilterForm' -import { safeFormatUnits, safeParseUnits } from '@/utils/formatters' import { getTimezone } from '@/services/transactions' type IncomingTxFilter = NonNullable @@ -69,7 +68,7 @@ export const txFilter = { execution_date__lte: formData.execution_date__lte ? endOfDay(formData.execution_date__lte).toISOString() : undefined, - value: formData.value ? safeParseUnits(formData.value, 18)?.toString() : undefined, + value: formData.value, }) return { @@ -93,7 +92,7 @@ export const txFilter = { ...filter, execution_date__gte: !isModule && filter.execution_date__gte ? new Date(filter.execution_date__gte) : null, execution_date__lte: !isModule && filter.execution_date__lte ? new Date(filter.execution_date__lte) : null, - value: !isModule && filter.value ? safeFormatUnits(filter.value, 18)?.toString() : '', + value: isModule ? '' : filter.value, } }, } diff --git a/apps/web/src/utils/tx-list.ts b/apps/web/src/utils/tx-list.ts index f54b89bf0b..e6b9ef7f78 100644 --- a/apps/web/src/utils/tx-list.ts +++ b/apps/web/src/utils/tx-list.ts @@ -2,7 +2,7 @@ import { TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' import type { Transaction, TransactionListItem } from '@safe-global/safe-gateway-typescript-sdk' import { isConflictHeaderListItem, isNoneConflictType, isTransactionListItem } from '@/utils/transaction-guards' -import { sameAddress } from './addresses' +import { sameAddress } from '@safe-global/utils/utils/addresses' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' type GroupedTxs = Array diff --git a/apps/web/src/utils/url.ts b/apps/web/src/utils/url.ts index a9c5eb19fd..dd14141fa5 100644 --- a/apps/web/src/utils/url.ts +++ b/apps/web/src/utils/url.ts @@ -43,3 +43,13 @@ export const getOriginPath = (url: string): string => { return url } } + +// Function to strip URL parameters, returning only the base URL +export const stripUrlParams = (url: string): string => { + try { + const urlObj = new URL(url) + return `${urlObj.origin}${urlObj.pathname}` + } catch (e) { + return url + } +} diff --git a/apps/web/src/utils/wallets.ts b/apps/web/src/utils/wallets.ts index 9596de55d9..707d25a5a2 100644 --- a/apps/web/src/utils/wallets.ts +++ b/apps/web/src/utils/wallets.ts @@ -1,5 +1,5 @@ import type { EthersError } from '@/utils/ethers-utils' -import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' +import { getWalletConnectLabel, type ConnectedWallet } from '@/hooks/wallets/useOnboard' import { getWeb3ReadOnly } from '@/hooks/wallets/web3' import { WALLET_KEYS } from '@/hooks/wallets/consts' import { EMPTY_DATA } from '@safe-global/protocol-kit/dist/src/utils/constants' @@ -8,6 +8,7 @@ import { PRIVATE_KEY_MODULE_LABEL } from '@/services/private-key-module' import { type JsonRpcProvider } from 'ethers' const WALLETCONNECT = 'WalletConnect' +const WC_LEDGER = 'Ledger Wallet' const isWCRejection = (err: Error): boolean => { return /rejected/.test(err?.message) @@ -21,8 +22,16 @@ export const isWalletRejection = (err: EthersError | Error): boolean => { return isEthersRejection(err as EthersError) || isWCRejection(err) } +export const isEthSignWallet = (wallet: ConnectedWallet): boolean => { + return [WALLET_KEYS.TREZOR, WALLET_KEYS.KEYSTONE].includes(wallet.label.toUpperCase() as WALLET_KEYS) +} + +export const isLedgerLive = (wallet: ConnectedWallet): boolean => { + return getWalletConnectLabel(wallet) === WC_LEDGER +} + export const isLedger = (wallet: ConnectedWallet): boolean => { - return wallet.label.toUpperCase() === WALLET_KEYS.LEDGER + return wallet.label.toUpperCase() === WALLET_KEYS.LEDGER || isLedgerLive(wallet) } export const isWalletConnect = (wallet: ConnectedWallet): boolean => { @@ -35,6 +44,10 @@ export const isHardwareWallet = (wallet: ConnectedWallet): boolean => { ) } +export const isPKWallet = (wallet: ConnectedWallet): boolean => { + return wallet.label.toUpperCase() === WALLET_KEYS.PK +} + export const isSmartContract = async (address: string, provider?: JsonRpcProvider): Promise => { const web3 = provider ?? getWeb3ReadOnly() @@ -56,7 +69,7 @@ export const isSmartContractWallet = memoize( /* Check if the wallet is unlocked. */ export const isWalletUnlocked = async (walletName: string): Promise => { - if (walletName === PRIVATE_KEY_MODULE_LABEL) return true + if ([PRIVATE_KEY_MODULE_LABEL, WALLETCONNECT].includes(walletName)) return true const METAMASK_LIKE = ['MetaMask', 'Rabby Wallet', 'Zerion'] diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 70787e3a8e..cafab523ba 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -21,7 +21,8 @@ "@gnosis.pm/zodiac/*": ["../../node_modules/@gnosis.pm/zodiac/*"], "@cowprotocol/app-data": ["../../node_modules/@cowprotocol/app-data"], "@/public/*": ["./public/*"], - "@safe-global/store/*": ["../../packages/store/src/*"] + "@safe-global/store/*": ["../../packages/store/src/*"], + "@safe-global/utils/*": ["../../packages/utils/src/*"] }, "plugins": [ { diff --git a/codemods/safe-gateway-typescript-sdk/convert-messages.js b/codemods/safe-gateway-typescript-sdk/convert-messages.js new file mode 100644 index 0000000000..d8c00c03b8 --- /dev/null +++ b/codemods/safe-gateway-typescript-sdk/convert-messages.js @@ -0,0 +1,56 @@ +/** + * jscodeshift transformer to update @safe-global/safe-gateway-typescript-sdk Messages imports to @safe-global/store messages imports. + * + * The scripts modifies import declarations, type references and type assertions across the codebase. + * + * To use the script run + * + * jscodeshift -t codemods/safe-gateway-typescript-sdk/convert-messages.js apps/web/src --extensions=tsx --parser=tsx + * and + * jscodeshift -t codemods/safe-gateway-typescript-sdk/convert-messages.js apps/web/src --extensions=ts --parser=ts + * + * This way the changes are applied to both TypeScript and JSX files. + */ + +import createTransformer from './transform' +export const importMapping = { + // Messages + SafeMessageListItemType: { module: '@safe-global/store/gateway/types', newName: 'SafeMessageListItemType' }, + SafeMessageDateLabel: { module: '@safe-global/store/gateway/AUTO_GENERATED/messages', newName: 'DateLabel' }, + SafeMessageStatus: { module: '@safe-global/store/gateway/types', newName: 'SafeMessageStatus' }, + TypedDataDomain: { module: '@safe-global/store/gateway/AUTO_GENERATED/messages', newName: 'TypedDataDomain' }, + TypedDataTypes: { module: '@safe-global/store/gateway/AUTO_GENERATED/messages', newName: 'TypedDataParameter' }, + TypedMessageTypes: { module: '@safe-global/store/gateway/types', newName: 'TypedMessageTypes' }, + EIP712TypedData: { module: '@safe-global/store/gateway/AUTO_GENERATED/messages', newName: 'TypedData' }, + SafeMessage: { module: '@safe-global/store/gateway/AUTO_GENERATED/messages', newName: 'MessageItem' }, + SafeMessageListItem: { module: '@safe-global/store/gateway/types', newName: 'SafeMessageListItem' }, + SafeMessageListPage: { module: '@safe-global/store/gateway/AUTO_GENERATED/messages', newName: 'MessagePage' }, + ProposeSafeMessageRequest: { + module: '@safe-global/store/gateway/AUTO_GENERATED/messages', + newName: 'CreateMessageDto', + }, + ConfirmSafeMessageRequest: { + module: '@safe-global/store/gateway/AUTO_GENERATED/messages', + newName: 'UpdateMessageSignatureDto', + }, +} + +export const enumLiteralMappings = { + SafeMessageStatus: { + NEEDS_CONFIRMATION: 'NEEDS_CONFIRMATION', + CONFIRMED: 'CONFIRMED', + }, + + SafeMessageListItemType: { + DATE_LABEL: 'DATE_LABEL', + MESSAGE: 'MESSAGE', + }, +} + +export const sourcePackage = '@safe-global/safe-gateway-typescript-sdk' + +export default createTransformer({ + importMapping, + enumLiteralMappings, + sourcePackage, +}) diff --git a/codemods/safe-gateway-typescript-sdk/transform.js b/codemods/safe-gateway-typescript-sdk/transform.js new file mode 100644 index 0000000000..14585e547e --- /dev/null +++ b/codemods/safe-gateway-typescript-sdk/transform.js @@ -0,0 +1,107 @@ +/** + * Generic jscodeshift transformer that can be used with different mapping configurations + * to update imports and type references across the codebase. + * + * To use this transformer, create a mappings object with the following properties: + * - importMapping: Object mapping old imports to new module/name pairs + * - enumLiteralMappings: Object mapping enum names to their literal values + * - sourcePackage: String identifying the source package to transform + * + * then pass the mappings object to the transformer. + */ +export default function createTransformer(mappings) { + return function transformer(file, api) { + if (!file.source.includes(mappings.sourcePackage)) { + return file.source + } + + const j = api.jscodeshift + const root = j(file.source) + + const newImportsMap = new Map() + const importsToRemove = [] + + // Replace enum references with literals based on the configurable mapping + Object.entries(mappings.enumLiteralMappings).forEach(([enumName, enumMappings]) => { + root.find(j.MemberExpression, { object: { name: enumName } }).forEach((path) => { + const propName = path.node.property.name + if (enumMappings[propName]) { + const newNode = j.tsAsExpression( + j.stringLiteral(enumMappings[propName]), + j.tsTypeReference(j.identifier('const')), + ) + j(path).replaceWith(newNode) + } + }) + }) + + // Transform imports + root.find(j.ImportDeclaration, { source: { value: mappings.sourcePackage } }).forEach((path) => { + const baseImportKind = path.node.importKind || 'value' + const transformedSpecifiers = [] + + path.node.specifiers.forEach((specifier) => { + if (specifier.type !== 'ImportSpecifier') return + + const importedName = specifier.imported.name + const mapping = mappings.importMapping[importedName] + if (!mapping) return + + const specifierImportKind = specifier.importKind || baseImportKind + const key = `${mapping.module}|${specifierImportKind}` + if (!newImportsMap.has(key)) newImportsMap.set(key, []) + + const localName = specifier.local.name !== importedName ? specifier.local.name : undefined + if (localName) { + newImportsMap.get(key).push(j.importSpecifier(j.identifier(mapping.newName), j.identifier(localName))) + } else { + newImportsMap.get(key).push(j.importSpecifier(j.identifier(mapping.newName))) + } + + transformedSpecifiers.push(specifier) + }) + + if (transformedSpecifiers.length === path.node.specifiers.length) { + importsToRemove.push(path) + } else { + path.node.specifiers = path.node.specifiers.filter((spec) => !transformedSpecifiers.includes(spec)) + } + }) + + importsToRemove.forEach((p) => j(p).remove()) + + // Add new imports + newImportsMap.forEach((specifiers, key) => { + const [module, importKind] = key.split('|') + const importDecl = j.importDeclaration(specifiers, j.literal(module)) + if (importKind === 'type') importDecl.importKind = 'type' + root.get().node.program.body.unshift(importDecl) + }) + + // Replace types in angle-bracket assertions + root.find(j.TSTypeAssertion).forEach((path) => { + const typeName = path.node.typeAnnotation.typeName?.name + if (typeName && mappings.importMapping[typeName]) { + path.node.typeAnnotation.typeName.name = mappings.importMapping[typeName].newName + } + }) + + // Replace type references + root.find(j.TSTypeReference).forEach((path) => { + const typeName = path.node.typeName?.name + if (typeName && mappings.importMapping[typeName]) { + path.node.typeName.name = mappings.importMapping[typeName].newName + } + }) + + // Remove empty import declarations + root + .find(j.ImportDeclaration) + .filter((path) => path.node.specifiers.length === 0) + .forEach((path) => { + j(path).remove() + }) + + return root.toSource({ quote: 'single', trailingComma: true }) + } +} diff --git a/config/test/msw/handlers.ts b/config/test/msw/handlers.ts index 7eb5989420..b6f7d5ca27 100644 --- a/config/test/msw/handlers.ts +++ b/config/test/msw/handlers.ts @@ -5,6 +5,14 @@ import { CollectiblePage } from '@safe-global/store/src/gateway/AUTO_GENERATED/c const iso4217Currencies = ['USD', 'EUR', 'GBP'] export const handlers = (GATEWAY_URL: string) => [ + http.get(`${GATEWAY_URL}/v1/auth/nonce`, () => { + return HttpResponse.json({ + nonce: 'mock-nonce-for-testing-12345', + timestamp: new Date().toISOString(), + expirationTime: new Date(Date.now() + 300000).toISOString(), + }) + }), + http.get(`${GATEWAY_URL}/v1/chains/1/safes/0x123/balances/USD`, () => { return HttpResponse.json({ items: [ diff --git a/config/test/presets/jest-preset.js b/config/test/presets/jest-preset.js index d1882045a6..2d5190903b 100644 --- a/config/test/presets/jest-preset.js +++ b/config/test/presets/jest-preset.js @@ -6,6 +6,8 @@ module.exports = { moduleNameMapper: { '.+\\.(css|style|less|sass|scss|png|jpg|ttf|woff|woff2|mp4)$': 'jest-transform-stub', // Jest by default doesn't support absolute imports out of the box + '^@safe-global/utils/(.*)$': '/../../packages/utils/src/$1', + '^@safe-global/store/(.*)$': '/../../packages/store/src/$1', '^src/(.*)$': '/src/$1', }, modulePathIgnorePatterns: ['/node_modules'], @@ -14,7 +16,7 @@ module.exports = { // setupFilesAfterEnv: ['/../../config/jest-presets/jest/setup.js'], transformIgnorePatterns: [ - 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|react-redux|moti/.*)', + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|react-redux|moti/.*|@safe-global/safe-apps-sdk|ethereum-cryptography|@safe-global/protocol-kit|@safe-global/safe-gateway-typescript-sdk)', ], coverageDirectory: '/coverage', coverageReporters: ['json', 'lcov', 'html'], diff --git a/config/tsconfig/confs/base.json b/config/tsconfig/confs/base.json index 4d2c701606..5fc6257763 100644 --- a/config/tsconfig/confs/base.json +++ b/config/tsconfig/confs/base.json @@ -2,7 +2,8 @@ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "paths": { - "@safe-global/store/*": ["packages/store/src/*"] + "@safe-global/store/*": ["packages/store/src/*"], + "@safe-global/utils/*": ["packages/utils/src/*"] }, "importHelpers": true, "allowJs": false, diff --git a/docs/release-procedure.md b/docs/release-procedure.md deleted file mode 100644 index 6868b7c00b..0000000000 --- a/docs/release-procedure.md +++ /dev/null @@ -1,15 +0,0 @@ -# Releasing to Production - -The code is being actively developed on the `main` branch. Pull requests are made against this branch. - -When we want to make a release, we create a new branch from `main` called `mobile-release/vX.Y.Z` where `X.Y.Z` is the -version number of the release. - -This will trigger a new build on the CI/CD pipeline, which will build the app and submit it to the internal distribution -lanes in App Store and Google Play Store. - -The release has to be tested by QA and once approved can be promoted to the production lane. - -## Triggering Maestro E2E tests - -On the release PR add the github label `eas-build-ios:build-and-maestro-test` to trigger the e2e tests in Expo CI. diff --git a/expo-plugins/notification-service-ios/README.md b/expo-plugins/notification-service-ios/README.md new file mode 100644 index 0000000000..9c23fbc3c5 --- /dev/null +++ b/expo-plugins/notification-service-ios/README.md @@ -0,0 +1,5 @@ +# Notification service ios + +This plugin is based on top of the excellent https://github.com/evennit/notifee-expo-plugin +We needed some custom functionality on top of the notifee-expo-plugin and the easiest thing was +to copy and modify it for our use case. diff --git a/expo-plugins/notification-service-ios/app.plugin.js b/expo-plugins/notification-service-ios/app.plugin.js new file mode 100644 index 0000000000..1f1028f576 --- /dev/null +++ b/expo-plugins/notification-service-ios/app.plugin.js @@ -0,0 +1 @@ +module.exports = require('./dist/plugin/withNotifee.js') diff --git a/expo-plugins/notification-service-ios/ios-notification-service-files/NotifeeNotificationServiceExtension-Info.plist b/expo-plugins/notification-service-ios/ios-notification-service-files/NotifeeNotificationServiceExtension-Info.plist new file mode 100644 index 0000000000..01b2b348e6 --- /dev/null +++ b/expo-plugins/notification-service-ios/ios-notification-service-files/NotifeeNotificationServiceExtension-Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NotifeeNotificationServiceExtension + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + \ No newline at end of file diff --git a/expo-plugins/notification-service-ios/ios-notification-service-files/NotifeeNotificationServiceExtension.entitlements b/expo-plugins/notification-service-ios/ios-notification-service-files/NotifeeNotificationServiceExtension.entitlements new file mode 100644 index 0000000000..5366d587dd --- /dev/null +++ b/expo-plugins/notification-service-ios/ios-notification-service-files/NotifeeNotificationServiceExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + [APP_GROUPS_PLACEHOLDER] + + + \ No newline at end of file diff --git a/expo-plugins/notification-service-ios/ios-notification-service-files/NotificationService.swift b/expo-plugins/notification-service-ios/ios-notification-service-files/NotificationService.swift new file mode 100644 index 0000000000..8b8cb4ac95 --- /dev/null +++ b/expo-plugins/notification-service-ios/ios-notification-service-files/NotificationService.swift @@ -0,0 +1,135 @@ +import UserNotifications +import RNNotifeeCore +import MMKV +import SwiftCryptoTokenFormatter +import BigInt + +struct ChainInfo: Codable { + let name: String + let symbol: String + let decimals: Int +} + +struct ExtensionStore: Codable { + let chains: [String: ChainInfo] + let contacts: [String: String] +} + +func loadExtensionStore() -> ExtensionStore? { + guard let groupDir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "[NOTIFICATION_APP_GROUP_IDENTIFIER]")?.path else { + NSLog("[NotifeeDebug] Failed to get app group directory") + return nil + } + guard let kv = MMKV(mmapID: "extension", cryptKey: nil, rootPath: groupDir, mode: .multiProcess, expectedCapacity: 0) else { + NSLog("[NotifeeDebug] Failed to open MMKV") + return nil + } + guard let json = kv.string(forKey: "notification-extension-data") else { + NSLog("[NotifeeDebug] No data found for notification-extension-data key") + return nil + } + guard let data = json.data(using: String.Encoding.utf8) else { + NSLog("[NotifeeDebug] Failed to convert json to data") + return nil + } + return try? JSONDecoder().decode(ExtensionStore.self, from: data) +} + + +func parseNotification(userInfo: [AnyHashable: Any], store: ExtensionStore) -> (String, String)? { + NSLog("[NotifeeDebug] Parsing notification with userInfo: \(userInfo)") + + guard let type = userInfo["type"] as? String else { + NSLog("[NotifeeDebug] No type found in notification") + return nil + } + NSLog("[NotifeeDebug] Notification type: \(type)") + + let chainId = userInfo["chainId"] as? String + let address = userInfo["address"] as? String + + NSLog("[NotifeeDebug] ChainId: \(chainId ?? "nil"), Address: \(address ?? "nil")") + + let chainInfo = chainId.flatMap { store.chains[$0] } + let chainName = chainInfo?.name ?? (chainId != nil ? "Chain Id \(chainId!)" : "") + let safeName = address.flatMap { store.contacts[$0] } ?? (address ?? "") + + NSLog("[NotifeeDebug] Resolved chainName: \(chainName), safeName: \(safeName)") + + switch type { + case "INCOMING_ETHER": + // Use chain symbol if available, otherwise fall back to userInfo or default + let symbol = chainInfo?.symbol ?? userInfo["symbol"] as? String ?? "ETH" + let formatter = TokenFormatter() + let value = userInfo["value"] as? String ?? "" + // Use chain decimals if available, otherwise fall back to userInfo or default + let decimals = chainInfo?.decimals ?? Int(userInfo["decimals"] as? String ?? "18") ?? 18 + let amount = formatter.string( + from: BigDecimal(BigInt(value) ?? BigInt(0), decimals), + decimalSeparator: Locale.autoupdatingCurrent.decimalSeparator ?? ".", + thousandSeparator: Locale.autoupdatingCurrent.groupingSeparator ?? ",") + + return ("Incoming \(symbol) (\(chainName))", "\(safeName): \(amount) \(symbol) received") + case "INCOMING_TOKEN": + return ("Incoming token (\(chainName))", "\(safeName): tokens received") + case "EXECUTED_MULTISIG_TRANSACTION": + let status = (userInfo["failed"] as? String) == "true" ? "failed" : "successful" + return ("Transaction \(status) (\(chainName))", "\(safeName): Transaction \(status)") + case "CONFIRMATION_REQUEST": + return ("Confirmation required (\(chainName))", "\(safeName): A transaction requires your confirmation!") + default: + return nil + } +} + +class NotificationService: UNNotificationServiceExtension { + let appGroup = "[NOTIFICATION_APP_GROUP_IDENTIFIER]" + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override init() { + super.init() + if let groupDir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)?.path { + MMKV.initialize(rootDir: groupDir) + } else { + NSLog("[NotifeeDebug] Failed to initialize MMKV: couldn't get app group directory") + } + } + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + NSLog("[NotifeeDebug] Received notification request with id: \(request.identifier)") + self.contentHandler = contentHandler + self.bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + + if let mutable = self.bestAttemptContent { + NSLog("[NotifeeDebug] Successfully created mutable content") + + if let store = loadExtensionStore() { + NSLog("[NotifeeDebug] Successfully loaded extension store") + + if let parsed = parseNotification(userInfo: request.content.userInfo, store: store) { + NSLog("[NotifeeDebug] Successfully parsed notification: title=\(parsed.0), body=\(parsed.1)") + mutable.title = parsed.0 + mutable.body = parsed.1 + mutable.badge = 1 + } else { + NSLog("[NotifeeDebug] Failed to parse notification") + } + } else { + NSLog("[NotifeeDebug] Failed to load extension store") + } + + NotifeeExtensionHelper.populateNotificationContent(request, with: mutable, withContentHandler: contentHandler) + } else { + NSLog("[NotifeeDebug] Failed to create mutable content") + contentHandler(request.content) + } + } + + override func serviceExtensionTimeWillExpire() { + NSLog("[NotifeeDebug] Service extension time will expire") + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } + } +} diff --git a/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/BoolString.swift b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/BoolString.swift new file mode 100644 index 0000000000..7491304216 --- /dev/null +++ b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/BoolString.swift @@ -0,0 +1,40 @@ +// +// BoolString.swift +// Multisig +// +// Created by Dmitry Bespalov on 26.07.21. +// Copyright © 2021 Gnosis Ltd. All rights reserved. +// + +import Foundation + +struct BoolString: Hashable, Codable { + var value: Bool + + init(_ value: Bool) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + value = string == "true" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(value ? "true" : "false") + } +} + +extension BoolString: ExpressibleByStringLiteral { + init(stringLiteral value: StringLiteralType) { + self.init(value == "true") + } +} + +extension BoolString: CustomStringConvertible { + var description: String { + String(describing: value) + } +} diff --git a/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/CharacterSet+Hex.swift b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/CharacterSet+Hex.swift new file mode 100644 index 0000000000..b43b8359d9 --- /dev/null +++ b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/CharacterSet+Hex.swift @@ -0,0 +1,24 @@ +// +// CharacterSet+Hex.swift +// Multisig +// +// Created by Moaaz on 5/22/20. +// Copyright © 2020 Gnosis Ltd. All rights reserved. +// + +import Foundation + +extension CharacterSet { + + static var hexadecimalNumbers: CharacterSet { + return ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] + } + + static var hexadecimalLetters: CharacterSet { + return ["a", "b", "c", "d", "e", "f", "A", "B", "C", "D", "E", "F"] + } + + static var hexadecimals: CharacterSet { + return hexadecimalNumbers.union(hexadecimalLetters) + } +} diff --git a/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/ConfigurationKey.swift b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/ConfigurationKey.swift new file mode 100644 index 0000000000..993c396be4 --- /dev/null +++ b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/ConfigurationKey.swift @@ -0,0 +1,122 @@ +// +// Configuration.swift +// Multisig +// +// Created by Dmitry Bespalov on 27.05.20. +// Copyright © 2020 Gnosis Ltd. All rights reserved. +// + +import Foundation + +/// Allows to fetch a variable from the main Info.plist or override it +/// with a concrete value. +/// +/// All of the standard data types present in the Plist format +/// are supported as well, plus the URL type that is converted from String +/// value. +/// +/// The supported values conform to the `InfoPlistValueType` protocol. +/// You can conform your own types to this protocol. +/// +/// Example usage: +/// +/// struct Configuration { +/// +/// @ConfigurationKey("TERMS_URL") +/// var termsURL: URL +/// +/// @ConfigurationKey("RELAY_SERVICE_URL") +/// var relayServiceURL: URL +/// +/// } +/// +/// IMPORTANT: test your configuration values, otherwise the app will +/// crash if the value with the specified key is not found or if the value +/// cannot be converted to the supported type. +/// +@propertyWrapper +struct ConfigurationKey { + private let key: String + private var override: T? + + init(_ key: String) { + self.key = key + } + + var wrappedValue: T { + get { + if let overriden = override { return overriden } + guard let value = Bundle.main.object(forInfoDictionaryKey: key) else { + preconditionFailure("Configuration key \(key) not found in the info dictionary") + } + return T.convert(from: value) + } + set { + override = newValue + } + } + +} + +/// Value type that is used in the Info.plist dictionary +protocol InfoPlistValueType { + /// Converts value from a plist object to the protocol's implementation type + /// - Parameter value: a value from Info.plist dictionary + static func convert(from value: Any) -> Self +} + +extension URL: InfoPlistValueType { + static func convert(from value: Any) -> URL { + URL(string: value as! String)! + } +} + +extension String: InfoPlistValueType { + static func convert(from value: Any) -> Self { + value as! String + } +} + +extension Int: InfoPlistValueType { + static func convert(from value: Any) -> Self { + value as! Int + } +} + +extension Double: InfoPlistValueType { + static func convert(from value: Any) -> Self { + value as! Double + } +} + +extension Bool: InfoPlistValueType { + static func convert(from value: Any) -> Self { + if let bool = value as? Bool { return bool } + else if let nsString = value as? NSString { return nsString.boolValue } + preconditionFailure("Invalid configuration value: \(value)") + } +} + +extension Dictionary: InfoPlistValueType where Key == String, Value == Any { + static func convert(from value: Any) -> Self { + value as! [String: Any] + } +} + +extension Array: InfoPlistValueType where Element == Any { + static func convert(from value: Any) -> Self { + value as! [Any] + } +} + +extension Date: InfoPlistValueType { + static func convert(from value: Any) -> Self { + value as! Date + } +} + +extension Data: InfoPlistValueType { + static func convert(from value: Any) -> Self { + value as! Data + } +} diff --git a/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/Data+MultisigExtension.swift b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/Data+MultisigExtension.swift new file mode 100644 index 0000000000..7f0a736c08 --- /dev/null +++ b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/Data+MultisigExtension.swift @@ -0,0 +1,102 @@ +// +// Copyright © 2018 Gnosis Ltd. All rights reserved. +// + +import Foundation +import CryptoSwift + +// MARK: - Hex String to Data conversion +public extension Data { + + static func value(of nibble: UInt8) -> UInt8? { + guard let letter = String(bytes: [nibble], encoding: .ascii) else { return nil } + return UInt8(letter, radix: 16) + } + + // TODO: Duplicate code. Remove and use init(hex: String) instead, when + // disambiguation with other implementations of Data.init?(hex:) has been achieved + init(hexWC: String) { + var data = Data() + let string = hexWC.hasPrefix("0x") ? String(hexWC.dropFirst(2)) : hexWC + + // Convert the string to bytes for better performance + guard + let stringData = string.data(using: .ascii, allowLossyConversion: true) + else { + self = data + return + } + + let stringBytes = Array(stringData) + for idx in stride(from: 0, to: stringBytes.count, by: 2) { + guard let high = Data.value(of: stringBytes[idx]) else { + data.removeAll() + break + } + if idx < stringBytes.count - 1, let low = Data.value(of: stringBytes[idx + 1]) { + data.append((high << 4) | low) + } else { + data.append(high) + } + } + self = data + } + + /// Creates data from hex string, padding to even byte character count from the left with 0. + /// For example, "0x1" will become "0x01". + /// + /// - Parameter ethHex: hex string. + init(ethHex: String) { + var value = ethHex + while value.hasPrefix("0x") || value.hasPrefix("0X") { value = String(value.dropFirst(2)) } + // if ethHex is not full byte, Data(hex:) adds nibble at the end, but we need it in the beginning + let paddingToByte = value.count % 2 == 1 ? "0" : "" + value = paddingToByte + value + self.init(hexWC: value) + } + + init?(exactlyHex hex: String) { + var value = hex.lowercased() + if value.hasPrefix("0x") { + value.removeFirst(2) + } + guard value.rangeOfCharacter(from: CharacterSet.hexadecimals.inverted) == nil else { + return nil + } + self.init(hexWC: value) + } + + func toHexStringWithPrefix() -> String { + "0x" + toHexString() + } + + /// Pads data with `value` from the left to total width of `count` + /// + /// - Parameters: + /// - count: total padded with= + /// - value: padding value, default is 0 + /// - Returns: padded data of size `count` + func leftPadded(to count: Int, with value: UInt8 = 0) -> Data { + if self.count >= count { return self } + return Data(repeating: value, count: count - self.count) + self + } + + func rightPadded(to count: Int, with value: UInt8 = 0) -> Data { + if self.count >= count { return self } + return self + Data(repeating: value, count: count - self.count) + } + + func endTruncated(to count: Int) -> Data { + guard self.count > count else { return self } + return prefix(count) + } + + init?(randomOfSize count: Int) { + var bytes: [UInt8] = .init(repeating: 0, count: count) + let result = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard result == errSecSuccess else { + return nil + } + self.init(bytes) + } +} diff --git a/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/DataString.swift b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/DataString.swift new file mode 100644 index 0000000000..a5641ffe3f --- /dev/null +++ b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/DataString.swift @@ -0,0 +1,45 @@ +// +// DataString.swift +// Multisig +// +// Created by Dmitry Bespalov on 16.06.20. +// Copyright © 2020 Gnosis Ltd. All rights reserved. +// + +import Foundation + +struct DataString: Hashable, Codable { + let data: Data + + init(_ data: Data) { + self.data = data + } + + init(hex: String) { + self.data = Data(hex: hex) + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + data = Data(hex: string) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(data.toHexStringWithPrefix()) + } + +} + +extension DataString: ExpressibleByStringLiteral { + init(stringLiteral value: StringLiteralType) { + self.init(Data(ethHex: value)) + } +} + +extension DataString: CustomStringConvertible { + var description: String { + data.toHexStringWithPrefix() + } +} diff --git a/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/UInt256.swift b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/UInt256.swift new file mode 100644 index 0000000000..76a4120c8a --- /dev/null +++ b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/UInt256.swift @@ -0,0 +1,19 @@ +// +// UInt256.swift +// Multisig +// +// Created by Dmitry Bespalov on 15.06.20. +// Copyright © 2020 Gnosis Ltd. All rights reserved. +// + +import Foundation +import BigInt + +typealias UInt256 = BigUInt +typealias Int256 = BigInt + +extension UInt256 { + var data32: Data { + Data(ethHex: String(self, radix: 16)).leftPadded(to: 32).suffix(32) + } +} diff --git a/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/UInt256String.swift b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/UInt256String.swift new file mode 100644 index 0000000000..75528d8429 --- /dev/null +++ b/expo-plugins/notification-service-ios/ios-notification-service-files/Utils/UInt256String.swift @@ -0,0 +1,78 @@ +// +// UInt256String.swift +// Multisig +// +// Created by Dmitry Bespalov on 16.06.20. +// Copyright © 2020 Gnosis Ltd. All rights reserved. +// + +import Foundation + +struct UInt256String: Hashable, Codable { + let value: UInt256 + + var data32: Data { + value.data32 + } + + init(_ value: T) where T: BinaryInteger { + self.value = UInt256(value) + } + + init(_ value: UInt256) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let string = try? container.decode(String.self) { + if string.hasPrefix("0x") { + let data = Data(ethHex: string) + value = UInt256(data) + } else if let uint256 = UInt256(string) { + value = uint256 + } else { + let context = DecodingError.Context.init( + codingPath: decoder.codingPath, + debugDescription: "Could not convert String \(string) to UInt256") + throw DecodingError.valueNotFound(UInt256.self, context) + } + } else if let uint = try? container.decode(UInt.self) { + value = UInt256(uint) + } else if let int = try? container.decode(Int.self), int >= 0 { + value = UInt256(int) + } else { + let context = DecodingError.Context.init( + codingPath: decoder.codingPath, + debugDescription: "Could not convert value to UInt256") + throw DecodingError.valueNotFound(UInt256.self, context) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(value.description) + } +} + +extension UInt256String: ExpressibleByStringLiteral { + init(stringLiteral value: StringLiteralType) { + if let uint256Value = UInt256(value) { + self = UInt256String(uint256Value) + } else { + preconditionFailure("Invalid literal UInt256 value: \(value)") + } + } +} + +extension UInt256String: ExpressibleByIntegerLiteral { + init(integerLiteral value: UInt) { + self.init(UInt256(value)) + } +} + +extension UInt256String: CustomStringConvertible { + var description: String { + String(value) + } +} diff --git a/expo-plugins/notification-service-ios/package.json b/expo-plugins/notification-service-ios/package.json new file mode 100644 index 0000000000..f260c16b8a --- /dev/null +++ b/expo-plugins/notification-service-ios/package.json @@ -0,0 +1,21 @@ +{ + "name": "@safe-global/notification-service-ios", + "version": "1.0.0", + "description": "This plugin will allow you to use Notifee with a notification service extension without needing to eject from Expo managed workflow.", + "main": "./app.plugin.js", + "scripts": { + "build": "rm -rf dist && tsc && cp -a ./ios-notification-service-files dist/ios-notification-service-files/", + "postinstall": "yarn run build" + }, + "keywords": [ + "notifee", + "expo", + "push", + "notifications" + ], + "license": "MIT", + "devDependencies": { + "expo-module-scripts": "^4.1.7", + "typescript": "5.8.3" + } +} diff --git a/expo-plugins/notification-service-ios/plugin/config.ts b/expo-plugins/notification-service-ios/plugin/config.ts new file mode 100644 index 0000000000..fc646f2a09 --- /dev/null +++ b/expo-plugins/notification-service-ios/plugin/config.ts @@ -0,0 +1,31 @@ +/** IOS */ +export const DEFAULT_IOS_BUILD_NUMBER = '1' +export const DEFAULT_APP_VERSION = '1.0.0' +export const DEFAULT_IOS_DEPLOYMENT_TARGET = '13.4' +export const TARGET_DEVICES = '"1,2"' // IPHONE / IPAD + +export const EXTENSION_SERVICE_NAME = 'NotifeeNotificationServiceExtension' +export const EXTENSION_SERVICE_FILE = 'NotificationService.swift' +export const FILES_TO_ADD = [ + `NotifeeNotificationServiceExtension-Info.plist`, + `NotifeeNotificationServiceExtension.entitlements`, +] + +export const PODFILE_MODIF_NEEDED = ` +$NotifeeExtension = true +target 'NotifeeNotificationServiceExtension' do + pod 'RNNotifeeCore', :path => '../../../node_modules/@notifee/react-native/RNNotifeeCore.podspec' + pod 'MMKV', :git => 'https://github.com/Tencent/MMKV.git', :branch => 'master' + pod 'CryptoSwift', '~> 1.8.3' + pod 'SwiftCryptoTokenFormatter', :git => 'https://github.com/compojoom/SwiftCryptoTokenFormatter', :branch => 'main' + use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks'] +end` +export const PODFILE_TARGET_STRING = "target 'NotifeeNotificationServiceExtension'" +export const APP_VERSION_STRING = '[IF_YOU_SEE_THIS_YOU_FORGOT_TO_ADD_APP_VERSION_IN_EXPO_CONFIG]' +export const BUNDLE_IDENTIFIER_STRING = '[IF_YOU_SEE_THIS_YOU_FORGOT_TO_ADD_BUNDLE_IDENTIFIER_IN_IOS_EXPO_CONFIG]' +export const IOS_BUILD_NUMBER_STRING = '[IF_YOU_SEE_THIS_YOU_FORGOT_TO_ADD_BUILD_NUMBER_IN_IOS_EXPO_CONFIG]' +export const NOTIFICATION_APP_GROUP_IDENTIFIER_STRING = '[NOTIFICATION_APP_GROUP_IDENTIFIER]' +export const APP_GROUPS_PLACEHOLDER = '[APP_GROUPS_PLACEHOLDER]' + +export const BACKGROUND_MODES_TO_ENABLE = ['remote-notification'] +export const USER_ACTIVITY_TYPES_KEYS = ['INSendMessageIntent'] diff --git a/expo-plugins/notification-service-ios/plugin/ios.ts b/expo-plugins/notification-service-ios/plugin/ios.ts new file mode 100644 index 0000000000..42d72763c8 --- /dev/null +++ b/expo-plugins/notification-service-ios/plugin/ios.ts @@ -0,0 +1,456 @@ +import { NotifeeExpoPluginProps } from './types' +import { + ConfigPlugin, + withDangerousMod, + withEntitlementsPlist, + withInfoPlist, + withXcodeProject, +} from '@expo/config-plugins' +import * as fs from 'fs' +import * as path from 'path' +import { + APP_VERSION_STRING, + APP_GROUPS_PLACEHOLDER, + BACKGROUND_MODES_TO_ENABLE, + BUNDLE_IDENTIFIER_STRING, + DEFAULT_APP_VERSION, + DEFAULT_IOS_BUILD_NUMBER, + DEFAULT_IOS_DEPLOYMENT_TARGET, + EXTENSION_SERVICE_FILE, + EXTENSION_SERVICE_NAME, + FILES_TO_ADD, + IOS_BUILD_NUMBER_STRING, + NOTIFICATION_APP_GROUP_IDENTIFIER_STRING, + PODFILE_MODIF_NEEDED, + PODFILE_TARGET_STRING, + TARGET_DEVICES, + USER_ACTIVITY_TYPES_KEYS, +} from './config' +import { log, logError, throwError } from './utils' + +/** + * Adds Notifee to the iOS Podfile within an Expo project configuration. + * + * @param {object} c - The Expo configuration object. + * @returns {object} - The updated Expo configuration object after modifying the Podfile. + */ +const addNotifeeToPodfile: ConfigPlugin = (c) => { + return withDangerousMod(c, [ + 'ios', + async (c) => { + const pathToPodfile = path.join(c.modRequest.projectRoot, 'ios', 'Podfile') + + try { + const podfile = fs.readFileSync(pathToPodfile, 'utf8') + const hasAlreadyNeededChanges = podfile.includes(PODFILE_TARGET_STRING) + //Add at end of podfile + if (!hasAlreadyNeededChanges) { + fs.appendFileSync(pathToPodfile, PODFILE_MODIF_NEEDED) + } + + log('Added Notifee to Podfile') + } catch { + throwError('Error when trying to add Notifee to Podfile') + } + + return c + }, + ]) +} + +/** + * Adds necessary notification service files to the iOS project for Notifee configuration. + * + * @param {object} c - The Expo configuration object. + * @param {NotifeeExpoPluginProps} props - The properties required for configuring Notifee-Expo-Plugin. + * @returns {object} - The updated Expo configuration object after adding the notification service files. + */ +const addNotificationServiceFilesToProject: ConfigPlugin = (c, props) => { + const serviceExtensionFilesFolderPath = path.join( + require.resolve('@safe-global/notification-service-ios/package.json'), + '../ios-notification-service-files/', + ) + + const updatedConfig = withDangerousMod(c, [ + 'ios', + async (config) => { + const p = path.join(config.modRequest.projectRoot, 'ios') + + try { + //Create folders + fs.mkdirSync(path.join(p, EXTENSION_SERVICE_NAME), { recursive: true }) + + if (!config.version) { + logError("You need to define 'version' in the expo config!") + } + const appVersion = !!config.version ? config.version : DEFAULT_APP_VERSION + if (!c.ios || !c.ios.bundleIdentifier) { + logError("You need to define 'bundleIdentifier' in the ios object of the expo config!") + } + const bundleIdentifier = 'group.' + config.ios?.bundleIdentifier + '.notifee' + if (!c.ios || !c.ios.bundleIdentifier) { + logError("You need to define 'buildNumber' in the ios object of the expo config!") + } + const buildNumber = !!config.ios?.buildNumber ? config.ios.buildNumber : DEFAULT_IOS_BUILD_NUMBER + //Transfer files & Edit necessary values + for (const fileName of FILES_TO_ADD) { + const pathToFileToRead = path.join(serviceExtensionFilesFolderPath, fileName) + const pathWhereToWrite = path.join(p, EXTENSION_SERVICE_NAME, fileName) + let file = fs.readFileSync(pathToFileToRead, 'utf8') + if (fileName === EXTENSION_SERVICE_NAME + '-Info.plist') { + file = file.replace(APP_VERSION_STRING, appVersion) + file = file.replace(IOS_BUILD_NUMBER_STRING, buildNumber) + } else if (fileName === EXTENSION_SERVICE_NAME + '.entitlements') { + // Build the app groups array for entitlements + const defaultNotifeeGroup = bundleIdentifier + const mainAppGroup = props.appGroupIdentifier || `group.${config.ios?.bundleIdentifier}` + const additionalGroups = props.additionalAppGroups || [] + + // Combine all app groups (notifee group + main app group + additional groups) + const allAppGroups = [defaultNotifeeGroup, mainAppGroup, ...additionalGroups] + + // Remove duplicates + const uniqueAppGroups = Array.from(new Set(allAppGroups)) + + // Generate the XML string for app groups + const appGroupsXml = uniqueAppGroups.map((group) => `\t\t${group}`).join('\n') + + file = file.replace(APP_GROUPS_PLACEHOLDER, appGroupsXml) + } + fs.writeFileSync(pathWhereToWrite, file) + } + + // Copy Utils folder if it exists + const sourceUtilsPath = path.join(serviceExtensionFilesFolderPath, 'Utils') + const destUtilsPath = path.join(p, EXTENSION_SERVICE_NAME, 'Utils') + + if (fs.existsSync(sourceUtilsPath)) { + // Create Utils directory if it doesn't exist + fs.mkdirSync(destUtilsPath, { recursive: true }) + + // Copy all files from Utils directory + const utilsFiles = fs.readdirSync(sourceUtilsPath) + for (const utilFile of utilsFiles) { + const sourcePath = path.join(sourceUtilsPath, utilFile) + const destPath = path.join(destUtilsPath, utilFile) + fs.copyFileSync(sourcePath, destPath) + } + log('Copied Utils folder successfully!') + } + + const notificationServicePath = !!props.customNotificationServiceFilePath + ? props.customNotificationServiceFilePath + : path.join(serviceExtensionFilesFolderPath, EXTENSION_SERVICE_FILE) + const pathWhereToWriteNotificationService = path.join(p, EXTENSION_SERVICE_NAME, EXTENSION_SERVICE_FILE) + + // Determine the app group identifier to use + const appGroupIdentifier = props.appGroupIdentifier || `group.${config.ios?.bundleIdentifier}` + + let notificationServiceFile = fs.readFileSync(notificationServicePath, 'utf8') + // Replace the app group identifier placeholder + notificationServiceFile = notificationServiceFile.replaceAll( + NOTIFICATION_APP_GROUP_IDENTIFIER_STRING, + appGroupIdentifier, + ) + fs.writeFileSync(pathWhereToWriteNotificationService, notificationServiceFile) + + log('Added NotificationService files!') + } catch { + logError('serviceExtensionFilesFolderPath: ' + serviceExtensionFilesFolderPath) + logError('Error while copying notification service files') + } + + return config + }, + ]) + //Make files added before available in xcode project + return withXcodeProject(updatedConfig, (nc) => { + const x = nc.modResults + + // Create the main service extension group + const mainGroup = x.addPbxGroup( + [...FILES_TO_ADD, EXTENSION_SERVICE_FILE], + EXTENSION_SERVICE_NAME, + EXTENSION_SERVICE_NAME, + ) + + // Create Utils subgroup if files exist + const sourceUtilsPath = path.join(serviceExtensionFilesFolderPath, 'Utils') + if (fs.existsSync(sourceUtilsPath)) { + const utilsFiles = fs.readdirSync(sourceUtilsPath) + if (utilsFiles.length > 0) { + // Create a Utils subgroup within the main group + const utilsGroup = x.addPbxGroup(utilsFiles, 'Utils', 'Utils') + // Add the Utils group to the main group + x.addToPbxGroup(utilsGroup.uuid, mainGroup.uuid) + + // Add Utils files to Sources build phase + const target = x.pbxTargetByName(EXTENSION_SERVICE_NAME) + if (target) { + const sourcesBuildPhase = x.buildPhaseObject('PBXSourcesBuildPhase', 'Sources', target.uuid) + if (sourcesBuildPhase) { + for (const utilFile of utilsFiles) { + if (utilFile.endsWith('.swift')) { + x.addToPbxBuildFileSection(`Utils/${utilFile}`) + x.addToPbxSourcesBuildPhase(`Utils/${utilFile}`, sourcesBuildPhase) + } + } + } + } + } + } + + // Add the main group to the root group + const pbxs = x.hash.project.objects['PBXGroup'] + Object.keys(pbxs).forEach(function (v) { + if (typeof pbxs[v] === 'object' && !pbxs[v].name && !pbxs[v].path) { + x.addToPbxGroup(mainGroup.uuid, v) + } + }) + + return nc + }) +} + +/** + * Signs the main iOS app target and the notification service extension target with the specified Apple development team ID. + * + * @param {object} c - The current Expo configuration object. + * @param {NotifeeExpoPluginProps} props - The properties containing the Apple development team ID. + * @returns {object} - The updated Expo configuration object after signing targets. + */ +const signAppAndNotificationServiceExtension: ConfigPlugin = (c, props) => { + if (!props.appleDevTeamId) { + return c + } + + return withXcodeProject(c, (nc) => { + const xcodeProject = nc.modResults + //Sign main target + const mainTarget = xcodeProject.pbxTargetByName(c.name) + if (mainTarget) { + xcodeProject.addTargetAttribute('DevelopmentTeam', props.appleDevTeamId, mainTarget) + } + //Sign notification service extension target + const target = xcodeProject.pbxTargetByName(EXTENSION_SERVICE_NAME) + if (target) { + xcodeProject.addTargetAttribute('DevelopmentTeam', props.appleDevTeamId, target) + } + log('Signed the main app and notification service extension targets with: ' + props.appleDevTeamId) + + return nc + }) +} + +/** + * Sets the APS Environment Entitlement in the app's entitlements plist file to specify whether to use the development or production Apple Push Notification service (APNs). + * + * @param {object} c - The current Expo configuration object. + * @param {NotifeeExpoPluginProps} props - The properties containing the APS environment mode (production or development). + * @returns {object} - The updated Expo configuration object after setting the APS environment. + */ +const setAPSEnvironment: ConfigPlugin = (c, props) => { + return withEntitlementsPlist(c, (nc) => { + nc.modResults['aps-environment'] = props.apsEnvMode + + log('Set aps-environment to: ' + props.apsEnvMode) + return nc + }) +} + +/** + * Adds the application group entitlement necessary for Notifee to the iOS project's entitlements plist. + * + * @param {object} c - The Expo configuration object. + * @returns {object} - The updated Expo configuration object with added application group entitlement. + */ +const addNotificationServiceGroup: ConfigPlugin = (c) => { + return withEntitlementsPlist(c, (nc) => { + const g = 'com.apple.security.application-groups' + if (!Array.isArray(nc.modResults[g])) { + nc.modResults[g] = [] + } + const gName = `group.${nc.ios?.bundleIdentifier}` + const modResults = nc.modResults[g] + if (!modResults.includes(gName)) { + modResults.push(gName) + } + + log(`Added '${gName} to com.apple.security.application-groups`) + return nc + }) +} + +/** + * Adds required background modes to the iOS project's Info.plist for Notifee functionality. + * + * @param {object} c - The Expo configuration object. + * @returns {object} - The updated Expo configuration object with added background modes. + */ +const addBackgroundModes: ConfigPlugin = (c, props) => { + return withInfoPlist(c, (nc) => { + //Added this condition so it doesn't add background modes capability without anything selected when the user wants no background modes. + if (props.backgroundModes && props.backgroundModes.length === 0) { + return nc + } + if (!Array.isArray(nc.modResults.UIBackgroundModes)) { + nc.modResults.UIBackgroundModes = [] + } + if (!props.backgroundModes) { + props.backgroundModes = BACKGROUND_MODES_TO_ENABLE + } + for (const mode of props.backgroundModes) { + if (!nc.modResults.UIBackgroundModes.includes(mode)) { + nc.modResults.UIBackgroundModes.push(mode) + } + } + log('Added background modes (' + props.backgroundModes.join(', ') + ')') + return nc + }) +} + +/** + * Enables communication notifications capability for the iOS project if specified in props. + * This includes setting entitlements and adding necessary keys to Info.plist. + * + * @param {object} c - The Expo configuration object. + * @param {NotifeeExpoPluginProps} props - The properties object containing configuration options. + * @returns {object} - The updated Expo configuration object with added communication notifications capability. + */ +const addCommunicationNotificationsCapability: ConfigPlugin = (c, props) => { + if (!props.enableCommunicationNotifications) { + return c + } + + const updatedConfig = withEntitlementsPlist(c, (nc) => { + if (props.enableCommunicationNotifications) { + nc.modResults['com.apple.developer.usernotifications.communication'] = true + } + log('Added communication notifications capability') + return nc + }) + + return withInfoPlist(updatedConfig, (nc) => { + if (!Array.isArray(nc.modResults.NSUserActivityTypes)) { + nc.modResults.NSUserActivityTypes = [] + } + for (const v of USER_ACTIVITY_TYPES_KEYS) { + if (!nc.modResults.NSUserActivityTypes.includes(v)) { + nc.modResults.NSUserActivityTypes.push(v) + } + } + log('Added INSendMessageIntent to NSUserActivityTypes for communication notifications') + return nc + }) +} + +/** + * Creates and adds a notification service extension target to the Xcode project if it doesn't already exist. + * Configures necessary settings and build phases for the extension target. + * + * @param {object} c - The Expo configuration object. + * @param {NotifeeExpoPluginProps} props - The properties object containing configuration options. + * @returns {object} - The updated Expo configuration object with the added notification service extension target. + */ +const createAndAddNotificationServiceExtensionTarget: ConfigPlugin = (c, props) => { + return withXcodeProject(c, (nc) => { + const x = nc.modResults + if (!!x.pbxTargetByName(EXTENSION_SERVICE_NAME)) { + return nc + } + + /** + * Needed or project with one target won't add notification extension service target + * correctly and it will throw cannot install podfiles + */ + const po = x.hash.project.objects + po['PBXContainerItemProxy'] = po['PBXTargetDependency'] ?? {} + po['PBXTargetDependency'] = po['PBXTargetDependency'] ?? {} + + // Create a new target for the notification service extension + const newTargetBundleIdentifier = c.ios?.bundleIdentifier + '.' + EXTENSION_SERVICE_NAME + const nt = x.addTarget(EXTENSION_SERVICE_NAME, 'app_extension', EXTENSION_SERVICE_NAME, newTargetBundleIdentifier) + // Add necessary files to the new target + x.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', nt.uuid) + x.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', nt.uuid) + x.addBuildPhase(['NotificationService.swift'], 'PBXSourcesBuildPhase', 'Sources', nt.uuid) + + // Set the info of notification service extension target + const config = x.pbxXCBuildConfigurationSection() + for (const v in config) { + if (!!config[v].buildSettings && config[v].buildSettings.PRODUCT_NAME === `"${EXTENSION_SERVICE_NAME}"`) { + config[v].buildSettings = { + ...config[v].buildSettings, + TARGETED_DEVICE_FAMILY: TARGET_DEVICES, + IPHONEOS_DEPLOYMENT_TARGET: props.iosDeploymentTarget ?? DEFAULT_IOS_DEPLOYMENT_TARGET, + DEVELOPMENT_TEAM: props.appleDevTeamId, + CODE_SIGN_ENTITLEMENTS: `${EXTENSION_SERVICE_NAME}/${EXTENSION_SERVICE_NAME}.entitlements`, + CODE_SIGN_STYLE: 'Automatic', + SWIFT_VERSION: '5.0', + } + } else if (!!config[v].buildSettings && config[v].buildSettings.PRODUCT_NAME === `"${c.name}"`) { + config[v].buildSettings = { + ...config[v].buildSettings, + DEVELOPMENT_TEAM: props.appleDevTeamId, + } + } + } + + log(`Created Notification Service Extension (${newTargetBundleIdentifier})`) + return nc + }) +} + +/** + * Adds the Notifee target to the Expo app extensions configuration for EAS builds. + * Configures the target name, bundle identifier, and entitlements for the Notifee extension. + * + * @param {object} c - The Expo configuration object. + * @returns {object} - The updated Expo configuration object with Notifee target added to app extensions. + */ +const addNotifeeTargetToExpoAppExtensions: ConfigPlugin = (c) => { + const bundleIdentifier = c.ios?.bundleIdentifier + '.' + EXTENSION_SERVICE_NAME + + const expoAppExtension = { + targetName: EXTENSION_SERVICE_NAME, + bundleIdentifier, + entitlements: { + 'com.apple.security.application-groups': [ + `group.${c.ios?.bundleIdentifier}.notifee`, + `group.${c.ios?.bundleIdentifier}`, + ], + }, + } + + return { + ...c, + extra: { + ...c.extra, + eas: { + ...c.extra?.eas, + build: { + ...c.extra?.eas?.build, + experimental: { + ...c.extra?.eas?.build?.experimental, + ios: { + ...c.extra?.eas?.build?.experimental?.ios, + appExtensions: [...(c.extra?.eas?.build?.experimental?.ios?.appExtensions ?? []), expoAppExtension], + }, + }, + }, + }, + }, + } +} + +export default { + setAPSEnvironment, + addCommunicationNotificationsCapability, + addBackgroundModes, + addNotificationServiceFilesToProject, + addNotifeeToPodfile, + signAppAndNotificationServiceExtension, + createAndAddNotificationServiceExtensionTarget, + addNotifeeTargetToExpoAppExtensions, + addNotificationServiceGroup, +} diff --git a/expo-plugins/notification-service-ios/plugin/types.ts b/expo-plugins/notification-service-ios/plugin/types.ts new file mode 100644 index 0000000000..7c7d1c6cff --- /dev/null +++ b/expo-plugins/notification-service-ios/plugin/types.ts @@ -0,0 +1,63 @@ +/** + * Defines the possible types for Notifee Android icons. + */ +export type NotifeeAndroidIconType = 'large' | 'small' + +/** + * Defines the possible environments for Apple Push Notification Service (APNs). + */ +export type APSEnvironmentMode = 'production' | 'development' + +/** + * Describes the properties required for configuring Notifee-Expo-Plugin in an Expo project. + */ +export type NotifeeExpoPluginProps = { + /** + * Sets the APS Environment Entitlement. Determines whether to use the development or production + * Apple Push Notification service (APNs). + */ + apsEnvMode: APSEnvironmentMode + + /** + * Sets the deployment target of the notification service extension for iOS. + * This should match the deployment target of the main app. + */ + iosDeploymentTarget: string + + /** + * Specifies the background modes to enable for the app. + * If not provided, the default value will be: ["remote-notification"]. + * On the other hand, an empty array [] will signal to the plugin to skip the backgroundModes step completly. + * See possible values here: https://developer.apple.com/documentation/bundleresources/information_property_list/uibackgroundmodes + */ + backgroundModes?: string[] + + /** + * Enables communication notifications, which adds the necessary configurations + * for communication notifications as mentioned in https://github.com/invertase/notifee/pull/526. + */ + enableCommunicationNotifications?: boolean + + /** + * Automatically signs the app and the notification service extension targets with the provided Apple developer team ID. + */ + appleDevTeamId?: string + + /** + * Specifies the path to a custom notification service file, which should already include + * the necessary configurations for Notifee along with any additional customizations. + */ + customNotificationServiceFilePath?: string + + /** + * Specifies the app group identifier to use for sharing data between the main app and the notification service extension. + * If not provided, defaults to the main app's bundle identifier app group (group.{bundleIdentifier}). + */ + appGroupIdentifier?: string + + /** + * Specifies additional app groups that the notification service extension should have access to. + * These will be added to the entitlements in addition to the default notifee app group. + */ + additionalAppGroups?: string[] +} diff --git a/expo-plugins/notification-service-ios/plugin/utils.ts b/expo-plugins/notification-service-ios/plugin/utils.ts new file mode 100644 index 0000000000..3ee68a921d --- /dev/null +++ b/expo-plugins/notification-service-ios/plugin/utils.ts @@ -0,0 +1,67 @@ +import { NotifeeExpoPluginProps } from './types' + +const errorPrefix = 'expo-plugins/notification-service-ios:' + +/** + * Throws an error prefixed with the package name. + * + * @param {string} message - The error message. + * @throws {Error} Always throws an error. + */ +export const throwError = (message: string) => { + throw new Error(errorPrefix + message) +} + +/** + * Validates the properties passed to the Notifee Expo plugin. + * + * @param {NotifeeExpoPluginProps} props - The properties to validate. + * @throws {Error} If any validation check fails. + */ +export const validateProps = (props: NotifeeExpoPluginProps) => { + if (!props) { + throwError("You need to pass options to this plugin! The props 'apsEnvMode' & 'iosDeploymentTarget' are required!") + } + + if (typeof props.iosDeploymentTarget !== 'string') { + throwError("'iosDeploymentTarget' needs to be a string!") + } + + if (typeof props.apsEnvMode !== 'string') { + throwError("'apsEnvMode' needs to be a string!") + } + + if (props.appleDevTeamId && typeof props.appleDevTeamId !== 'string') { + throwError("'appleDevTeamId' needs to be a string!") + } + + if (props.enableCommunicationNotifications && typeof props.enableCommunicationNotifications !== 'boolean') { + throwError("'enableCommunicationNotifications' needs to be a boolean!") + } + + if (props.customNotificationServiceFilePath && typeof props.customNotificationServiceFilePath !== 'string') { + throwError("'customNotificationServiceFilePath' needs to be a string!") + } + + if (props.backgroundModes && !Array.isArray(props.backgroundModes)) { + throwError("'backgroundModes' needs to be an array!") + } +} + +/** + * Logs a message to the console with the package name prefixed. + * + * @param {string} message - The message to log. + */ +export const log = (message: string) => { + console.log(`${errorPrefix}: ` + message) +} + +/** + * Logs an error message to the console with the package name prefixed. + * + * @param {string} message - The error message to log. + */ +export const logError = (message: string) => { + console.error(`${errorPrefix}: ` + message) +} diff --git a/expo-plugins/notification-service-ios/plugin/withNotifee.ts b/expo-plugins/notification-service-ios/plugin/withNotifee.ts new file mode 100644 index 0000000000..5cb55b44f3 --- /dev/null +++ b/expo-plugins/notification-service-ios/plugin/withNotifee.ts @@ -0,0 +1,31 @@ +import { ConfigPlugin } from '@expo/config-plugins' +import { validateProps } from './utils' +import { NotifeeExpoPluginProps } from './types' +import NotifeeIos from './ios' + +/** + * Configures Notifee settings for both Android and iOS platforms in an Expo project. + * + * @param {object} c - The Expo configuration object. + * @param {NotifeeExpoPluginProps} props - The properties required for configuring Notifee-Expo-Plugin. + * + * @returns {object} - The updated Expo configuration object. + */ +const withNotifee: ConfigPlugin = (c, props) => { + validateProps(props) + + /** iOS Configuration */ + c = NotifeeIos.setAPSEnvironment(c, props) + c = NotifeeIos.addBackgroundModes(c, props) + c = NotifeeIos.addCommunicationNotificationsCapability(c, props) + c = NotifeeIos.addNotificationServiceGroup(c, props) + c = NotifeeIos.addNotifeeToPodfile(c, props) + c = NotifeeIos.addNotificationServiceFilesToProject(c, props) + c = NotifeeIos.addNotifeeTargetToExpoAppExtensions(c, props) + c = NotifeeIos.createAndAddNotificationServiceExtensionTarget(c, props) + c = NotifeeIos.signAppAndNotificationServiceExtension(c, props) + + return c +} + +export default withNotifee diff --git a/expo-plugins/notification-service-ios/tsconfig.json b/expo-plugins/notification-service-ios/tsconfig.json new file mode 100644 index 0000000000..8777684f30 --- /dev/null +++ b/expo-plugins/notification-service-ios/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "expo-module-scripts/tsconfig.plugin", + "compilerOptions": { + "rootDir": "./plugin", + "outDir": "./dist/plugin", + "declaration": true + }, + "include": ["./plugin/**/*"], + "exclude": ["**/__tests__/*", "**/__mocks__/*"] +} diff --git a/package.json b/package.json index 06e8fdcb27..daea31179b 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "private": true, "name": "@safe-global/safe-wallet", - "version": "1.0.0", + "version": "1.0.1", "workspaces": [ + "expo-plugins/*", "apps/*", "config/*", "packages/*" @@ -12,27 +13,41 @@ "test": "yarn workspaces foreach --all -pt run test", "eslint": "yarn workspaces foreach --all -pt run eslint", "prettier": "prettier --check \"**/*.{js,jsx,ts,tsx,json,md}\"", - "prettier:fix": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"" + "prettier:fix": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"", + "prepare": "husky" }, "resolutions": { - "@safe-global/safe-core-sdk-types/ethers": "6.13.4", - "@safe-global/protocol-kit/ethers": "6.13.4", - "@safe-global/api-kit/ethers": "6.13.4", - "@gnosis.pm/zodiac/ethers": "6.13.4", + "@safe-global/safe-core-sdk-types/ethers": "6.14.3", + "@safe-global/protocol-kit/ethers": "6.14.3", + "@safe-global/api-kit/ethers": "6.14.3", + "@ledgerhq/context-module/ethers": "6.14.3", + "@gnosis.pm/zodiac/ethers": "6.14.3", + "@ledgerhq/device-signer-kit-ethereum/ethers": "6.14.3", "@cowprotocol/events": "1.3.0", "@ethersproject/signing-key/elliptic": "^6.6.1", - "@safe-global/protocol-kit@npm:^5.1.1": "patch:@safe-global/protocol-kit@npm%3A4.1.5#~/.yarn/patches/@safe-global-protocol-kit-npm-4.1.5-15df80b7bc.patch", - "@safe-global/safe-deployments@npm:^1.37.28": "patch:@safe-global/safe-deployments@npm%3A1.37.28#~/.yarn/patches/@safe-global-safe-deployments-npm-1.37.28-060d6063bb.patch", - "@safe-global/safe-deployments@npm:^1.37.22": "patch:@safe-global/safe-deployments@npm%3A1.37.28#~/.yarn/patches/@safe-global-safe-deployments-npm-1.37.28-060d6063bb.patch" + "stylus": "github:stylus/stylus#0.64.0", + "@safe-global/safe-deployments@npm:^1.37.40": "patch:@safe-global/safe-deployments@npm%3A1.37.40#~/.yarn/patches/@safe-global-safe-deployments-npm-1.37.40-a424a8f650.patch", + "@safe-global/safe-deployments@npm:^1.37.35": "patch:@safe-global/safe-deployments@npm%3A1.37.40#~/.yarn/patches/@safe-global-safe-deployments-npm-1.37.40-a424a8f650.patch", + "@safe-global/safe-modules-deployments@npm:^2.2.12": "patch:@safe-global/safe-modules-deployments@npm%3A2.2.12#~/.yarn/patches/@safe-global-safe-modules-deployments-npm-2.2.12-c30bb15dee.patch", + "@safe-global/safe-modules-deployments@npm:^2.2.10": "patch:@safe-global/safe-modules-deployments@npm%3A2.2.12#~/.yarn/patches/@safe-global-safe-modules-deployments-npm-2.2.12-c30bb15dee.patch" }, "devDependencies": { "husky": "^9.1.6", "lint-staged": "^15.2.10", - "msw": "^2.7.0", + "msw": "^2.7.3", "prettier": "^3.4.2" }, - "packageManager": "yarn@4.5.3", + "dependenciesMeta": { + "cypress": { + "built": true + }, + "next": { + "built": true + } + }, + "packageManager": "yarn@4.6.0", "dependencies": { + "@yarnpkg/types": "^4.0.1", "react-markdown": "9.0.1" } } diff --git a/packages/store/package.json b/packages/store/package.json index 9554f8bcde..36ad8bb245 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -15,6 +15,7 @@ "jest": "^29.7.0" }, "dependencies": { + "redux-persist": "^6.0.0", "ts-node": "^10.9.2" } } diff --git a/packages/store/scripts/api-schema/schema.json b/packages/store/scripts/api-schema/schema.json index be46055982..fabbb52fd2 100644 --- a/packages/store/scripts/api-schema/schema.json +++ b/packages/store/scripts/api-schema/schema.json @@ -574,6 +574,20 @@ ] } }, + "/v1/auth/logout": { + "post": { + "operationId": "authLogoutV1", + "parameters": [], + "responses": { + "200": { + "description": "Empty response body. Cookie value is removed and set to expire." + } + }, + "tags": [ + "auth" + ] + } + }, "/v1/chains/{chainId}/safes/{safeAddress}/balances/{fiatCode}": { "get": { "operationId": "balancesGetBalancesV1", @@ -1840,6 +1854,35 @@ ] } }, + "/v2/notifications/subscriptions": { + "delete": { + "description": "Delete all subscriptions of a Safe on a device. This will delete all subscriptions of a Safe on a device for all chains passed in the request body.", + "operationId": "notificationsDeleteAllSubscriptionsV2", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteAllSubscriptionsDto" + } + } + } + }, + "responses": { + "404": { + "description": "No subscription was found" + }, + "422": { + "description": "The request body is invalid" + } + }, + "summary": "Delete all subscriptions of a device", + "tags": [ + "notifications" + ] + } + }, "/v2/chains/{chainId}/notifications/devices/{deviceUuid}": { "delete": { "operationId": "notificationsDeleteDeviceV2", @@ -2037,6 +2080,7 @@ }, "/v1/register/notifications": { "post": { + "deprecated": true, "operationId": "notificationsRegisterDeviceV1", "parameters": [], "requestBody": { @@ -2054,6 +2098,7 @@ "description": "" } }, + "summary": "", "tags": [ "notifications" ] @@ -2061,6 +2106,7 @@ }, "/v1/chains/{chainId}/notifications/devices/{uuid}": { "delete": { + "deprecated": true, "operationId": "notificationsUnregisterDeviceV1", "parameters": [ { @@ -2085,6 +2131,7 @@ "description": "" } }, + "summary": "", "tags": [ "notifications" ] @@ -2092,6 +2139,7 @@ }, "/v1/chains/{chainId}/notifications/devices/{uuid}/safes/{safeAddress}": { "delete": { + "deprecated": true, "operationId": "notificationsUnregisterSafeV1", "parameters": [ { @@ -2124,162 +2172,130 @@ "description": "" } }, + "summary": "", "tags": [ "notifications" ] } }, - "/v1/chains/{chainId}/owners/{ownerAddress}/safes": { + "/v1/users": { "get": { - "operationId": "ownersGetSafesByOwnerV1", - "parameters": [ - { - "name": "chainId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "ownerAddress", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], + "operationId": "usersGetWithWalletsV1", + "parameters": [], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SafeList" + "$ref": "#/components/schemas/UserWithWallets" } } } + }, + "401": { + "description": "Signer address not provided" + }, + "404": { + "description": "User (wallet) not found" } }, "tags": [ - "owners" + "users" ] - } - }, - "/v1/owners/{ownerAddress}/safes": { - "get": { - "operationId": "ownersGetAllSafesByOwnerV1", - "parameters": [ - { - "name": "ownerAddress", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], + }, + "delete": { + "operationId": "usersDeleteV1", + "parameters": [], "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SafeList" - } - } - } + "description": "User deleted" + }, + "401": { + "description": "Signer address not provided" + }, + "404": { + "description": "User (wallet) not found" } }, "tags": [ - "owners" + "users" ] } }, - "/v2/owners/{ownerAddress}/safes": { - "get": { - "operationId": "ownersGetAllSafesByOwnerV2", - "parameters": [ - { - "name": "ownerAddress", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], + "/v1/users/wallet": { + "post": { + "operationId": "usersCreateWithWalletV1", + "parameters": [], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - } + "$ref": "#/components/schemas/CreatedUserWithWallet" } } } + }, + "401": { + "description": "Signer address not provided" + }, + "409": { + "description": "Wallet already exists" } }, "tags": [ - "owners" + "users" ] } }, - "/v1/chains/{chainId}/relay": { + "/v1/users/wallet/add": { "post": { - "operationId": "relayRelayV1", - "parameters": [ - { - "name": "chainId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], + "operationId": "usersAddWalletToUserV1", + "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RelayDto" + "$ref": "#/components/schemas/SiweDto" } } } }, "responses": { - "201": { - "description": "" + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WalletAddedToUser" + } + } + } + }, + "401": { + "description": "Signer address not provided OR invalid message/signature" + }, + "404": { + "description": "User not found" + }, + "409": { + "description": "Wallet already exists" } }, "tags": [ - "relay" + "users" ] } }, - "/v1/chains/{chainId}/relay/{safeAddress}": { - "get": { - "operationId": "relayGetRelaysRemainingV1", + "/v1/users/wallet/{walletAddress}": { + "delete": { + "operationId": "usersDeleteWalletFromUserV1", "parameters": [ { - "name": "chainId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "safeAddress", + "name": "walletAddress", "required": true, "in": "path", "schema": { @@ -2289,115 +2305,126 @@ ], "responses": { "200": { - "description": "" + "description": "Wallet removed from user and deleted" + }, + "401": { + "description": "Signer address not provided" + }, + "404": { + "description": "User OR provided wallet not found" + }, + "409": { + "description": "Cannot remove the current wallet" } }, "tags": [ - "relay" + "users" ] } }, - "/v1/chains/{chainId}/safe-apps": { + "/v1/spaces/{spaceId}/address-book": { "get": { - "operationId": "safeAppsGetSafeAppsV1", + "operationId": "addressBooksGetAddressBookItemsV1", "parameters": [ { - "name": "chainId", + "name": "spaceId", "required": true, "in": "path", "schema": { - "type": "string" - } - }, - { - "name": "clientUrl", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "url", - "required": false, - "in": "query", - "schema": { - "type": "string" + "type": "number" } } ], "responses": { "200": { - "description": "", + "description": "Address Book Items found", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SafeApp" - } + "$ref": "#/components/schemas/SpaceAddressBookDto" } } } + }, + "401": { + "description": "Signer address not provided" + }, + "403": { + "description": "Signer not authorized." + }, + "404": { + "description": "User, member or space not found" } }, "tags": [ - "safe-apps" + "spaces" ] - } - }, - "/v1/chains/{chainId}/safes/{safeAddress}": { - "get": { - "operationId": "safesGetSafeV1", + }, + "put": { + "operationId": "addressBooksUpsertAddressBookItemsV1", "parameters": [ { - "name": "chainId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "safeAddress", + "name": "spaceId", "required": true, "in": "path", "schema": { - "type": "string" + "type": "number" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertAddressBookItemsDto" + } + } + } + }, "responses": { "200": { - "description": "", + "description": "Address Book updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SafeState" + "$ref": "#/components/schemas/SpaceAddressBookDto" } } } + }, + "400": { + "description": "Address book items limit exceeded." + }, + "401": { + "description": "Signer address not provided" + }, + "403": { + "description": "Signer not authorized." + }, + "404": { + "description": "User, member or space not found" } }, "tags": [ - "safes" + "spaces" ] } }, - "/v1/chains/{chainId}/safes/{safeAddress}/nonces": { - "get": { - "operationId": "safesGetNoncesV1", + "/v1/spaces/{spaceId}/address-book/{address}": { + "delete": { + "operationId": "addressBooksDeleteByAddressV1", "parameters": [ { - "name": "chainId", + "name": "spaceId", "required": true, "in": "path", "schema": { - "type": "string" + "type": "number" } }, { - "name": "safeAddress", + "name": "address", "required": true, "in": "path", "schema": { @@ -2407,172 +2434,254 @@ ], "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SafeNonces" - } - } - } + "description": "Address book item deleted" + }, + "403": { + "description": "Signer address not present or not authorized" + }, + "404": { + "description": "User, member or Space not found" } }, "tags": [ - "safes" + "spaces" ] } }, - "/v1/safes": { - "get": { - "operationId": "safesGetSafeOverviewV1", - "parameters": [ - { - "name": "currency", - "required": true, - "in": "query", - "schema": { - "type": "string" + "/v1/spaces": { + "post": { + "operationId": "spacesCreateV1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSpaceDto" + } } - }, - { - "name": "safes", - "required": true, - "in": "query", - "schema": { - "type": "string" + } + }, + "responses": { + "200": { + "description": "Space created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSpaceResponse" + } + } } }, - { - "name": "trusted", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } + "401": { + "description": "Signer address not provided" }, - { - "name": "exclude_spam", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } + "403": { + "description": "Forbidden resource" }, - { - "name": "wallet_address", - "required": false, - "in": "query", - "schema": { - "type": "string" - } + "404": { + "description": "User not found." } - ], + }, + "tags": [ + "spaces" + ] + }, + "get": { + "operationId": "spacesGetV1", + "parameters": [], "responses": { "200": { - "description": "", + "description": "Spaces found", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/SafeOverview" + "$ref": "#/components/schemas/GetSpaceResponse" } } } } + }, + "401": { + "description": "Signer address not provided" + }, + "403": { + "description": "Forbidden resource" + }, + "404": { + "description": "User not found." } }, "tags": [ - "safes" + "spaces" ] } }, - "/v1/targeted-messaging/outreaches/{outreachId}/chains/{chainId}/safes/{safeAddress}/signers/{signerAddress}/submissions": { + "/v1/spaces/create-with-user": { + "post": { + "operationId": "spacesCreateWithUserV1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSpaceDto" + } + } + } + }, + "responses": { + "200": { + "description": "Space created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSpaceResponse" + } + } + } + }, + "401": { + "description": "Signer address not provided" + }, + "403": { + "description": "Forbidden resource" + } + }, + "tags": [ + "spaces" + ] + } + }, + "/v1/spaces/{id}": { "get": { - "operationId": "targetedMessagingGetSubmissionV1", + "operationId": "spacesGetOneV1", "parameters": [ { - "name": "outreachId", + "name": "id", "required": true, "in": "path", "schema": { "type": "number" } - }, - { - "name": "chainId", - "required": true, - "in": "path", - "schema": { - "type": "string" + } + ], + "responses": { + "200": { + "description": "Space found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSpaceResponse" + } + } } }, - { - "name": "safeAddress", - "required": true, - "in": "path", - "schema": { - "type": "string" - } + "401": { + "description": "Signer address not provided" + }, + "403": { + "description": "Forbidden resource" }, + "404": { + "description": "Space not found. OR User not found." + } + }, + "tags": [ + "spaces" + ] + }, + "patch": { + "operationId": "spacesUpdateV1", + "parameters": [ { - "name": "signerAddress", + "name": "id", "required": true, "in": "path", "schema": { - "type": "string" + "type": "number" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSpaceDto" + } + } + } + }, "responses": { "200": { - "description": "", + "description": "Space updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Submission" + "$ref": "#/components/schemas/UpdateSpaceResponse" } } } + }, + "401": { + "description": "Signer address not provided OR User is unauthorized" + }, + "403": { + "description": "Forbidden resource" + }, + "404": { + "description": "User not found." } }, "tags": [ - "targeted-messaging" + "spaces" ] }, - "post": { - "operationId": "targetedMessagingCreateSubmissionV1", + "delete": { + "operationId": "spacesDeleteV1", "parameters": [ { - "name": "outreachId", + "name": "id", "required": true, "in": "path", "schema": { "type": "number" } + } + ], + "responses": { + "204": { + "description": "Spaces deleted" }, - { - "name": "chainId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } + "401": { + "description": "Signer address not provided OR User is unauthorized" }, - { - "name": "safeAddress", - "required": true, - "in": "path", - "schema": { - "type": "string" - } + "403": { + "description": "Forbidden resource" }, + "404": { + "description": "User not found." + } + }, + "tags": [ + "spaces" + ] + } + }, + "/v1/spaces/{spaceId}/safes": { + "post": { + "operationId": "spaceSafesCreateV1", + "parameters": [ { - "name": "signerAddress", + "name": "spaceId", "required": true, "in": "path", "schema": { - "type": "string" + "type": "number" } } ], @@ -2581,178 +2690,313 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateSubmissionDto" + "$ref": "#/components/schemas/CreateSpaceSafesDto" } } } }, "responses": { "201": { - "description": "", + "description": "Safes created successfully" + }, + "401": { + "description": "User unauthorize OR signer address not provided" + }, + "404": { + "description": "User not found." + } + }, + "tags": [ + "spaces" + ] + }, + "get": { + "operationId": "spaceSafesGetV1", + "parameters": [ + { + "name": "spaceId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "Safes fetched successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Submission" + "$ref": "#/components/schemas/GetSpaceSafeResponse" } } } + }, + "401": { + "description": "User unauthorized OR signer address not provided" + }, + "404": { + "description": "User not found." } }, "tags": [ - "targeted-messaging" + "spaces" ] - } - }, - "/v1/chains/{chainId}/transactions/{id}": { - "get": { - "operationId": "transactionsGetTransactionByIdV1", + }, + "delete": { + "operationId": "spaceSafesDeleteV1", "parameters": [ { - "name": "chainId", + "name": "spaceId", "required": true, "in": "path", "schema": { - "type": "string" + "type": "number" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteSpaceSafesDto" + } + } + } + }, + "responses": { + "204": { + "description": "Safes deleted successfully" + }, + "401": { + "description": "User unauthorized OR signer address not provided" }, + "404": { + "description": "Space has no Safes OR user not found." + } + }, + "tags": [ + "spaces" + ] + } + }, + "/v1/spaces/{spaceId}/members/invite": { + "post": { + "operationId": "membersInviteUserV1", + "parameters": [ { - "name": "id", + "name": "spaceId", "required": true, "in": "path", "schema": { - "type": "string" + "type": "number" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InviteUsersDto" + } + } + } + }, "responses": { "200": { - "description": "", + "description": "Users invited", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TransactionDetails" + "type": "array", + "items": { + "$ref": "#/components/schemas/Invitation" + } } } } + }, + "401": { + "description": "User not admin OR signer address not provided OR member is not active" + }, + "403": { + "description": "User not authorized" + }, + "409": { + "description": "Too many invites" } }, "tags": [ - "transactions" + "spaces" ] } }, - "/v1/chains/{chainId}/safes/{safeAddress}/multisig-transactions": { - "get": { - "operationId": "transactionsGetMultisigTransactionsV1", + "/v1/spaces/{spaceId}/members/accept": { + "post": { + "operationId": "membersAcceptInviteV1", "parameters": [ { - "name": "chainId", + "name": "spaceId", "required": true, "in": "path", "schema": { - "type": "string" - } - }, - { - "name": "safeAddress", - "required": true, - "in": "path", - "schema": { - "type": "string" + "type": "number" } - }, - { - "name": "execution_date__gte", - "required": false, - "in": "query", - "schema": { - "type": "string" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AcceptInviteDto" + } } + } + }, + "responses": { + "200": { + "description": "Invite accepted" }, - { - "name": "execution_date__lte", - "required": false, - "in": "query", - "schema": { - "type": "string" - } + "403": { + "description": "Signer not authorized" }, - { - "name": "to", - "required": false, - "in": "query", - "schema": { - "type": "string" - } + "404": { + "description": "Signer, space or membership not found" }, + "409": { + "description": "User invite not pending" + } + }, + "tags": [ + "spaces" + ] + } + }, + "/v1/spaces/{spaceId}/members/decline": { + "post": { + "operationId": "membersDeclineInviteV1", + "parameters": [ { - "name": "value", - "required": false, - "in": "query", + "name": "spaceId", + "required": true, + "in": "path", "schema": { - "type": "string" + "type": "number" } + } + ], + "responses": { + "200": { + "description": "Invite declined" }, - { - "name": "nonce", - "required": false, - "in": "query", - "schema": { - "type": "string" - } + "403": { + "description": "Signer not authorized" }, - { - "name": "executed", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } + "404": { + "description": "Signer, space or membership not found" }, + "409": { + "description": "User invite not pending" + } + }, + "tags": [ + "spaces" + ] + } + }, + "/v1/spaces/{spaceId}/members": { + "get": { + "operationId": "membersGetUsersV1", + "parameters": [ { - "name": "cursor", - "required": false, - "in": "query", + "name": "spaceId", + "required": true, + "in": "path", "schema": { - "type": "string" + "type": "number" } } ], "responses": { "200": { - "description": "", + "description": "Space and members list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MultisigTransactionPage" + "$ref": "#/components/schemas/MembersDto" } } } + }, + "403": { + "description": "Signer not authorized" + }, + "404": { + "description": "Signer or space not found" } }, "tags": [ - "transactions" + "spaces" + ] + }, + "delete": { + "description": "Remove own membership from a space.", + "operationId": "membersSelfRemoveV1", + "parameters": [ + { + "name": "spaceId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "Membership deleted" + }, + "401": { + "description": "Signer address not provided" + }, + "403": { + "description": "Signer not authorized" + }, + "404": { + "description": "Signer or space not found" + }, + "409": { + "description": "Cannot remove last admin" + } + }, + "summary": "Leave a space", + "tags": [ + "spaces" ] } }, - "/v1/chains/{chainId}/transactions/{safeTxHash}": { - "delete": { - "operationId": "transactionsDeleteTransactionV1", + "/v1/spaces/{spaceId}/members/{userId}/role": { + "patch": { + "operationId": "membersUpdateRoleV1", "parameters": [ { - "name": "chainId", + "name": "spaceId", "required": true, "in": "path", "schema": { - "type": "string" + "type": "number" } }, { - "name": "safeTxHash", + "name": "userId", "required": true, "in": "path", "schema": { - "type": "string" + "type": "number" } } ], @@ -2761,69 +3005,133 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteTransactionDto" + "$ref": "#/components/schemas/UpdateRoleDto" } } } }, "responses": { "200": { - "description": "" + "description": "Role updated" + }, + "401": { + "description": "Signer not active or admin" + }, + "403": { + "description": "Signer not authorized" + }, + "404": { + "description": "Signer, space or signer/user-to-update membership not found" + }, + "409": { + "description": "Cannot remove last admin" } }, "tags": [ - "transactions" + "spaces" ] } }, - "/v1/chains/{chainId}/safes/{safeAddress}/module-transactions": { - "get": { - "operationId": "transactionsGetModuleTransactionsV1", + "/v1/spaces/{spaceId}/members/alias": { + "patch": { + "description": "Update the alias of the authenticated member in a space. Users can only update their own alias.", + "operationId": "membersUpdateAliasV1", "parameters": [ { - "name": "chainId", + "name": "spaceId", "required": true, "in": "path", "schema": { - "type": "string" + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMemberAliasDto" + } } + } + }, + "responses": { + "200": { + "description": "Alias updated" }, + "403": { + "description": "Signer not authorized" + }, + "404": { + "description": "Signer, space or member not found" + } + }, + "summary": "Update member alias", + "tags": [ + "spaces" + ] + } + }, + "/v1/spaces/{spaceId}/members/{userId}": { + "delete": { + "operationId": "membersRemoveUserV1", + "parameters": [ { - "name": "safeAddress", + "name": "spaceId", "required": true, "in": "path", "schema": { - "type": "string" + "type": "number" } }, { - "name": "to", - "required": false, - "in": "query", + "name": "userId", + "required": true, + "in": "path", "schema": { - "type": "string" + "type": "number" } + } + ], + "responses": { + "200": { + "description": "Membership deleted" }, - { - "name": "module", - "required": false, - "in": "query", - "schema": { - "type": "string" - } + "401": { + "description": "Signer not active or admin" + }, + "403": { + "description": "Signer not authorized" + }, + "404": { + "description": "Signer or space not found" }, + "409": { + "description": "Cannot remove last admin" + } + }, + "tags": [ + "spaces" + ] + } + }, + "/v1/chains/{chainId}/owners/{ownerAddress}/safes": { + "get": { + "operationId": "ownersGetSafesByOwnerV1", + "parameters": [ { - "name": "transaction_hash", - "required": false, - "in": "query", + "name": "chainId", + "required": true, + "in": "path", "schema": { "type": "string" } }, { - "name": "cursor", - "required": false, - "in": "query", + "name": "ownerAddress", + "required": true, + "in": "path", "schema": { "type": "string" } @@ -2835,31 +3143,24 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ModuleTransactionPage" + "$ref": "#/components/schemas/SafeList" } } } } }, "tags": [ - "transactions" + "owners" ] } }, - "/v1/chains/{chainId}/transactions/{safeTxHash}/confirmations": { - "post": { - "operationId": "transactionsAddConfirmationV1", + "/v1/owners/{ownerAddress}/safes": { + "get": { + "deprecated": true, + "operationId": "ownersGetAllSafesByOwnerV1", "parameters": [ { - "name": "chainId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "safeTxHash", + "name": "ownerAddress", "required": true, "in": "path", "schema": { @@ -2867,95 +3168,152 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AddConfirmationDto" - } - } - } - }, "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Transaction" + "$ref": "#/components/schemas/SafeList" } } } } }, + "summary": "Deprecated", "tags": [ - "transactions" + "owners" ] } }, - "/v1/chains/{chainId}/safes/{safeAddress}/incoming-transfers": { + "/v2/owners/{ownerAddress}/safes": { "get": { - "operationId": "transactionsGetIncomingTransfersV1", + "operationId": "ownersGetAllSafesByOwnerV2", "parameters": [ { - "name": "chainId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "safeAddress", + "name": "ownerAddress", "required": true, "in": "path", "schema": { "type": "string" } - }, - { - "name": "trusted", - "required": false, - "in": "query", - "schema": { - "type": "boolean" + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } } - }, + } + }, + "tags": [ + "owners" + ] + } + }, + "/v1/chains/{chainId}/relay": { + "post": { + "operationId": "relayRelayV1", + "parameters": [ { - "name": "execution_date__gte", - "required": false, - "in": "query", + "name": "chainId", + "required": true, + "in": "path", "schema": { "type": "string" } - }, + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelayDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Relay" + } + } + } + } + }, + "tags": [ + "relay" + ] + } + }, + "/v1/chains/{chainId}/relay/{safeAddress}": { + "get": { + "operationId": "relayGetRelaysRemainingV1", + "parameters": [ { - "name": "execution_date__lte", - "required": false, - "in": "query", + "name": "chainId", + "required": true, + "in": "path", "schema": { "type": "string" } }, { - "name": "to", - "required": false, - "in": "query", + "name": "safeAddress", + "required": true, + "in": "path", "schema": { "type": "string" } - }, + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelaysRemaining" + } + } + } + } + }, + "tags": [ + "relay" + ] + } + }, + "/v1/chains/{chainId}/safe-apps": { + "get": { + "operationId": "safeAppsGetSafeAppsV1", + "parameters": [ { - "name": "value", - "required": false, - "in": "query", + "name": "chainId", + "required": true, + "in": "path", "schema": { "type": "string" } }, { - "name": "token_address", + "name": "clientUrl", "required": false, "in": "query", "schema": { @@ -2963,7 +3321,7 @@ } }, { - "name": "cursor", + "name": "url", "required": false, "in": "query", "schema": { @@ -2977,20 +3335,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IncomingTransferPage" + "type": "array", + "items": { + "$ref": "#/components/schemas/SafeApp" + } } } } } }, "tags": [ - "transactions" + "safe-apps" ] } }, - "/v1/chains/{chainId}/transactions/{safeAddress}/preview": { - "post": { - "operationId": "transactionsPreviewTransactionV1", + "/v1/chains/{chainId}/safes/{safeAddress}": { + "get": { + "operationId": "safesGetSafeV1", "parameters": [ { "name": "chainId", @@ -3009,36 +3370,26 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PreviewTransactionDto" - } - } - } - }, "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TransactionPreview" + "$ref": "#/components/schemas/SafeState" } } } } }, "tags": [ - "transactions" + "safes" ] } }, - "/v1/chains/{chainId}/safes/{safeAddress}/transactions/queued": { + "/v1/chains/{chainId}/safes/{safeAddress}/nonces": { "get": { - "operationId": "transactionsGetTransactionQueueV1", + "operationId": "safesGetNoncesV1", "parameters": [ { "name": "chainId", @@ -3055,22 +3406,6 @@ "schema": { "type": "string" } - }, - { - "name": "trusted", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "cursor", - "required": false, - "in": "query", - "schema": { - "type": "string" - } } ], "responses": { @@ -3079,42 +3414,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueuedItemPage" + "$ref": "#/components/schemas/SafeNonces" } } } } }, "tags": [ - "transactions" + "safes" ] } }, - "/v1/chains/{chainId}/safes/{safeAddress}/transactions/history": { + "/v1/safes": { "get": { - "operationId": "transactionsGetTransactionsHistoryV1", + "operationId": "safesGetSafeOverviewV1", "parameters": [ { - "name": "chainId", + "name": "currency", "required": true, - "in": "path", + "in": "query", "schema": { "type": "string" } }, { - "name": "safeAddress", + "name": "safes", "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "timezone_offset", - "required": false, "in": "query", - "deprecated": true, "schema": { "type": "string" } @@ -3128,7 +3454,7 @@ } }, { - "name": "imitation", + "name": "exclude_spam", "required": false, "in": "query", "schema": { @@ -3136,15 +3462,7 @@ } }, { - "name": "timezone", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "cursor", + "name": "wallet_address", "required": false, "in": "query", "schema": { @@ -3158,21 +3476,32 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TransactionItemPage" + "type": "array", + "items": { + "$ref": "#/components/schemas/SafeOverview" + } } } } } }, "tags": [ - "transactions" + "safes" ] } }, - "/v1/chains/{chainId}/transactions/{safeAddress}/propose": { - "post": { - "operationId": "transactionsProposeTransactionV1", + "/v1/targeted-messaging/outreaches/{outreachId}/chains/{chainId}/safes/{safeAddress}": { + "get": { + "operationId": "targetedMessagingGetTargetedSafeV1", "parameters": [ + { + "name": "outreachId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, { "name": "chainId", "required": true, @@ -3190,37 +3519,38 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProposeTransactionDto" - } - } - } - }, "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Transaction" + "$ref": "#/components/schemas/TargetedSafe" } } } + }, + "404": { + "description": "Safe not targeted." } }, "tags": [ - "transactions" + "targeted-messaging" ] } }, - "/v1/chains/{chainId}/safes/{safeAddress}/transactions/creation": { + "/v1/targeted-messaging/outreaches/{outreachId}/chains/{chainId}/safes/{safeAddress}/signers/{signerAddress}/submissions": { "get": { - "operationId": "transactionsGetCreationTransactionV1", + "operationId": "targetedMessagingGetSubmissionV1", "parameters": [ + { + "name": "outreachId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, { "name": "chainId", "required": true, @@ -3236,6 +3566,14 @@ "schema": { "type": "string" } + }, + { + "name": "signerAddress", + "required": true, + "in": "path", + "schema": { + "type": "string" + } } ], "responses": { @@ -3244,7 +3582,107 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreationTransaction" + "$ref": "#/components/schemas/Submission" + } + } + } + } + }, + "tags": [ + "targeted-messaging" + ] + }, + "post": { + "operationId": "targetedMessagingCreateSubmissionV1", + "parameters": [ + { + "name": "outreachId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeAddress", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "signerAddress", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSubmissionDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Submission" + } + } + } + } + }, + "tags": [ + "targeted-messaging" + ] + } + }, + "/v1/chains/{chainId}/transactions/{id}": { + "get": { + "operationId": "transactionsGetTransactionByIdV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransactionDetails" } } } @@ -3254,694 +3692,2488 @@ "transactions" ] } - } - }, - "info": { - "title": "Safe Client Gateway", - "description": "", - "version": "main", - "contact": {} - }, - "tags": [], - "servers": [], - "components": { - "schemas": { - "About": { + }, + "/v1/chains/{chainId}/multisig-transactions/{safeTxHash}/raw": { + "get": { + "deprecated": true, + "operationId": "transactionsGetDomainMultisigTransactionBySafeTxHashV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeTxHash", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TXSMultisigTransaction" + } + } + } + } + }, + "summary": "Deprecated", + "tags": [ + "transactions" + ] + } + }, + "/v1/chains/{chainId}/safes/{safeAddress}/multisig-transactions/raw": { + "get": { + "deprecated": true, + "operationId": "transactionsGetDomainMultisigTransactionsV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeAddress", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "failed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "modified__lt", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "modified__gt", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "modified__lte", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "modified__gte", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "nonce__lt", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "nonce__gt", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "nonce__lte", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "nonce__gte", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "nonce", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "safe_tx_hash", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "value__lt", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "value__gt", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "value", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "executed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "has_confirmations", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "trusted", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "execution_date__gte", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "execution_date__lte", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "submission_date__gte", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "submission_date__lte", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "transaction_hash", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "ordering", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TXSMultisigTransactionPage" + } + } + } + } + }, + "summary": "Deprecated", + "tags": [ + "transactions" + ] + } + }, + "/v1/chains/{chainId}/safes/{safeAddress}/multisig-transactions": { + "get": { + "operationId": "transactionsGetMultisigTransactionsV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeAddress", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "execution_date__gte", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "execution_date__lte", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "value", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "nonce", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "executed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultisigTransactionPage" + } + } + } + } + }, + "tags": [ + "transactions" + ] + } + }, + "/v1/chains/{chainId}/transactions/{safeTxHash}": { + "delete": { + "operationId": "transactionsDeleteTransactionV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeTxHash", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTransactionDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "transactions" + ] + } + }, + "/v1/chains/{chainId}/safes/{safeAddress}/module-transactions": { + "get": { + "operationId": "transactionsGetModuleTransactionsV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeAddress", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "module", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "transaction_hash", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleTransactionPage" + } + } + } + } + }, + "tags": [ + "transactions" + ] + } + }, + "/v1/chains/{chainId}/transactions/{safeTxHash}/confirmations": { + "post": { + "operationId": "transactionsAddConfirmationV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeTxHash", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddConfirmationDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Transaction" + } + } + } + } + }, + "tags": [ + "transactions" + ] + } + }, + "/v1/chains/{chainId}/safes/{safeAddress}/incoming-transfers": { + "get": { + "operationId": "transactionsGetIncomingTransfersV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeAddress", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "trusted", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "execution_date__gte", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "execution_date__lte", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "value", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "token_address", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IncomingTransferPage" + } + } + } + } + }, + "tags": [ + "transactions" + ] + } + }, + "/v1/chains/{chainId}/transactions/{safeAddress}/preview": { + "post": { + "operationId": "transactionsPreviewTransactionV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeAddress", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewTransactionDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransactionPreview" + } + } + } + } + }, + "tags": [ + "transactions" + ] + } + }, + "/v1/chains/{chainId}/safes/{safeAddress}/transactions/queued": { + "get": { + "operationId": "transactionsGetTransactionQueueV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeAddress", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "trusted", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueuedItemPage" + } + } + } + } + }, + "tags": [ + "transactions" + ] + } + }, + "/v1/chains/{chainId}/safes/{safeAddress}/transactions/history": { + "get": { + "operationId": "transactionsGetTransactionsHistoryV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeAddress", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "timezone_offset", + "required": false, + "in": "query", + "deprecated": true, + "schema": { + "type": "string" + } + }, + { + "name": "trusted", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "imitation", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "timezone", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransactionItemPage" + } + } + } + } + }, + "tags": [ + "transactions" + ] + } + }, + "/v1/chains/{chainId}/transactions/{safeAddress}/propose": { + "post": { + "operationId": "transactionsProposeTransactionV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeAddress", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProposeTransactionDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Transaction" + } + } + } + } + }, + "tags": [ + "transactions" + ] + } + }, + "/v1/chains/{chainId}/safes/{safeAddress}/transactions/creation": { + "get": { + "operationId": "transactionsGetCreationTransactionV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeAddress", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreationTransaction" + } + } + } + } + }, + "tags": [ + "transactions" + ] + } + }, + "/v1/chains/{chainId}/safes/{safeAddress}/creation/raw": { + "get": { + "deprecated": true, + "operationId": "transactionsGetDomainCreationTransactionV1", + "parameters": [ + { + "name": "chainId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "safeAddress", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TXSCreationTransaction" + } + } + } + } + }, + "summary": "Deprecated", + "tags": [ + "transactions" + ] + } + } + }, + "info": { + "title": "Safe Client Gateway", + "description": "", + "version": "", + "contact": {} + }, + "tags": [], + "servers": [], + "components": { + "schemas": { + "About": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string", + "nullable": true + }, + "buildNumber": { + "type": "string", + "nullable": true + } + }, + "required": [ + "name" + ] + }, + "CreateAccountDto": { + "type": "object", + "properties": { + "address": { + "type": "string", + "minLength": 3, + "maxLength": 30 + }, + "name": { + "type": "string" + } + }, + "required": [ + "address", + "name" + ] + }, + "Account": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "groupId": { + "type": "string", + "nullable": true + }, + "address": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "address", + "name" + ] + }, + "AccountDataType": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "isActive": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "isActive" + ] + }, + "AccountDataSetting": { + "type": "object", + "properties": { + "dataTypeId": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "dataTypeId", + "enabled" + ] + }, + "UpsertAccountDataSettingDto": { + "type": "object", + "properties": { + "dataTypeId": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "dataTypeId", + "enabled" + ] + }, + "UpsertAccountDataSettingsDto": { + "type": "object", + "properties": { + "accountDataSettings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpsertAccountDataSettingDto" + } + } + }, + "required": [ + "accountDataSettings" + ] + }, + "AddressBookItem": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "address": { + "type": "string" + }, + "chainIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "address", + "chainIds" + ] + }, + "AddressBook": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "accountId": { + "type": "string" + }, + "chainId": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AddressBookItem" + } + } + }, + "required": [ + "id", + "accountId", + "chainId", + "data" + ] + }, + "CreateAddressBookItemDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 3, + "maxLength": 30 + }, + "address": { + "type": "string" + } + }, + "required": [ + "name", + "address" + ] + }, + "CounterfactualSafe": { + "type": "object", + "properties": { + "chainId": { + "type": "string" + }, + "creator": { + "type": "string" + }, + "fallbackHandler": { + "type": "string" + }, + "owners": { + "type": "array", + "items": { + "type": "string" + } + }, + "predictedAddress": { + "type": "string" + }, + "saltNonce": { + "type": "string" + }, + "singletonAddress": { + "type": "string" + }, + "threshold": { + "type": "number" + } + }, + "required": [ + "chainId", + "creator", + "fallbackHandler", + "owners", + "predictedAddress", + "saltNonce", + "singletonAddress", + "threshold" + ] + }, + "CreateCounterfactualSafeDto": { + "type": "object", + "properties": { + "chainId": { + "type": "string" + }, + "fallbackHandler": { + "type": "string" + }, + "owners": { + "type": "array", + "items": { + "type": "string" + } + }, + "predictedAddress": { + "type": "string" + }, + "saltNonce": { + "type": "string" + }, + "singletonAddress": { + "type": "string" + }, + "threshold": { + "type": "number" + } + }, + "required": [ + "chainId", + "fallbackHandler", + "owners", + "predictedAddress", + "saltNonce", + "singletonAddress", + "threshold" + ] + }, + "AuthNonce": { + "type": "object", + "properties": { + "nonce": { + "type": "string" + } + }, + "required": [ + "nonce" + ] + }, + "SiweDto": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "signature": { + "type": "string" + } + }, + "required": [ + "message", + "signature" + ] + }, + "NativeToken": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "decimals": { + "type": "number" + }, + "logoUri": { + "type": "string" + }, + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "NATIVE_TOKEN" + ] + } + }, + "required": [ + "address", + "decimals", + "logoUri", + "name", + "symbol", + "type" + ] + }, + "Erc20Token": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "decimals": { + "type": "number" + }, + "logoUri": { + "type": "string" + }, + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "ERC20" + ] + } + }, + "required": [ + "address", + "decimals", + "logoUri", + "name", + "symbol", + "type" + ] + }, + "Erc721Token": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "decimals": { + "type": "number" + }, + "logoUri": { + "type": "string" + }, + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "ERC721" + ] + } + }, + "required": [ + "address", + "decimals", + "logoUri", + "name", + "symbol", + "type" + ] + }, + "Balance": { + "type": "object", + "properties": { + "balance": { + "type": "string" + }, + "fiatBalance": { + "type": "string" + }, + "fiatConversion": { + "type": "string" + }, + "tokenInfo": { + "oneOf": [ + { + "$ref": "#/components/schemas/NativeToken" + }, + { + "$ref": "#/components/schemas/Erc20Token" + }, + { + "$ref": "#/components/schemas/Erc721Token" + } + ] + }, + "fiatBalance24hChange": { + "type": "string", + "nullable": true + } + }, + "required": [ + "balance", + "fiatBalance", + "fiatConversion", + "tokenInfo" + ] + }, + "Balances": { + "type": "object", + "properties": { + "fiatTotal": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/Balance" + } + ] + } + } + }, + "required": [ + "fiatTotal", + "items" + ] + }, + "GasPriceOracle": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "gasParameter": { + "type": "string" + }, + "gweiFactor": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "type", + "gasParameter", + "gweiFactor", + "uri" + ] + }, + "GasPriceFixed": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "weiValue": { + "type": "string" + } + }, + "required": [ + "type", + "weiValue" + ] + }, + "GasPriceFixedEIP1559": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "maxFeePerGas": { + "type": "string" + }, + "maxPriorityFeePerGas": { + "type": "string" + } + }, + "required": [ + "type", + "maxFeePerGas", + "maxPriorityFeePerGas" + ] + }, + "NativeCurrency": { "type": "object", "properties": { + "decimals": { + "type": "number" + }, + "logoUri": { + "type": "string" + }, "name": { "type": "string" }, - "version": { + "symbol": { + "type": "string" + } + }, + "required": [ + "decimals", + "logoUri", + "name", + "symbol" + ] + }, + "BlockExplorerUriTemplate": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "api": { + "type": "string" + }, + "txHash": { + "type": "string" + } + }, + "required": [ + "address", + "api", + "txHash" + ] + }, + "BalancesProvider": { + "type": "object", + "properties": { + "chainName": { + "type": "number", + "nullable": true + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ] + }, + "ContractAddresses": { + "type": "object", + "properties": { + "safeSingletonAddress": { "type": "string", "nullable": true }, - "buildNumber": { + "safeProxyFactoryAddress": { + "type": "string", + "nullable": true + }, + "multiSendAddress": { + "type": "string", + "nullable": true + }, + "multiSendCallOnlyAddress": { + "type": "string", + "nullable": true + }, + "fallbackHandlerAddress": { + "type": "string", + "nullable": true + }, + "signMessageLibAddress": { + "type": "string", + "nullable": true + }, + "createCallAddress": { + "type": "string", + "nullable": true + }, + "simulateTxAccessorAddress": { + "type": "string", + "nullable": true + }, + "safeWebAuthnSignerFactoryAddress": { "type": "string", "nullable": true } + } + }, + "RpcUri": { + "type": "object", + "properties": { + "authentication": { + "type": "string", + "enum": [ + "API_KEY_PATH", + "NO_AUTHENTICATION", + "UNKNOWN" + ] + }, + "value": { + "type": "string" + } }, "required": [ - "name" + "authentication", + "value" ] }, - "CreateAccountDto": { + "Theme": { "type": "object", "properties": { - "address": { + "backgroundColor": { "type": "string" }, - "name": { + "textColor": { "type": "string" } }, "required": [ - "address", - "name" + "backgroundColor", + "textColor" ] }, - "Account": { + "Chain": { "type": "object", "properties": { - "id": { + "chainId": { "type": "string" }, - "groupId": { + "chainName": { + "type": "string" + }, + "description": { + "type": "string" + }, + "chainLogoUri": { "type": "string", "nullable": true }, - "address": { + "l2": { + "type": "boolean" + }, + "isTestnet": { + "type": "boolean" + }, + "zk": { + "type": "boolean" + }, + "nativeCurrency": { + "$ref": "#/components/schemas/NativeCurrency" + }, + "transactionService": { "type": "string" }, - "name": { + "blockExplorerUriTemplate": { + "$ref": "#/components/schemas/BlockExplorerUriTemplate" + }, + "beaconChainExplorerUriTemplate": { + "type": "object" + }, + "disabledWallets": { + "type": "array", + "items": { + "type": "string" + } + }, + "ensRegistryAddress": { + "type": "string", + "nullable": true + }, + "balancesProvider": { + "$ref": "#/components/schemas/BalancesProvider" + }, + "contractAddresses": { + "$ref": "#/components/schemas/ContractAddresses" + }, + "features": { + "type": "array", + "items": { + "type": "string" + } + }, + "gasPrice": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/GasPriceOracle" + }, + { + "$ref": "#/components/schemas/GasPriceFixed" + }, + { + "$ref": "#/components/schemas/GasPriceFixedEIP1559" + } + ] + } + }, + "publicRpcUri": { + "$ref": "#/components/schemas/RpcUri" + }, + "rpcUri": { + "$ref": "#/components/schemas/RpcUri" + }, + "safeAppsRpcUri": { + "$ref": "#/components/schemas/RpcUri" + }, + "shortName": { "type": "string" + }, + "theme": { + "$ref": "#/components/schemas/Theme" + }, + "recommendedMasterCopyVersion": { + "type": "string", + "nullable": true } }, "required": [ - "id", - "address", - "name" + "chainId", + "chainName", + "description", + "l2", + "isTestnet", + "zk", + "nativeCurrency", + "transactionService", + "blockExplorerUriTemplate", + "beaconChainExplorerUriTemplate", + "disabledWallets", + "balancesProvider", + "contractAddresses", + "features", + "gasPrice", + "publicRpcUri", + "rpcUri", + "safeAppsRpcUri", + "shortName", + "theme" + ] + }, + "ChainPage": { + "type": "object", + "properties": { + "count": { + "type": "number", + "nullable": true + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Chain" + } + } + }, + "required": [ + "results" ] }, - "AccountDataType": { + "AboutChain": { "type": "object", "properties": { - "id": { + "transactionServiceBaseUri": { "type": "string" }, "name": { "type": "string" }, - "description": { - "type": "string", - "nullable": true + "version": { + "type": "string" }, - "isActive": { - "type": "boolean" + "buildNumber": { + "type": "string" } }, "required": [ - "id", + "transactionServiceBaseUri", "name", - "isActive" + "version", + "buildNumber" ] }, - "AccountDataSetting": { + "Backbone": { "type": "object", "properties": { - "dataTypeId": { + "api_version": { "type": "string" }, - "enabled": { + "headers": { + "type": "string", + "nullable": true + }, + "host": { + "type": "string" + }, + "name": { + "type": "string" + }, + "secure": { "type": "boolean" + }, + "settings": { + "type": "object", + "nullable": true + }, + "version": { + "type": "string" } }, "required": [ - "dataTypeId", - "enabled" + "api_version", + "host", + "name", + "secure", + "settings", + "version" ] }, - "UpsertAccountDataSettingDto": { + "MasterCopy": { "type": "object", "properties": { - "dataTypeId": { + "address": { "type": "string" }, - "enabled": { - "type": "boolean" + "version": { + "type": "string" } }, "required": [ - "dataTypeId", - "enabled" + "address", + "version" ] }, - "UpsertAccountDataSettingsDto": { + "IndexingStatus": { "type": "object", "properties": { - "accountDataSettings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UpsertAccountDataSettingDto" - } + "lastSync": { + "type": "number" + }, + "synced": { + "type": "boolean" } }, "required": [ - "accountDataSettings" + "lastSync", + "synced" ] }, - "AddressBookItem": { + "Collectible": { "type": "object", "properties": { - "id": { + "address": { "type": "string" }, - "name": { + "tokenName": { "type": "string" }, - "address": { + "tokenSymbol": { + "type": "string" + }, + "logoUri": { + "type": "string" + }, + "id": { "type": "string" + }, + "uri": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "imageUri": { + "type": "string", + "nullable": true + }, + "metadata": { + "type": "object", + "nullable": true } }, "required": [ - "id", - "name", - "address" + "address", + "tokenName", + "tokenSymbol", + "logoUri", + "id" ] }, - "AddressBook": { + "CollectiblePage": { "type": "object", "properties": { - "id": { - "type": "string" + "count": { + "type": "number", + "nullable": true }, - "accountId": { - "type": "string" + "next": { + "type": "string", + "nullable": true }, - "chainId": { - "type": "string" + "previous": { + "type": "string", + "nullable": true }, - "data": { + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/AddressBookItem" + "$ref": "#/components/schemas/Collectible" } } }, "required": [ - "id", - "accountId", - "chainId", - "data" + "results" ] }, - "CreateAddressBookItemDto": { + "ActivityMetadata": { "type": "object", "properties": { "name": { "type": "string" }, - "address": { + "description": { "type": "string" + }, + "maxPoints": { + "type": "number" } }, "required": [ "name", - "address" + "description", + "maxPoints" ] }, - "CounterfactualSafe": { + "Campaign": { "type": "object", "properties": { - "chainId": { + "resourceId": { "type": "string" }, - "creator": { + "name": { "type": "string" }, - "fallbackHandler": { + "description": { "type": "string" }, - "owners": { + "startDate": { + "type": "string" + }, + "endDate": { + "type": "string" + }, + "lastUpdated": { + "type": "string", + "nullable": true + }, + "activitiesMetadata": { + "nullable": true, "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/ActivityMetadata" } }, - "predictedAddress": { - "type": "string" + "rewardValue": { + "type": "string", + "nullable": true }, - "saltNonce": { - "type": "string" + "rewardText": { + "type": "string", + "nullable": true }, - "singletonAddress": { - "type": "string" + "iconUrl": { + "type": "string", + "nullable": true }, - "threshold": { - "type": "number" + "safeAppUrl": { + "type": "string", + "nullable": true + }, + "partnerUrl": { + "type": "string", + "nullable": true + }, + "isPromoted": { + "type": "boolean" } }, "required": [ - "chainId", - "creator", - "fallbackHandler", - "owners", - "predictedAddress", - "saltNonce", - "singletonAddress", - "threshold" + "resourceId", + "name", + "description", + "startDate", + "endDate", + "isPromoted" ] }, - "CreateCounterfactualSafeDto": { + "CampaignPage": { "type": "object", "properties": { - "chainId": { - "type": "string" + "count": { + "type": "number", + "nullable": true }, - "fallbackHandler": { - "type": "string" + "next": { + "type": "string", + "nullable": true }, - "owners": { + "previous": { + "type": "string", + "nullable": true + }, + "results": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/Campaign" } - }, - "predictedAddress": { + } + }, + "required": [ + "results" + ] + }, + "CampaignRank": { + "type": "object", + "properties": { + "holder": { "type": "string" }, - "saltNonce": { - "type": "string" + "position": { + "type": "number" }, - "singletonAddress": { - "type": "string" + "boost": { + "type": "number" }, - "threshold": { + "totalPoints": { + "type": "number" + }, + "totalBoostedPoints": { "type": "number" } }, "required": [ - "chainId", - "fallbackHandler", - "owners", - "predictedAddress", - "saltNonce", - "singletonAddress", - "threshold" + "holder", + "position", + "boost", + "totalPoints", + "totalBoostedPoints" ] }, - "AuthNonce": { + "CampaignRankPage": { "type": "object", "properties": { - "nonce": { - "type": "string" + "count": { + "type": "number", + "nullable": true + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignRank" + } } }, "required": [ - "nonce" + "results" ] }, - "SiweDto": { + "EligibilityRequest": { "type": "object", "properties": { - "message": { + "requestId": { "type": "string" }, - "signature": { + "sealedData": { "type": "string" } }, "required": [ - "message", - "signature" + "requestId", + "sealedData" ] }, - "Token": { + "Eligibility": { "type": "object", "properties": { - "address": { - "type": "string" - }, - "decimals": { - "type": "number" - }, - "logoUri": { - "type": "string" - }, - "name": { + "requestId": { "type": "string" }, - "symbol": { - "type": "string" + "isAllowed": { + "type": "boolean" }, - "type": { - "type": "string", - "enum": [ - "ERC721", - "ERC20", - "NATIVE_TOKEN", - "UNKNOWN" - ] + "isVpn": { + "type": "boolean" } }, "required": [ - "address", - "logoUri", - "name", - "symbol", - "type" + "requestId", + "isAllowed", + "isVpn" ] }, - "Balance": { + "LockingRank": { "type": "object", "properties": { - "balance": { + "holder": { "type": "string" }, - "fiatBalance": { + "position": { + "type": "number" + }, + "lockedAmount": { "type": "string" }, - "fiatConversion": { + "unlockedAmount": { "type": "string" }, - "tokenInfo": { - "$ref": "#/components/schemas/Token" + "withdrawnAmount": { + "type": "string" } }, "required": [ - "balance", - "fiatBalance", - "fiatConversion", - "tokenInfo" + "holder", + "position", + "lockedAmount", + "unlockedAmount", + "withdrawnAmount" ] }, - "Balances": { + "LockingRankPage": { "type": "object", "properties": { - "fiatTotal": { - "type": "string" + "count": { + "type": "number", + "nullable": true }, - "items": { + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/Balance" - } - ] + "$ref": "#/components/schemas/LockingRank" } } }, "required": [ - "fiatTotal", - "items" + "results" ] }, - "GasPriceOracle": { + "LockEventItem": { "type": "object", "properties": { - "type": { + "eventType": { + "type": "string", + "enum": [ + "LOCKED" + ] + }, + "executionDate": { "type": "string" }, - "gasParameter": { + "transactionHash": { "type": "string" }, - "gweiFactor": { + "holder": { "type": "string" }, - "uri": { + "amount": { + "type": "string" + }, + "logIndex": { "type": "string" } }, "required": [ - "type", - "gasParameter", - "gweiFactor", - "uri" + "eventType", + "executionDate", + "transactionHash", + "holder", + "amount", + "logIndex" ] }, - "GasPriceFixed": { + "UnlockEventItem": { "type": "object", "properties": { - "type": { + "eventType": { + "type": "string", + "enum": [ + "UNLOCKED" + ] + }, + "executionDate": { "type": "string" }, - "weiValue": { + "transactionHash": { + "type": "string" + }, + "holder": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "logIndex": { + "type": "string" + }, + "unlockIndex": { "type": "string" } }, "required": [ - "type", - "weiValue" + "eventType", + "executionDate", + "transactionHash", + "holder", + "amount", + "logIndex", + "unlockIndex" ] }, - "GasPriceFixedEIP1559": { + "WithdrawEventItem": { "type": "object", "properties": { - "type": { + "eventType": { + "type": "string", + "enum": [ + "WITHDRAWN" + ] + }, + "executionDate": { "type": "string" }, - "maxFeePerGas": { + "transactionHash": { "type": "string" }, - "maxPriorityFeePerGas": { + "holder": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "logIndex": { + "type": "string" + }, + "unlockIndex": { "type": "string" } }, "required": [ - "type", - "maxFeePerGas", - "maxPriorityFeePerGas" + "eventType", + "executionDate", + "transactionHash", + "holder", + "amount", + "logIndex", + "unlockIndex" ] }, - "NativeCurrency": { + "LockingEventPage": { "type": "object", "properties": { - "decimals": { - "type": "number" + "count": { + "type": "number", + "nullable": true }, - "logoUri": { - "type": "string" + "next": { + "type": "string", + "nullable": true }, - "name": { - "type": "string" + "previous": { + "type": "string", + "nullable": true }, - "symbol": { - "type": "string" + "results": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/LockEventItem" + }, + { + "$ref": "#/components/schemas/UnlockEventItem" + }, + { + "$ref": "#/components/schemas/WithdrawEventItem" + } + ] + } } }, "required": [ - "decimals", - "logoUri", - "name", - "symbol" + "results" ] }, - "BlockExplorerUriTemplate": { + "Contract": { "type": "object", "properties": { "address": { "type": "string" }, - "api": { + "name": { "type": "string" }, - "txHash": { + "displayName": { + "type": "string" + }, + "logoUri": { "type": "string" + }, + "contractAbi": { + "type": "object", + "nullable": true + }, + "trustedForDelegateCall": { + "type": "boolean" } }, "required": [ "address", - "api", - "txHash" + "name", + "displayName", + "logoUri", + "trustedForDelegateCall" ] }, - "BalancesProvider": { + "TransactionDataDto": { "type": "object", "properties": { - "chainName": { - "type": "number", - "nullable": true + "data": { + "type": "string", + "description": "Hexadecimal value" }, - "enabled": { - "type": "boolean" + "to": { + "type": "string", + "description": "The target Ethereum address" } }, "required": [ - "enabled" + "data" ] }, - "ContractAddresses": { + "BaseDataDecoded": { "type": "object", "properties": { - "safeSingletonAddress": { - "type": "string", - "nullable": true - }, - "safeProxyFactoryAddress": { - "type": "string", - "nullable": true - }, - "multiSendAddress": { - "type": "string", - "nullable": true - }, - "multiSendCallOnlyAddress": { - "type": "string", - "nullable": true - }, - "fallbackHandlerAddress": { - "type": "string", - "nullable": true - }, - "signMessageLibAddress": { - "type": "string", - "nullable": true - }, - "createCallAddress": { - "type": "string", - "nullable": true - }, - "simulateTxAccessorAddress": { - "type": "string", - "nullable": true + "method": { + "type": "string" }, - "safeWebAuthnSignerFactoryAddress": { - "type": "string", - "nullable": true + "parameters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DataDecodedParameter" + } } - } + }, + "required": [ + "method" + ] }, - "RpcUri": { + "MultiSend": { "type": "object", "properties": { - "authentication": { - "type": "string", + "operation": { + "type": "number", "enum": [ - "API_KEY_PATH", - "NO_AUTHENTICATION", - "UNKNOWN" + 0, + 1 ] }, "value": { "type": "string" + }, + "dataDecoded": { + "$ref": "#/components/schemas/BaseDataDecoded" + }, + "to": { + "type": "string" + }, + "data": { + "type": "object" } }, "required": [ - "authentication", - "value" + "operation", + "value", + "to" ] }, - "Theme": { + "DataDecodedParameter": { "type": "object", "properties": { - "backgroundColor": { + "name": { "type": "string" }, - "textColor": { + "type": { "type": "string" + }, + "value": { + "type": "object" + }, + "valueDecoded": { + "oneOf": [ + { + "$ref": "#/components/schemas/BaseDataDecoded" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/MultiSend" + } + }, + { + "type": "null" + } + ] } }, "required": [ - "backgroundColor", - "textColor" + "name", + "type", + "value" ] }, - "Chain": { + "DataDecoded": { "type": "object", "properties": { - "chainId": { - "type": "string" - }, - "chainName": { - "type": "string" - }, - "description": { - "type": "string" - }, - "chainLogoUri": { - "type": "string", - "nullable": true - }, - "l2": { - "type": "boolean" - }, - "isTestnet": { - "type": "boolean" - }, - "nativeCurrency": { - "$ref": "#/components/schemas/NativeCurrency" - }, - "transactionService": { + "method": { "type": "string" }, - "blockExplorerUriTemplate": { - "$ref": "#/components/schemas/BlockExplorerUriTemplate" - }, - "beaconChainExplorerUriTemplate": { - "type": "object" - }, - "disabledWallets": { - "type": "array", - "items": { - "type": "string" - } - }, - "ensRegistryAddress": { - "type": "string", - "nullable": true - }, - "balancesProvider": { - "$ref": "#/components/schemas/BalancesProvider" - }, - "contractAddresses": { - "$ref": "#/components/schemas/ContractAddresses" - }, - "features": { - "type": "array", - "items": { - "type": "string" - } - }, - "gasPrice": { + "parameters": { + "nullable": true, "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/GasPriceOracle" - }, - { - "$ref": "#/components/schemas/GasPriceFixed" - }, - { - "$ref": "#/components/schemas/GasPriceFixedEIP1559" - } - ] + "$ref": "#/components/schemas/DataDecodedParameter" } }, - "publicRpcUri": { - "$ref": "#/components/schemas/RpcUri" - }, - "rpcUri": { - "$ref": "#/components/schemas/RpcUri" - }, - "safeAppsRpcUri": { - "$ref": "#/components/schemas/RpcUri" - }, - "shortName": { - "type": "string" - }, - "theme": { - "$ref": "#/components/schemas/Theme" - }, - "recommendedMasterCopyVersion": { + "accuracy": { "type": "string", - "nullable": true + "enum": [ + "FULL_MATCH", + "PARTIAL_MATCH", + "ONLY_FUNCTION_MATCH", + "NO_MATCH", + "UNKNOWN" + ], + "default": "UNKNOWN" } }, "required": [ - "chainId", - "chainName", - "description", - "l2", - "isTestnet", - "nativeCurrency", - "transactionService", - "blockExplorerUriTemplate", - "beaconChainExplorerUriTemplate", - "disabledWallets", - "balancesProvider", - "contractAddresses", - "features", - "gasPrice", - "publicRpcUri", - "rpcUri", - "safeAppsRpcUri", - "shortName", - "theme" + "method" + ] + }, + "Delegate": { + "type": "object", + "properties": { + "safe": { + "type": "string", + "nullable": true + }, + "delegate": { + "type": "string" + }, + "delegator": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "delegate", + "delegator", + "label" ] }, - "ChainPage": { + "DelegatePage": { "type": "object", "properties": { "count": { @@ -3959,7 +6191,7 @@ "results": { "type": "array", "items": { - "$ref": "#/components/schemas/Chain" + "$ref": "#/components/schemas/Delegate" } } }, @@ -3967,390 +6199,527 @@ "results" ] }, - "AboutChain": { + "CreateDelegateDto": { "type": "object", "properties": { - "transactionServiceBaseUri": { + "safe": { + "type": "string", + "nullable": true + }, + "delegate": { "type": "string" }, - "name": { + "delegator": { "type": "string" }, - "version": { + "signature": { "type": "string" }, - "buildNumber": { + "label": { "type": "string" } }, "required": [ - "transactionServiceBaseUri", - "name", - "version", - "buildNumber" + "delegate", + "delegator", + "signature", + "label" ] }, - "Backbone": { + "DeleteDelegateDto": { "type": "object", "properties": { - "api_version": { + "delegate": { "type": "string" }, - "headers": { - "type": "string", - "nullable": true + "delegator": { + "type": "string" }, - "host": { + "signature": { + "type": "string" + } + }, + "required": [ + "delegate", + "delegator", + "signature" + ] + }, + "DeleteSafeDelegateDto": { + "type": "object", + "properties": { + "delegate": { "type": "string" }, - "name": { + "safe": { "type": "string" }, - "secure": { - "type": "boolean" + "signature": { + "type": "string" + } + }, + "required": [ + "delegate", + "safe", + "signature" + ] + }, + "DeleteDelegateV2Dto": { + "type": "object", + "properties": { + "delegator": { + "type": "string", + "nullable": true }, - "settings": { - "type": "object", + "safe": { + "type": "string", "nullable": true }, - "version": { + "signature": { "type": "string" } }, "required": [ - "api_version", - "host", - "name", - "secure", - "settings", - "version" + "signature" ] }, - "MasterCopy": { + "AddRecoveryModuleDto": { "type": "object", "properties": { - "address": { + "moduleAddress": { + "type": "string" + } + }, + "required": [ + "moduleAddress" + ] + }, + "GetEstimationDto": { + "type": "object", + "properties": { + "to": { "type": "string" }, - "version": { + "value": { "type": "string" + }, + "data": { + "type": "string", + "nullable": true + }, + "operation": { + "type": "number" } }, "required": [ - "address", - "version" + "to", + "value", + "operation" ] }, - "IndexingStatus": { + "EstimationResponse": { "type": "object", "properties": { - "lastSync": { + "currentNonce": { "type": "number" }, - "synced": { - "type": "boolean" + "recommendedNonce": { + "type": "number" + }, + "safeTxGas": { + "type": "string" } }, "required": [ - "lastSync", - "synced" + "currentNonce", + "recommendedNonce", + "safeTxGas" ] }, - "Collectible": { + "NotificationType": { + "type": "string", + "enum": [ + "CONFIRMATION_REQUEST", + "DELETED_MULTISIG_TRANSACTION", + "EXECUTED_MULTISIG_TRANSACTION", + "INCOMING_ETHER", + "INCOMING_TOKEN", + "MESSAGE_CONFIRMATION_REQUEST", + "MODULE_TRANSACTION" + ] + }, + "UpsertSubscriptionsSafesDto": { "type": "object", "properties": { - "address": { - "type": "string" - }, - "tokenName": { - "type": "string" - }, - "tokenSymbol": { + "chainId": { "type": "string" }, - "logoUri": { + "address": { "type": "string" }, - "id": { + "notificationTypes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationType" + } + } + }, + "required": [ + "chainId", + "address", + "notificationTypes" + ] + }, + "DeviceType": { + "type": "string", + "enum": [ + "ANDROID", + "IOS", + "WEB" + ] + }, + "UpsertSubscriptionsDto": { + "type": "object", + "properties": { + "cloudMessagingToken": { "type": "string" }, - "uri": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string", - "nullable": true + "safes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpsertSubscriptionsSafesDto" + } }, - "description": { - "type": "string", - "nullable": true + "deviceType": { + "allOf": [ + { + "$ref": "#/components/schemas/DeviceType" + } + ] }, - "imageUri": { + "deviceUuid": { "type": "string", "nullable": true - }, - "metadata": { - "type": "object", - "nullable": true } }, "required": [ - "address", - "tokenName", - "tokenSymbol", - "logoUri", - "id" + "cloudMessagingToken", + "safes", + "deviceType" ] }, - "CollectiblePage": { + "DeleteAllSubscriptionItemDto": { "type": "object", "properties": { - "count": { - "type": "number", - "nullable": true + "chainId": { + "type": "string" }, - "next": { - "type": "string", - "nullable": true + "deviceUuid": { + "type": "string" }, - "previous": { - "type": "string", - "nullable": true + "safeAddress": { + "type": "string" }, - "results": { + "signerAddress": { + "type": "string", + "nullable": true, + "description": "Optional signer address filter:\n• Omitted (undefined): Deletes subscriptions regardless of signer address\n• null: Deletes only subscriptions with no signer address\n• Valid address: Deletes only subscriptions with that specific signer address", + "example": "0x1234567890123456789012345678901234567890" + } + }, + "required": [ + "chainId", + "deviceUuid", + "safeAddress" + ] + }, + "DeleteAllSubscriptionsDto": { + "type": "object", + "properties": { + "subscriptions": { + "minItems": 1, + "description": "At least one subscription is required", "type": "array", "items": { - "$ref": "#/components/schemas/Collectible" + "$ref": "#/components/schemas/DeleteAllSubscriptionItemDto" } } }, "required": [ - "results" + "subscriptions" ] }, - "ActivityMetadata": { + "TypedDataParameter": { "type": "object", "properties": { "name": { "type": "string" }, - "description": { + "type": { "type": "string" - }, - "maxPoints": { - "type": "number" } }, "required": [ "name", - "description", - "maxPoints" + "type" ] }, - "Campaign": { + "TypedDataDomain": { "type": "object", "properties": { - "resourceId": { - "type": "string" + "chainId": { + "type": "number" }, "name": { "type": "string" }, - "description": { + "salt": { "type": "string" }, - "startDate": { + "verifyingContract": { "type": "string" }, - "endDate": { + "version": { "type": "string" + } + } + }, + "TypedData": { + "type": "object", + "properties": { + "domain": { + "$ref": "#/components/schemas/TypedDataDomain" }, - "lastUpdated": { - "type": "string", - "nullable": true + "primaryType": { + "type": "string" }, - "activitiesMetadata": { - "nullable": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/ActivityMetadata" + "types": { + "type": "object", + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/TypedDataParameter" + }, + "type": "array" } }, - "rewardValue": { - "type": "string", - "nullable": true - }, - "rewardText": { - "type": "string", - "nullable": true - }, - "iconUrl": { - "type": "string", - "nullable": true - }, - "safeAppUrl": { - "type": "string", - "nullable": true - }, - "partnerUrl": { - "type": "string", - "nullable": true - }, - "isPromoted": { - "type": "boolean" + "message": { + "type": "object", + "additionalProperties": true } }, "required": [ - "resourceId", - "name", - "description", - "startDate", - "endDate", - "isPromoted" + "domain", + "primaryType", + "types", + "message" ] }, - "CampaignPage": { + "AddressInfo": { "type": "object", "properties": { - "count": { - "type": "number", - "nullable": true + "value": { + "type": "string" }, - "next": { + "name": { "type": "string", "nullable": true }, - "previous": { + "logoUri": { "type": "string", "nullable": true + } + }, + "required": [ + "value" + ] + }, + "MessageConfirmation": { + "type": "object", + "properties": { + "owner": { + "$ref": "#/components/schemas/AddressInfo" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Campaign" - } + "signature": { + "type": "string" } }, "required": [ - "results" + "owner", + "signature" ] }, - "CampaignRank": { + "Message": { "type": "object", "properties": { - "holder": { + "messageHash": { "type": "string" }, - "position": { + "status": { + "type": "string", + "enum": [ + "NEEDS_CONFIRMATION", + "CONFIRMED" + ] + }, + "logoUri": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "message": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/TypedData" + } + ] + }, + "creationTimestamp": { "type": "number" }, - "boost": { + "modifiedTimestamp": { "type": "number" }, - "totalPoints": { + "confirmationsSubmitted": { "type": "number" }, - "totalBoostedPoints": { + "confirmationsRequired": { "type": "number" + }, + "proposedBy": { + "$ref": "#/components/schemas/AddressInfo" + }, + "confirmations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageConfirmation" + } + }, + "preparedSignature": { + "type": "string", + "nullable": true + }, + "origin": { + "type": "string", + "nullable": true } }, "required": [ - "holder", - "position", - "boost", - "totalPoints", - "totalBoostedPoints" + "messageHash", + "status", + "message", + "creationTimestamp", + "modifiedTimestamp", + "confirmationsSubmitted", + "confirmationsRequired", + "proposedBy", + "confirmations" ] }, - "CampaignRankPage": { + "MessageItem": { "type": "object", "properties": { - "count": { - "type": "number", - "nullable": true + "messageHash": { + "type": "string" }, - "next": { + "status": { + "type": "string", + "enum": [ + "NEEDS_CONFIRMATION", + "CONFIRMED" + ] + }, + "logoUri": { "type": "string", "nullable": true }, - "previous": { + "name": { "type": "string", "nullable": true }, - "results": { + "message": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/TypedData" + } + ] + }, + "creationTimestamp": { + "type": "number" + }, + "modifiedTimestamp": { + "type": "number" + }, + "confirmationsSubmitted": { + "type": "number" + }, + "confirmationsRequired": { + "type": "number" + }, + "proposedBy": { + "$ref": "#/components/schemas/AddressInfo" + }, + "confirmations": { "type": "array", "items": { - "$ref": "#/components/schemas/CampaignRank" + "$ref": "#/components/schemas/MessageConfirmation" } - } - }, - "required": [ - "results" - ] - }, - "EligibilityRequest": { - "type": "object", - "properties": { - "requestId": { - "type": "string" }, - "sealedData": { - "type": "string" - } - }, - "required": [ - "requestId", - "sealedData" - ] - }, - "Eligibility": { - "type": "object", - "properties": { - "requestId": { - "type": "string" + "preparedSignature": { + "type": "string", + "nullable": true }, - "isAllowed": { - "type": "boolean" + "origin": { + "type": "string", + "nullable": true }, - "isVpn": { - "type": "boolean" + "type": { + "type": "string", + "enum": [ + "MESSAGE" + ] } }, "required": [ - "requestId", - "isAllowed", - "isVpn" + "messageHash", + "status", + "message", + "creationTimestamp", + "modifiedTimestamp", + "confirmationsSubmitted", + "confirmationsRequired", + "proposedBy", + "confirmations", + "type" ] }, - "LockingRank": { + "DateLabel": { "type": "object", "properties": { - "holder": { - "type": "string" + "type": { + "type": "string", + "enum": [ + "DATE_LABEL" + ] }, - "position": { + "timestamp": { "type": "number" - }, - "lockedAmount": { - "type": "string" - }, - "unlockedAmount": { - "type": "string" - }, - "withdrawnAmount": { - "type": "string" } }, "required": [ - "holder", - "position", - "lockedAmount", - "unlockedAmount", - "withdrawnAmount" + "type", + "timestamp" ] }, - "LockingRankPage": { + "MessagePage": { "type": "object", "properties": { "count": { @@ -4368,7 +6737,14 @@ "results": { "type": "array", "items": { - "$ref": "#/components/schemas/LockingRank" + "oneOf": [ + { + "$ref": "#/components/schemas/MessageItem" + }, + { + "$ref": "#/components/schemas/DateLabel" + } + ] } } }, @@ -4376,453 +6752,441 @@ "results" ] }, - "LockEventItem": { + "CreateMessageDto": { "type": "object", "properties": { - "eventType": { - "type": "string", - "enum": [ - "LOCKED" + "message": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/TypedData" + } ] }, - "executionDate": { - "type": "string" - }, - "transactionHash": { - "type": "string" - }, - "holder": { - "type": "string" + "safeAppId": { + "type": "number", + "nullable": true, + "deprecated": true }, - "amount": { + "signature": { "type": "string" }, - "logIndex": { - "type": "string" + "origin": { + "type": "string", + "nullable": true } }, "required": [ - "eventType", - "executionDate", - "transactionHash", - "holder", - "amount", - "logIndex" + "message", + "signature" ] }, - "UnlockEventItem": { + "UpdateMessageSignatureDto": { "type": "object", "properties": { - "eventType": { - "type": "string", - "enum": [ - "UNLOCKED" - ] - }, - "executionDate": { - "type": "string" - }, - "transactionHash": { - "type": "string" - }, - "holder": { + "signature": { "type": "string" - }, - "amount": { + } + }, + "required": [ + "signature" + ] + }, + "SafeRegistration": { + "type": "object", + "properties": { + "chainId": { "type": "string" }, - "logIndex": { - "type": "string" + "safes": { + "type": "array", + "items": { + "type": "string" + } }, - "unlockIndex": { - "type": "string" + "signatures": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "eventType", - "executionDate", - "transactionHash", - "holder", - "amount", - "logIndex", - "unlockIndex" + "chainId", + "safes", + "signatures" ] }, - "WithdrawEventItem": { + "RegisterDeviceDto": { "type": "object", "properties": { - "eventType": { + "uuid": { "type": "string", - "enum": [ - "WITHDRAWN" - ] + "nullable": true }, - "executionDate": { + "cloudMessagingToken": { "type": "string" }, - "transactionHash": { + "buildNumber": { "type": "string" }, - "holder": { + "bundle": { "type": "string" }, - "amount": { + "deviceType": { "type": "string" }, - "logIndex": { + "version": { "type": "string" }, - "unlockIndex": { - "type": "string" + "timestamp": { + "type": "string", + "nullable": true + }, + "safeRegistrations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SafeRegistration" + } } }, "required": [ - "eventType", - "executionDate", - "transactionHash", - "holder", - "amount", - "logIndex", - "unlockIndex" + "cloudMessagingToken", + "buildNumber", + "bundle", + "deviceType", + "version", + "safeRegistrations" ] }, - "LockingEventPage": { + "UserWallet": { "type": "object", "properties": { - "count": { - "type": "number", - "nullable": true + "id": { + "type": "number" }, - "next": { - "type": "string", - "nullable": true + "address": { + "type": "string" + } + }, + "required": [ + "id", + "address" + ] + }, + "UserWithWallets": { + "type": "object", + "properties": { + "id": { + "type": "number" }, - "previous": { - "type": "string", - "nullable": true + "status": { + "type": "number", + "enum": [ + 0, + 1 + ] }, - "results": { + "wallets": { "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/LockEventItem" - }, - { - "$ref": "#/components/schemas/UnlockEventItem" - }, - { - "$ref": "#/components/schemas/WithdrawEventItem" - } - ] + "$ref": "#/components/schemas/UserWallet" } } }, "required": [ - "results" + "id", + "status", + "wallets" ] }, - "Contract": { + "CreatedUserWithWallet": { "type": "object", "properties": { - "address": { - "type": "string" - }, - "name": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "logoUri": { - "type": "string" - }, - "contractAbi": { - "type": "object", - "nullable": true - }, - "trustedForDelegateCall": { - "type": "boolean" + "id": { + "type": "number" } }, "required": [ - "address", - "name", - "displayName", - "logoUri", - "trustedForDelegateCall" + "id" ] }, - "TransactionDataDto": { + "WalletAddedToUser": { "type": "object", "properties": { - "data": { - "type": "string", - "description": "Hexadecimal value" - }, - "to": { - "type": "string", - "description": "The target Ethereum address" - }, - "value": { - "type": "string", - "description": "The wei amount being sent to a payable function" + "id": { + "type": "number" } }, "required": [ - "data" + "id" ] }, - "DataDecodedParameter": { + "SpaceAddressBookItemDto": { "type": "object", "properties": { "name": { "type": "string" }, - "type": { + "address": { "type": "string" }, - "value": { - "type": "object" + "chainIds": { + "type": "array", + "items": { + "type": "string" + } }, - "valueDecoded": { - "oneOf": [ - { - "type": "object" - }, - { - "type": "array", - "items": { - "type": "object" - } - } - ], - "nullable": true + "createdBy": { + "type": "string" + }, + "lastUpdatedBy": { + "type": "string" } }, "required": [ "name", - "type", - "value" + "address", + "chainIds", + "createdBy", + "lastUpdatedBy" ] }, - "DataDecoded": { + "SpaceAddressBookDto": { "type": "object", "properties": { - "method": { + "spaceId": { "type": "string" }, - "parameters": { - "nullable": true, + "data": { "type": "array", "items": { - "$ref": "#/components/schemas/DataDecodedParameter" + "$ref": "#/components/schemas/SpaceAddressBookItemDto" } } }, "required": [ - "method" + "spaceId", + "data" ] }, - "Delegate": { + "UpsertAddressBookItemsDto": { "type": "object", "properties": { - "safe": { - "type": "string", - "nullable": true - }, - "delegate": { - "type": "string" - }, - "delegator": { - "type": "string" - }, - "label": { - "type": "string" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AddressBookItem" + } } }, "required": [ - "delegate", - "delegator", - "label" + "items" ] }, - "DelegatePage": { + "CreateSpaceDto": { "type": "object", "properties": { - "count": { - "type": "number", - "nullable": true - }, - "next": { - "type": "string", - "nullable": true - }, - "previous": { - "type": "string", - "nullable": true - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Delegate" - } + "name": { + "type": "string" } }, "required": [ - "results" + "name" ] }, - "CreateDelegateDto": { + "CreateSpaceResponse": { "type": "object", "properties": { - "safe": { - "type": "string", - "nullable": true - }, - "delegate": { - "type": "string" - }, - "delegator": { - "type": "string" - }, - "signature": { + "name": { "type": "string" }, - "label": { - "type": "string" + "id": { + "type": "number" } }, "required": [ - "delegate", - "delegator", - "signature", - "label" + "name", + "id" ] }, - "DeleteDelegateDto": { + "UserDto": { "type": "object", "properties": { - "delegate": { - "type": "string" - }, - "delegator": { - "type": "string" + "id": { + "type": "number" }, - "signature": { - "type": "string" + "status": { + "type": "string", + "enum": [ + "PENDING", + "ACTIVE" + ] } }, "required": [ - "delegate", - "delegator", - "signature" + "id", + "status" ] }, - "DeleteSafeDelegateDto": { + "MemberDto": { "type": "object", "properties": { - "delegate": { + "id": { + "type": "number" + }, + "role": { + "type": "string", + "enum": [ + "ADMIN", + "MEMBER" + ] + }, + "name": { "type": "string" }, - "safe": { + "invitedBy": { "type": "string" }, - "signature": { + "status": { + "type": "string", + "enum": [ + "INVITED", + "ACTIVE", + "DECLINED" + ] + }, + "createdAt": { + "format": "date-time", "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/UserDto" } }, "required": [ - "delegate", - "safe", - "signature" + "id", + "role", + "name", + "invitedBy", + "status", + "createdAt", + "updatedAt", + "user" ] }, - "DeleteDelegateV2Dto": { + "GetSpaceResponse": { "type": "object", "properties": { - "delegator": { - "type": "string", - "nullable": true + "id": { + "type": "number" }, - "safe": { + "name": { + "type": "string" + }, + "status": { "type": "string", - "nullable": true + "enum": [ + "ACTIVE" + ] }, - "signature": { - "type": "string" + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberDto" + } } }, "required": [ - "signature" + "id", + "name", + "status", + "members" ] }, - "AddRecoveryModuleDto": { + "UpdateSpaceDto": { "type": "object", "properties": { - "moduleAddress": { + "name": { "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE" + ] + } + } + }, + "UpdateSpaceResponse": { + "type": "object", + "properties": { + "id": { + "type": "number" } }, "required": [ - "moduleAddress" + "id" ] }, - "GetEstimationDto": { + "CreateSpaceSafeDto": { "type": "object", "properties": { - "to": { + "chainId": { "type": "string" }, - "value": { + "address": { "type": "string" - }, - "data": { - "type": "string", - "nullable": true - }, - "operation": { - "type": "number" } }, "required": [ - "to", - "value", - "operation" + "chainId", + "address" ] }, - "EstimationResponse": { + "CreateSpaceSafesDto": { "type": "object", "properties": { - "currentNonce": { - "type": "number" - }, - "recommendedNonce": { - "type": "number" - }, - "safeTxGas": { - "type": "string" + "safes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateSpaceSafeDto" + } } }, "required": [ - "currentNonce", - "recommendedNonce", - "safeTxGas" + "safes" ] }, - "NotificationType": { - "type": "string", - "enum": [ - "CONFIRMATION_REQUEST", - "DELETED_MULTISIG_TRANSACTION", - "EXECUTED_MULTISIG_TRANSACTION", - "INCOMING_ETHER", - "INCOMING_TOKEN", - "MESSAGE_CONFIRMATION_REQUEST", - "MODULE_TRANSACTION" + "GetSpaceSafeResponse": { + "type": "object", + "properties": { + "safes": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": { + "{chainId}": [ + "0x..." + ] + } + } + }, + "required": [ + "safes" ] }, - "UpsertSubscriptionsSafesDto": { + "DeleteSpaceSafeDto": { "type": "object", "properties": { "chainId": { @@ -4830,356 +7194,231 @@ }, "address": { "type": "string" - }, - "notificationTypes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationType" - } } }, "required": [ "chainId", - "address", - "notificationTypes" - ] - }, - "DeviceType": { - "type": "string", - "enum": [ - "ANDROID", - "IOS", - "WEB" + "address" ] }, - "UpsertSubscriptionsDto": { + "DeleteSpaceSafesDto": { "type": "object", "properties": { - "cloudMessagingToken": { - "type": "string" - }, "safes": { "type": "array", "items": { - "$ref": "#/components/schemas/UpsertSubscriptionsSafesDto" + "$ref": "#/components/schemas/DeleteSpaceSafeDto" } - }, - "deviceType": { - "allOf": [ - { - "$ref": "#/components/schemas/DeviceType" - } - ] - }, - "deviceUuid": { - "type": "string", - "nullable": true } }, "required": [ - "cloudMessagingToken", - "safes", - "deviceType" + "safes" ] }, - "AddressInfo": { + "InviteUserDto": { "type": "object", "properties": { - "value": { + "address": { "type": "string" }, "name": { "type": "string", - "nullable": true + "minLength": 3, + "maxLength": 30 }, - "logoUri": { + "role": { "type": "string", - "nullable": true + "enum": [ + "ADMIN", + "MEMBER" + ] } }, "required": [ - "value" + "address", + "name", + "role" ] }, - "Message": { + "InviteUsersDto": { "type": "object", "properties": { - "messageHash": { - "type": "string" - }, - "status": { - "type": "string" - }, - "logoUri": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string", - "nullable": true - }, - "message": { - "type": "object" - }, - "creationTimestamp": { - "type": "number" - }, - "modifiedTimestamp": { - "type": "number" - }, - "confirmationsSubmitted": { - "type": "number" - }, - "confirmationsRequired": { - "type": "number" - }, - "proposedBy": { - "$ref": "#/components/schemas/AddressInfo" - }, - "confirmations": { + "users": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/InviteUserDto" } - }, - "preparedSignature": { - "type": "string", - "nullable": true - }, - "origin": { - "type": "string", - "nullable": true } }, "required": [ - "messageHash", - "status", - "message", - "creationTimestamp", - "modifiedTimestamp", - "confirmationsSubmitted", - "confirmationsRequired", - "proposedBy", - "confirmations" + "users" ] }, - "MessageItem": { + "Invitation": { "type": "object", "properties": { - "messageHash": { - "type": "string" - }, - "status": { - "type": "string" - }, - "logoUri": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string", - "nullable": true - }, - "message": { - "type": "object" - }, - "creationTimestamp": { - "type": "number" - }, - "modifiedTimestamp": { + "userId": { "type": "number" }, - "confirmationsSubmitted": { - "type": "number" + "name": { + "type": "string" }, - "confirmationsRequired": { + "spaceId": { "type": "number" }, - "proposedBy": { - "$ref": "#/components/schemas/AddressInfo" - }, - "confirmations": { - "type": "array", - "items": { - "type": "string" - } + "role": { + "type": "string", + "enum": [ + "ADMIN", + "MEMBER" + ] }, - "preparedSignature": { + "status": { "type": "string", - "nullable": true + "enum": [ + "INVITED", + "ACTIVE", + "DECLINED" + ] }, - "origin": { + "invitedBy": { "type": "string", "nullable": true - }, - "type": { - "type": "string" } }, "required": [ - "messageHash", - "status", - "message", - "creationTimestamp", - "modifiedTimestamp", - "confirmationsSubmitted", - "confirmationsRequired", - "proposedBy", - "confirmations", - "type" + "userId", + "name", + "spaceId", + "role", + "status" ] }, - "DateLabel": { + "AcceptInviteDto": { "type": "object", "properties": { - "type": { + "name": { "type": "string", - "enum": [ - "DATE_LABEL" - ] - }, - "timestamp": { - "type": "number" + "minLength": 3, + "maxLength": 30 } }, "required": [ - "type", - "timestamp" + "name" ] }, - "MessagePage": { + "MemberUser": { "type": "object", "properties": { - "count": { - "type": "number", - "nullable": true - }, - "next": { - "type": "string", - "nullable": true + "id": { + "type": "number" }, - "previous": { + "status": { "type": "string", - "nullable": true - }, - "results": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MessageItem" - }, - { - "$ref": "#/components/schemas/DateLabel" - } - ] - } + "enum": [ + "PENDING", + "ACTIVE" + ] } }, "required": [ - "results" + "id", + "status" ] }, - "CreateMessageDto": { + "Member": { "type": "object", "properties": { - "message": { - "type": "object" + "id": { + "type": "number" }, - "safeAppId": { - "type": "number", - "nullable": true, - "deprecated": true + "role": { + "type": "string", + "enum": [ + "ADMIN", + "MEMBER" + ] }, - "signature": { + "status": { + "type": "string", + "enum": [ + "INVITED", + "ACTIVE", + "DECLINED" + ] + }, + "name": { "type": "string" }, - "origin": { + "alias": { "type": "string", "nullable": true - } - }, - "required": [ - "message", - "signature" - ] - }, - "UpdateMessageSignatureDto": { - "type": "object", - "properties": { - "signature": { + }, + "invitedBy": { + "type": "string", + "nullable": true + }, + "createdAt": { + "format": "date-time", "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/MemberUser" } }, "required": [ - "signature" + "id", + "role", + "status", + "name", + "createdAt", + "updatedAt", + "user" ] }, - "SafeRegistration": { + "MembersDto": { "type": "object", "properties": { - "chainId": { - "type": "string" - }, - "safes": { - "type": "array", - "items": { - "type": "string" - } - }, - "signatures": { + "members": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/Member" } } }, "required": [ - "chainId", - "safes", - "signatures" + "members" + ] + }, + "UpdateRoleDto": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": [ + "ADMIN", + "MEMBER" + ] + } + }, + "required": [ + "role" ] }, - "RegisterDeviceDto": { + "UpdateMemberAliasDto": { "type": "object", "properties": { - "uuid": { - "type": "string", - "nullable": true - }, - "cloudMessagingToken": { - "type": "string" - }, - "buildNumber": { - "type": "string" - }, - "bundle": { - "type": "string" - }, - "deviceType": { - "type": "string" - }, - "version": { - "type": "string" - }, - "timestamp": { + "alias": { "type": "string", - "nullable": true - }, - "safeRegistrations": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SafeRegistration" - } + "description": "The new alias for the member" } }, "required": [ - "cloudMessagingToken", - "buildNumber", - "bundle", - "deviceType", - "version", - "safeRegistrations" + "alias" ] }, "SafeList": { @@ -5220,6 +7459,32 @@ "data" ] }, + "Relay": { + "type": "object", + "properties": { + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, + "RelaysRemaining": { + "type": "object", + "properties": { + "remaining": { + "type": "number" + }, + "limit": { + "type": "number" + } + }, + "required": [ + "remaining", + "limit" + ] + }, "SafeAppProvider": { "type": "object", "properties": { @@ -5481,157 +7746,343 @@ } }, "required": [ - "address", - "chainId", - "threshold", - "owners", - "fiatTotal", - "queued" + "address", + "chainId", + "threshold", + "owners", + "fiatTotal", + "queued" + ] + }, + "TargetedSafe": { + "type": "object", + "properties": { + "outreachId": { + "type": "number" + }, + "address": { + "type": "string" + } + }, + "required": [ + "outreachId", + "address" + ] + }, + "Submission": { + "type": "object", + "properties": { + "outreachId": { + "type": "number" + }, + "targetedSafeId": { + "type": "number" + }, + "signerAddress": { + "type": "string" + }, + "completionDate": { + "format": "date-time", + "type": "string", + "nullable": true + } + }, + "required": [ + "outreachId", + "targetedSafeId", + "signerAddress" + ] + }, + "CreateSubmissionDto": { + "type": "object", + "properties": { + "completed": { + "type": "boolean" + } + }, + "required": [ + "completed" + ] + }, + "TransactionInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Bridge", + "Creation", + "Custom", + "NativeStakingDeposit", + "NativeStakingValidatorsExit", + "NativeStakingWithdraw", + "SettingsChange", + "Swap", + "SwapAndBridge", + "SwapOrder", + "SwapTransfer", + "Transfer", + "TwapOrder", + "VaultDeposit", + "VaultRedeem" + ] + }, + "humanDescription": { + "type": "string", + "nullable": true + } + }, + "required": [ + "type" + ] + }, + "CreationTransactionInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Creation" + ] + }, + "humanDescription": { + "type": "string", + "nullable": true + }, + "creator": { + "$ref": "#/components/schemas/AddressInfo" + }, + "transactionHash": { + "type": "string" + }, + "implementation": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/AddressInfo" + } + ] + }, + "factory": { + "$ref": "#/components/schemas/AddressInfo" + }, + "saltNonce": { + "type": "string", + "nullable": true + } + }, + "required": [ + "type", + "creator", + "transactionHash" + ] + }, + "CustomTransactionInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Custom" + ] + }, + "humanDescription": { + "type": "string", + "nullable": true + }, + "to": { + "$ref": "#/components/schemas/AddressInfo" + }, + "dataSize": { + "type": "string" + }, + "value": { + "type": "string", + "nullable": true + }, + "isCancellation": { + "type": "boolean" + }, + "methodName": { + "type": "string", + "nullable": true + }, + "actionCount": { + "type": "number", + "nullable": true + } + }, + "required": [ + "type", + "to", + "dataSize", + "isCancellation" + ] + }, + "AddOwner": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "ADD_OWNER" + ] + }, + "owner": { + "$ref": "#/components/schemas/AddressInfo" + }, + "threshold": { + "type": "number" + } + }, + "required": [ + "type", + "owner", + "threshold" + ] + }, + "ChangeMasterCopy": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "CHANGE_MASTER_COPY" + ] + }, + "implementation": { + "$ref": "#/components/schemas/AddressInfo" + } + }, + "required": [ + "type", + "implementation" ] }, - "Submission": { + "ChangeThreshold": { "type": "object", "properties": { - "outreachId": { - "type": "number" + "type": { + "type": "string", + "enum": [ + "CHANGE_THRESHOLD" + ] }, - "targetedSafeId": { + "threshold": { "type": "number" - }, - "signerAddress": { - "type": "string" - }, - "completionDate": { - "format": "date-time", - "type": "string", - "nullable": true } }, "required": [ - "outreachId", - "targetedSafeId", - "signerAddress" + "type", + "threshold" ] }, - "CreateSubmissionDto": { + "DeleteGuard": { "type": "object", "properties": { - "completed": { - "type": "boolean" + "type": { + "type": "string", + "enum": [ + "DELETE_GUARD" + ] } }, "required": [ - "completed" + "type" ] }, - "TransactionInfo": { + "DisableModule": { "type": "object", "properties": { "type": { "type": "string", "enum": [ - "Creation", - "Custom", - "SettingsChange", - "Transfer", - "SwapOrder", - "SwapTransfer", - "TwapOrder", - "NativeStakingDeposit", - "NativeStakingValidatorsExit", - "NativeStakingWithdraw" + "DISABLE_MODULE" ] }, - "humanDescription": { - "type": "string", - "nullable": true + "module": { + "$ref": "#/components/schemas/AddressInfo" } }, "required": [ - "type" + "type", + "module" ] }, - "CreationTransactionInfo": { + "EnableModule": { "type": "object", "properties": { "type": { "type": "string", "enum": [ - "Creation" + "ENABLE_MODULE" ] }, - "humanDescription": { - "type": "string", - "nullable": true - }, - "creator": { + "module": { "$ref": "#/components/schemas/AddressInfo" - }, - "transactionHash": { - "type": "string" - }, - "implementation": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/AddressInfo" - } + } + }, + "required": [ + "type", + "module" + ] + }, + "RemoveOwner": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "REMOVE_OWNER" ] }, - "factory": { + "owner": { "$ref": "#/components/schemas/AddressInfo" }, - "saltNonce": { - "type": "string", - "nullable": true + "threshold": { + "type": "number" } }, "required": [ "type", - "creator", - "transactionHash" + "owner", + "threshold" ] }, - "CustomTransactionInfo": { + "SetFallbackHandler": { "type": "object", "properties": { "type": { "type": "string", "enum": [ - "Custom" + "SET_FALLBACK_HANDLER" ] }, - "humanDescription": { - "type": "string", - "nullable": true - }, - "to": { + "handler": { "$ref": "#/components/schemas/AddressInfo" - }, - "dataSize": { - "type": "string" - }, - "value": { - "type": "string", - "nullable": true - }, - "isCancellation": { - "type": "boolean" - }, - "methodName": { + } + }, + "required": [ + "type", + "handler" + ] + }, + "SetGuard": { + "type": "object", + "properties": { + "type": { "type": "string", - "nullable": true + "enum": [ + "SET_GUARD" + ] }, - "actionCount": { - "type": "number", - "nullable": true + "guard": { + "$ref": "#/components/schemas/AddressInfo" } }, "required": [ "type", - "to", - "dataSize", - "isCancellation" + "guard" ] }, "SettingsChange": { @@ -5657,6 +8108,28 @@ "type" ] }, + "SwapOwner": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "SWAP_OWNER" + ] + }, + "oldOwner": { + "$ref": "#/components/schemas/AddressInfo" + }, + "newOwner": { + "$ref": "#/components/schemas/AddressInfo" + } + }, + "required": [ + "type", + "oldOwner", + "newOwner" + ] + }, "SettingsChangeTransaction": { "type": "object", "properties": { @@ -5674,17 +8147,44 @@ "$ref": "#/components/schemas/DataDecoded" }, "settingsInfo": { - "nullable": true, - "allOf": [ + "oneOf": [ + { + "$ref": "#/components/schemas/AddOwner" + }, + { + "$ref": "#/components/schemas/ChangeMasterCopy" + }, { - "$ref": "#/components/schemas/SettingsChange" + "$ref": "#/components/schemas/ChangeThreshold" + }, + { + "$ref": "#/components/schemas/DeleteGuard" + }, + { + "$ref": "#/components/schemas/DisableModule" + }, + { + "$ref": "#/components/schemas/EnableModule" + }, + { + "$ref": "#/components/schemas/RemoveOwner" + }, + { + "$ref": "#/components/schemas/SetFallbackHandler" + }, + { + "$ref": "#/components/schemas/SetGuard" + }, + { + "$ref": "#/components/schemas/SwapOwner" } ] } }, "required": [ "type", - "dataDecoded" + "dataDecoded", + "settingsInfo" ] }, "Erc20Transfer": { @@ -5859,6 +8359,25 @@ "transferInfo" ] }, + "BridgeFee": { + "type": "object", + "properties": { + "tokenAddress": { + "type": "string" + }, + "integratorFee": { + "type": "string" + }, + "lifiFee": { + "type": "string" + } + }, + "required": [ + "tokenAddress", + "integratorFee", + "lifiFee" + ] + }, "TokenInfo": { "type": "object", "properties": { @@ -5866,34 +8385,132 @@ "type": "string", "description": "The token address" }, - "decimals": { - "type": "number", - "description": "The token decimals" + "decimals": { + "type": "number", + "description": "The token decimals" + }, + "logoUri": { + "type": "string", + "nullable": true, + "description": "The logo URI for the token" + }, + "name": { + "type": "string", + "description": "The token name" + }, + "symbol": { + "type": "string", + "description": "The token symbol" + }, + "trusted": { + "type": "boolean", + "description": "The token trusted status" + } + }, + "required": [ + "address", + "decimals", + "name", + "symbol", + "trusted" + ] + }, + "BridgeAndSwapTransactionInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "SwapAndBridge" + ] + }, + "humanDescription": { + "type": "string", + "nullable": true + }, + "fromToken": { + "$ref": "#/components/schemas/TokenInfo" + }, + "recipient": { + "$ref": "#/components/schemas/AddressInfo" + }, + "explorerUrl": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "NOT_FOUND", + "INVALID", + "PENDING", + "DONE", + "FAILED", + "UNKNOWN", + "AWAITING_EXECUTION" + ] + }, + "substatus": { + "type": "string", + "enum": [ + "WAIT_SOURCE_CONFIRMATIONS", + "WAIT_DESTINATION_TRANSACTION", + "BRIDGE_NOT_AVAILABLE", + "CHAIN_NOT_AVAILABLE", + "REFUND_IN_PROGRESS", + "UNKNOWN_ERROR", + "COMPLETED", + "PARTIAL", + "REFUNDED", + "INSUFFICIENT_ALLOWANCE", + "INSUFFICIENT_BALANCE", + "OUT_OF_GAS", + "EXPIRED", + "SLIPPAGE_EXCEEDED", + "UNKNOWN_FAILED_ERROR", + "UNKNOWN", + "AWAITING_EXECUTION" + ] + }, + "fees": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/BridgeFee" + } + ] + }, + "fromAmount": { + "type": "string" }, - "logoUri": { - "type": "string", - "nullable": true, - "description": "The logo URI for the token" + "toChain": { + "type": "string" }, - "name": { - "type": "string", - "description": "The token name" + "toToken": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/TokenInfo" + } + ] }, - "symbol": { + "toAmount": { "type": "string", - "description": "The token symbol" - }, - "trusted": { - "type": "boolean", - "description": "The token trusted status" + "nullable": true } }, "required": [ - "address", - "decimals", - "name", - "symbol", - "trusted" + "type", + "fromToken", + "recipient", + "explorerUrl", + "status", + "substatus", + "fees", + "fromAmount", + "toChain", + "toToken", + "toAmount" ] }, "SwapOrderTransactionInfo": { @@ -5981,11 +8598,6 @@ "type": "string", "description": "The URL to the explorer page of the order" }, - "executedSurplusFee": { - "type": "string", - "description": "The amount of fees paid for this order.", - "deprecated": true - }, "executedFee": { "type": "string", "description": "The amount of fees paid for this order." @@ -6022,12 +8634,63 @@ "sellToken", "buyToken", "explorerUrl", - "executedSurplusFee", "executedFee", "executedFeeToken", "owner" ] }, + "SwapTransactionInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Swap" + ] + }, + "humanDescription": { + "type": "string", + "nullable": true + }, + "recipient": { + "$ref": "#/components/schemas/AddressInfo" + }, + "fees": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/BridgeFee" + } + ] + }, + "fromToken": { + "$ref": "#/components/schemas/TokenInfo" + }, + "fromAmount": { + "type": "string" + }, + "toToken": { + "$ref": "#/components/schemas/TokenInfo" + }, + "toAmount": { + "type": "string" + }, + "lifiExplorerUrl": { + "type": "string", + "nullable": true + } + }, + "required": [ + "type", + "recipient", + "fees", + "fromToken", + "fromAmount", + "toToken", + "toAmount", + "lifiExplorerUrl" + ] + }, "SwapTransferTransactionInfo": { "type": "object", "properties": { @@ -6140,11 +8803,6 @@ "type": "string", "description": "The URL to the explorer page of the order" }, - "executedSurplusFee": { - "type": "string", - "description": "The amount of fees paid for this order.", - "deprecated": true - }, "executedFee": { "type": "string", "description": "The amount of fees paid for this order." @@ -6189,12 +8847,75 @@ "sellToken", "buyToken", "explorerUrl", - "executedSurplusFee", "executedFee", "executedFeeToken", "owner" ] }, + "DurationAuto": { + "type": "object", + "properties": { + "durationType": { + "type": "string", + "enum": [ + "AUTO" + ] + } + }, + "required": [ + "durationType" + ] + }, + "DurationLimit": { + "type": "object", + "properties": { + "durationType": { + "type": "string", + "enum": [ + "LIMIT_DURATION" + ] + }, + "duration": { + "type": "string" + } + }, + "required": [ + "durationType", + "duration" + ] + }, + "StartTimeAtMining": { + "type": "object", + "properties": { + "startType": { + "type": "string", + "enum": [ + "AT_MINING_TIME" + ] + } + }, + "required": [ + "startType" + ] + }, + "StartTimeAtEpoch": { + "type": "object", + "properties": { + "startType": { + "type": "string", + "enum": [ + "AT_EPOCH" + ] + }, + "epoch": { + "type": "number" + } + }, + "required": [ + "startType", + "epoch" + ] + }, "TwapOrderTransactionInfo": { "type": "object", "properties": { @@ -6264,12 +8985,6 @@ "nullable": true, "description": "The executed buy token raw amount (no decimals), or null if there are too many parts" }, - "executedSurplusFee": { - "type": "string", - "nullable": true, - "description": "The executed surplus fee raw amount (no decimals), or null if there are too many parts", - "deprecated": true - }, "executedFee": { "type": "string", "nullable": true, @@ -6324,12 +9039,26 @@ "description": "The duration of the TWAP interval" }, "durationOfPart": { - "type": "object", - "description": "Whether the TWAP is valid for the entire interval or not" + "description": "Whether the TWAP is valid for the entire interval or not", + "oneOf": [ + { + "$ref": "#/components/schemas/DurationAuto" + }, + { + "$ref": "#/components/schemas/DurationLimit" + } + ] }, "startTime": { - "type": "object", - "description": "The start time of the TWAP" + "description": "The start time of the TWAP", + "oneOf": [ + { + "$ref": "#/components/schemas/StartTimeAtMining" + }, + { + "$ref": "#/components/schemas/StartTimeAtEpoch" + } + ] } }, "required": [ @@ -6494,22 +9223,166 @@ }, "required": [ "type", - "status", - "estimatedExitTime", - "estimatedWithdrawalTime", + "status", + "estimatedExitTime", + "estimatedWithdrawalTime", + "value", + "numValidators", + "tokenInfo", + "validators" + ] + }, + "NativeStakingWithdrawTransactionInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "NativeStakingWithdraw" + ] + }, + "humanDescription": { + "type": "string", + "nullable": true + }, + "value": { + "type": "string" + }, + "tokenInfo": { + "$ref": "#/components/schemas/TokenInfo" + }, + "validators": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "value", + "tokenInfo", + "validators" + ] + }, + "VaultInfo": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "dashboardUri": { + "type": "string", + "nullable": true + }, + "logoUri": { + "type": "string" + } + }, + "required": [ + "address", + "name", + "description", + "logoUri" + ] + }, + "VaultExtraReward": { + "type": "object", + "properties": { + "tokenInfo": { + "$ref": "#/components/schemas/TokenInfo" + }, + "nrr": { + "type": "number" + }, + "claimable": { + "type": "string" + }, + "claimableNext": { + "type": "string" + } + }, + "required": [ + "tokenInfo", + "nrr", + "claimable", + "claimableNext" + ] + }, + "VaultDepositTransactionInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "VaultDeposit" + ] + }, + "humanDescription": { + "type": "string", + "nullable": true + }, + "value": { + "type": "string" + }, + "baseNrr": { + "type": "number" + }, + "fee": { + "type": "number" + }, + "tokenInfo": { + "$ref": "#/components/schemas/TokenInfo" + }, + "vaultInfo": { + "$ref": "#/components/schemas/VaultInfo" + }, + "currentReward": { + "type": "string" + }, + "additionalRewardsNrr": { + "type": "number" + }, + "additionalRewards": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VaultExtraReward" + } + }, + "expectedMonthlyReward": { + "type": "string" + }, + "expectedAnnualReward": { + "type": "string" + } + }, + "required": [ + "type", "value", - "numValidators", + "baseNrr", + "fee", "tokenInfo", - "validators" + "vaultInfo", + "currentReward", + "additionalRewardsNrr", + "additionalRewards", + "expectedMonthlyReward", + "expectedAnnualReward" ] }, - "NativeStakingWithdrawTransactionInfo": { + "VaultRedeemTransactionInfo": { "type": "object", "properties": { "type": { "type": "string", "enum": [ - "NativeStakingWithdraw" + "VaultRedeem" ] }, "humanDescription": { @@ -6519,21 +9392,41 @@ "value": { "type": "string" }, + "baseNrr": { + "type": "number" + }, + "fee": { + "type": "number" + }, "tokenInfo": { "$ref": "#/components/schemas/TokenInfo" }, - "validators": { + "vaultInfo": { + "$ref": "#/components/schemas/VaultInfo" + }, + "currentReward": { + "type": "string" + }, + "additionalRewardsNrr": { + "type": "number" + }, + "additionalRewards": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/VaultExtraReward" } } }, "required": [ "type", "value", + "baseNrr", + "fee", "tokenInfo", - "validators" + "vaultInfo", + "currentReward", + "additionalRewardsNrr", + "additionalRewards" ] }, "TransactionData": { @@ -6567,6 +9460,26 @@ }, "addressInfoIndex": { "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/AddressInfo" + }, + "nullable": true + }, + "tokenInfoIndex": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/components/schemas/NativeToken" + }, + { + "$ref": "#/components/schemas/Erc20Token" + }, + { + "$ref": "#/components/schemas/Erc721Token" + } + ] + }, "nullable": true } }, @@ -6575,6 +9488,25 @@ "operation" ] }, + "MultisigConfirmationDetails": { + "type": "object", + "properties": { + "signer": { + "$ref": "#/components/schemas/AddressInfo" + }, + "signature": { + "type": "string", + "nullable": true + }, + "submittedAt": { + "type": "number" + } + }, + "required": [ + "signer", + "submittedAt" + ] + }, "MultisigExecutionDetails": { "type": "object", "properties": { @@ -6619,7 +9551,7 @@ "signers": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/AddressInfo" } }, "confirmationsRequired": { @@ -6628,7 +9560,7 @@ "confirmations": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/MultisigConfirmationDetails" } }, "rejectors": { @@ -6638,12 +9570,18 @@ } }, "gasTokenInfo": { - "nullable": true, - "allOf": [ + "oneOf": [ + { + "$ref": "#/components/schemas/NativeToken" + }, + { + "$ref": "#/components/schemas/Erc20Token" + }, { - "$ref": "#/components/schemas/Token" + "$ref": "#/components/schemas/Erc721Token" } - ] + ], + "nullable": true }, "trusted": { "type": "boolean" @@ -6739,6 +9677,12 @@ { "$ref": "#/components/schemas/SwapOrderTransactionInfo" }, + { + "$ref": "#/components/schemas/BridgeAndSwapTransactionInfo" + }, + { + "$ref": "#/components/schemas/SwapTransactionInfo" + }, { "$ref": "#/components/schemas/SwapTransferTransactionInfo" }, @@ -6753,6 +9697,12 @@ }, { "$ref": "#/components/schemas/NativeStakingWithdrawTransactionInfo" + }, + { + "$ref": "#/components/schemas/VaultDepositTransactionInfo" + }, + { + "$ref": "#/components/schemas/VaultRedeemTransactionInfo" } ], "allOf": [ @@ -6824,6 +9774,160 @@ "txStatus" ] }, + "TXSMultisigTransaction": { + "type": "object", + "properties": { + "safe": { + "type": "string" + }, + "to": { + "type": "string" + }, + "value": { + "type": "string" + }, + "data": { + "type": "object" + }, + "operation": { + "type": "number" + }, + "gasToken": { + "type": "object" + }, + "safeTxGas": { + "type": "object" + }, + "baseGas": { + "type": "object" + }, + "gasPrice": { + "type": "object" + }, + "proposer": { + "type": "object" + }, + "proposedByDelegate": { + "type": "object" + }, + "refundReceiver": { + "type": "object" + }, + "nonce": { + "type": "number" + }, + "executionDate": { + "type": "object" + }, + "submissionDate": { + "format": "date-time", + "type": "string" + }, + "modified": { + "type": "object" + }, + "blockNumber": { + "type": "object" + }, + "transactionHash": { + "type": "object" + }, + "safeTxHash": { + "type": "string" + }, + "executor": { + "type": "object" + }, + "isExecuted": { + "type": "boolean" + }, + "isSuccessful": { + "type": "object" + }, + "ethGasPrice": { + "type": "object" + }, + "gasUsed": { + "type": "object" + }, + "fee": { + "type": "object" + }, + "origin": { + "type": "object" + }, + "confirmationsRequired": { + "type": "number" + }, + "confirmations": { + "type": "object" + }, + "signatures": { + "type": "object" + }, + "trusted": { + "type": "boolean" + } + }, + "required": [ + "safe", + "to", + "value", + "data", + "operation", + "gasToken", + "safeTxGas", + "baseGas", + "gasPrice", + "proposer", + "proposedByDelegate", + "refundReceiver", + "nonce", + "executionDate", + "submissionDate", + "modified", + "blockNumber", + "transactionHash", + "safeTxHash", + "executor", + "isExecuted", + "isSuccessful", + "ethGasPrice", + "gasUsed", + "fee", + "origin", + "confirmationsRequired", + "confirmations", + "signatures", + "trusted" + ] + }, + "TXSMultisigTransactionPage": { + "type": "object", + "properties": { + "count": { + "type": "number", + "nullable": true + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TXSMultisigTransaction" + } + } + }, + "required": [ + "results" + ] + }, "ModuleExecutionInfo": { "type": "object", "properties": { @@ -6895,6 +9999,12 @@ { "$ref": "#/components/schemas/SwapOrderTransactionInfo" }, + { + "$ref": "#/components/schemas/BridgeAndSwapTransactionInfo" + }, + { + "$ref": "#/components/schemas/SwapTransactionInfo" + }, { "$ref": "#/components/schemas/SwapTransferTransactionInfo" }, @@ -6909,6 +10019,12 @@ }, { "$ref": "#/components/schemas/NativeStakingWithdrawTransactionInfo" + }, + { + "$ref": "#/components/schemas/VaultDepositTransactionInfo" + }, + { + "$ref": "#/components/schemas/VaultRedeemTransactionInfo" } ], "allOf": [ @@ -7082,12 +10198,12 @@ "AddConfirmationDto": { "type": "object", "properties": { - "signedSafeTxHash": { + "signature": { "type": "string" } }, "required": [ - "signedSafeTxHash" + "signature" ] }, "IncomingTransfer": { @@ -7184,6 +10300,12 @@ { "$ref": "#/components/schemas/SwapOrderTransactionInfo" }, + { + "$ref": "#/components/schemas/BridgeAndSwapTransactionInfo" + }, + { + "$ref": "#/components/schemas/SwapTransactionInfo" + }, { "$ref": "#/components/schemas/SwapTransferTransactionInfo" }, @@ -7198,6 +10320,12 @@ }, { "$ref": "#/components/schemas/NativeStakingWithdrawTransactionInfo" + }, + { + "$ref": "#/components/schemas/VaultDepositTransactionInfo" + }, + { + "$ref": "#/components/schemas/VaultRedeemTransactionInfo" } ], "allOf": [ @@ -7478,6 +10606,42 @@ "transactionHash", "factoryAddress" ] + }, + "TXSCreationTransaction": { + "type": "object", + "properties": { + "created": { + "format": "date-time", + "type": "string" + }, + "creator": { + "type": "string" + }, + "transactionHash": { + "type": "string" + }, + "factoryAddress": { + "type": "string" + }, + "masterCopy": { + "type": "object" + }, + "setupData": { + "type": "object" + }, + "saltNonce": { + "type": "object" + } + }, + "required": [ + "created", + "creator", + "transactionHash", + "factoryAddress", + "masterCopy", + "setupData", + "saltNonce" + ] } } } diff --git a/packages/store/scripts/openapi-config.ts b/packages/store/scripts/openapi-config.ts index fd213327cb..ca1c94f36e 100644 --- a/packages/store/scripts/openapi-config.ts +++ b/packages/store/scripts/openapi-config.ts @@ -6,16 +6,13 @@ const config: ConfigFile = { apiFile: '../src/gateway/cgwClient.ts', apiImport: 'cgwClient', exportName: 'cgwApi', - hooks: true, + hooks: { queries: true, lazyQueries: true, mutations: true }, filterEndpoints: [/^(?!.*delegates).*/], tag: true, outputFiles: { '../src/gateway/AUTO_GENERATED/about.ts': { filterEndpoints: [/^about/], }, - '../src/gateway/AUTO_GENERATED/accounts.ts': { - filterEndpoints: [/^accounts/], - }, '../src/gateway/AUTO_GENERATED/auth.ts': { filterEndpoints: [/^auth/], }, @@ -67,6 +64,12 @@ const config: ConfigFile = { '../src/gateway/AUTO_GENERATED/transactions.ts': { filterEndpoints: [/^transactions/], }, + '../src/gateway/AUTO_GENERATED/users.ts': { + filterEndpoints: [/^users/], + }, + '../src/gateway/AUTO_GENERATED/spaces.ts': { + filterEndpoints: [/^(spaces|members|spaceSafes)/], + }, }, } diff --git a/packages/store/src/gateway/AUTO_GENERATED/about.ts b/packages/store/src/gateway/AUTO_GENERATED/about.ts index 7fb6b45528..11ffed4d53 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/about.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/about.ts @@ -21,4 +21,4 @@ export type About = { version?: string | null buildNumber?: string | null } -export const { useAboutGetAboutQuery } = injectedRtkApi +export const { useAboutGetAboutQuery, useLazyAboutGetAboutQuery } = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/accounts.ts b/packages/store/src/gateway/AUTO_GENERATED/accounts.ts deleted file mode 100644 index 75fc89334e..0000000000 --- a/packages/store/src/gateway/AUTO_GENERATED/accounts.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { cgwClient as api } from '../cgwClient' -export const addTagTypes = ['accounts'] as const -const injectedRtkApi = api - .enhanceEndpoints({ - addTagTypes, - }) - .injectEndpoints({ - endpoints: (build) => ({ - accountsCreateAccountV1: build.mutation({ - query: (queryArg) => ({ url: `/v1/accounts`, method: 'POST', body: queryArg.createAccountDto }), - invalidatesTags: ['accounts'], - }), - accountsGetDataTypesV1: build.query({ - query: () => ({ url: `/v1/accounts/data-types` }), - providesTags: ['accounts'], - }), - accountsGetAccountDataSettingsV1: build.query< - AccountsGetAccountDataSettingsV1ApiResponse, - AccountsGetAccountDataSettingsV1ApiArg - >({ - query: (queryArg) => ({ url: `/v1/accounts/${queryArg.address}/data-settings` }), - providesTags: ['accounts'], - }), - accountsUpsertAccountDataSettingsV1: build.mutation< - AccountsUpsertAccountDataSettingsV1ApiResponse, - AccountsUpsertAccountDataSettingsV1ApiArg - >({ - query: (queryArg) => ({ - url: `/v1/accounts/${queryArg.address}/data-settings`, - method: 'PUT', - body: queryArg.upsertAccountDataSettingsDto, - }), - invalidatesTags: ['accounts'], - }), - accountsGetAccountV1: build.query({ - query: (queryArg) => ({ url: `/v1/accounts/${queryArg.address}` }), - providesTags: ['accounts'], - }), - accountsDeleteAccountV1: build.mutation({ - query: (queryArg) => ({ url: `/v1/accounts/${queryArg.address}`, method: 'DELETE' }), - invalidatesTags: ['accounts'], - }), - }), - overrideExisting: false, - }) -export { injectedRtkApi as cgwApi } -export type AccountsCreateAccountV1ApiResponse = /** status 200 */ Account -export type AccountsCreateAccountV1ApiArg = { - createAccountDto: CreateAccountDto -} -export type AccountsGetDataTypesV1ApiResponse = /** status 200 */ AccountDataType[] -export type AccountsGetDataTypesV1ApiArg = void -export type AccountsGetAccountDataSettingsV1ApiResponse = /** status 200 */ AccountDataSetting[] -export type AccountsGetAccountDataSettingsV1ApiArg = { - address: string -} -export type AccountsUpsertAccountDataSettingsV1ApiResponse = /** status 200 */ AccountDataSetting[] -export type AccountsUpsertAccountDataSettingsV1ApiArg = { - address: string - upsertAccountDataSettingsDto: UpsertAccountDataSettingsDto -} -export type AccountsGetAccountV1ApiResponse = /** status 200 */ Account -export type AccountsGetAccountV1ApiArg = { - address: string -} -export type AccountsDeleteAccountV1ApiResponse = unknown -export type AccountsDeleteAccountV1ApiArg = { - address: string -} -export type Account = { - id: string - groupId?: string | null - address: string - name: string -} -export type CreateAccountDto = { - address: string - name: string -} -export type AccountDataType = { - id: string - name: string - description?: string | null - isActive: boolean -} -export type AccountDataSetting = { - dataTypeId: string - enabled: boolean -} -export type UpsertAccountDataSettingDto = { - dataTypeId: string - enabled: boolean -} -export type UpsertAccountDataSettingsDto = { - accountDataSettings: UpsertAccountDataSettingDto[] -} -export const { - useAccountsCreateAccountV1Mutation, - useAccountsGetDataTypesV1Query, - useAccountsGetAccountDataSettingsV1Query, - useAccountsUpsertAccountDataSettingsV1Mutation, - useAccountsGetAccountV1Query, - useAccountsDeleteAccountV1Mutation, -} = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/auth.ts b/packages/store/src/gateway/AUTO_GENERATED/auth.ts index a5e675faa3..d1793de8db 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/auth.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/auth.ts @@ -14,6 +14,10 @@ const injectedRtkApi = api query: (queryArg) => ({ url: `/v1/auth/verify`, method: 'POST', body: queryArg.siweDto }), invalidatesTags: ['auth'], }), + authLogoutV1: build.mutation({ + query: () => ({ url: `/v1/auth/logout`, method: 'POST' }), + invalidatesTags: ['auth'], + }), }), overrideExisting: false, }) @@ -24,6 +28,8 @@ export type AuthVerifyV1ApiResponse = unknown export type AuthVerifyV1ApiArg = { siweDto: SiweDto } +export type AuthLogoutV1ApiResponse = unknown +export type AuthLogoutV1ApiArg = void export type AuthNonce = { nonce: string } @@ -31,4 +37,5 @@ export type SiweDto = { message: string signature: string } -export const { useAuthGetNonceV1Query, useAuthVerifyV1Mutation } = injectedRtkApi +export const { useAuthGetNonceV1Query, useLazyAuthGetNonceV1Query, useAuthVerifyV1Mutation, useAuthLogoutV1Mutation } = + injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/balances.ts b/packages/store/src/gateway/AUTO_GENERATED/balances.ts index 92ad397591..c08048ccc0 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/balances.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/balances.ts @@ -37,22 +37,44 @@ export type BalancesGetBalancesV1ApiArg = { } export type BalancesGetSupportedFiatCodesV1ApiResponse = /** status 200 */ string[] export type BalancesGetSupportedFiatCodesV1ApiArg = void -export type Token = { +export type NativeToken = { address: string - decimals?: number + decimals: number logoUri: string name: string symbol: string - type: 'ERC721' | 'ERC20' | 'NATIVE_TOKEN' | 'UNKNOWN' + type: 'NATIVE_TOKEN' +} +export type Erc20Token = { + address: string + decimals: number + logoUri: string + name: string + symbol: string + type: 'ERC20' +} +export type Erc721Token = { + address: string + decimals: number + logoUri: string + name: string + symbol: string + type: 'ERC721' } export type Balance = { balance: string fiatBalance: string fiatConversion: string - tokenInfo: Token + tokenInfo: NativeToken | Erc20Token | Erc721Token + fiatBalance24hChange?: string | null } export type Balances = { fiatTotal: string items: Balance[] } -export const { useBalancesGetBalancesV1Query, useBalancesGetSupportedFiatCodesV1Query } = injectedRtkApi +export const { + useBalancesGetBalancesV1Query, + useLazyBalancesGetBalancesV1Query, + useBalancesGetSupportedFiatCodesV1Query, + useLazyBalancesGetSupportedFiatCodesV1Query, +} = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/chains.ts b/packages/store/src/gateway/AUTO_GENERATED/chains.ts index e01d846347..ec3f521933 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/chains.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/chains.ts @@ -119,6 +119,7 @@ export type Chain = { chainLogoUri?: string | null l2: boolean isTestnet: boolean + zk: boolean nativeCurrency: NativeCurrency transactionService: string blockExplorerUriTemplate: BlockExplorerUriTemplate @@ -167,9 +168,15 @@ export type IndexingStatus = { } export const { useChainsGetChainsV1Query, + useLazyChainsGetChainsV1Query, useChainsGetChainV1Query, + useLazyChainsGetChainV1Query, useChainsGetAboutChainV1Query, + useLazyChainsGetAboutChainV1Query, useChainsGetBackboneV1Query, + useLazyChainsGetBackboneV1Query, useChainsGetMasterCopiesV1Query, + useLazyChainsGetMasterCopiesV1Query, useChainsGetIndexingStatusV1Query, + useLazyChainsGetIndexingStatusV1Query, } = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/collectibles.ts b/packages/store/src/gateway/AUTO_GENERATED/collectibles.ts index bfde94a37a..577e70251a 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/collectibles.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/collectibles.ts @@ -50,4 +50,4 @@ export type CollectiblePage = { previous?: string | null results: Collectible[] } -export const { useCollectiblesGetCollectiblesV2Query } = injectedRtkApi +export const { useCollectiblesGetCollectiblesV2Query, useLazyCollectiblesGetCollectiblesV2Query } = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/community.ts b/packages/store/src/gateway/AUTO_GENERATED/community.ts index 775ae1c88f..db9750dae1 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/community.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/community.ts @@ -222,12 +222,20 @@ export type LockingEventPage = { } export const { useCommunityGetCampaignsV1Query, + useLazyCommunityGetCampaignsV1Query, useCommunityGetCampaignByIdV1Query, + useLazyCommunityGetCampaignByIdV1Query, useCommunityGetCampaignActivitiesV1Query, + useLazyCommunityGetCampaignActivitiesV1Query, useCommunityGetCampaignLeaderboardV1Query, + useLazyCommunityGetCampaignLeaderboardV1Query, useCommunityGetCampaignRankV1Query, + useLazyCommunityGetCampaignRankV1Query, useCommunityCheckEligibilityV1Mutation, useCommunityGetLeaderboardV1Query, + useLazyCommunityGetLeaderboardV1Query, useCommunityGetLockingRankV1Query, + useLazyCommunityGetLockingRankV1Query, useCommunityGetLockingHistoryV1Query, + useLazyCommunityGetLockingHistoryV1Query, } = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/contracts.ts b/packages/store/src/gateway/AUTO_GENERATED/contracts.ts index 8a13816e3c..c89b0e4c8f 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/contracts.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/contracts.ts @@ -27,4 +27,4 @@ export type Contract = { contractAbi?: object | null trustedForDelegateCall: boolean } -export const { useContractsGetContractV1Query } = injectedRtkApi +export const { useContractsGetContractV1Query, useLazyContractsGetContractV1Query } = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/data-decoded.ts b/packages/store/src/gateway/AUTO_GENERATED/data-decoded.ts index eecc3f5828..966a2c7e48 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/data-decoded.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/data-decoded.ts @@ -26,22 +26,32 @@ export type DataDecodedGetDataDecodedV1ApiArg = { chainId: string transactionDataDto: TransactionDataDto } +export type BaseDataDecoded = { + method: string + parameters?: DataDecodedParameter[] +} +export type MultiSend = { + operation: 0 | 1 + value: string + dataDecoded?: BaseDataDecoded + to: string + data?: object +} export type DataDecodedParameter = { name: string type: string value: object - valueDecoded?: ((object | null) | (object[] | null)) | null + valueDecoded?: BaseDataDecoded | MultiSend[] | null } export type DataDecoded = { method: string parameters?: DataDecodedParameter[] | null + accuracy?: 'FULL_MATCH' | 'PARTIAL_MATCH' | 'ONLY_FUNCTION_MATCH' | 'NO_MATCH' | 'UNKNOWN' } export type TransactionDataDto = { /** Hexadecimal value */ data: string /** The target Ethereum address */ to?: string - /** The wei amount being sent to a payable function */ - value?: string } export const { useDataDecodedGetDataDecodedV1Mutation } = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/delegates.ts b/packages/store/src/gateway/AUTO_GENERATED/delegates.ts index 1e0baecb34..0f8517a126 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/delegates.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/delegates.ts @@ -139,9 +139,11 @@ export type DeleteDelegateV2Dto = { } export const { useDelegatesGetDelegatesV1Query, + useLazyDelegatesGetDelegatesV1Query, useDelegatesPostDelegateV1Mutation, useDelegatesDeleteDelegateV1Mutation, useDelegatesGetDelegatesV2Query, + useLazyDelegatesGetDelegatesV2Query, useDelegatesPostDelegateV2Mutation, useDelegatesDeleteDelegateV2Mutation, } = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/messages.ts b/packages/store/src/gateway/AUTO_GENERATED/messages.ts index 35a8769012..06b5858d0e 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/messages.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/messages.ts @@ -68,41 +68,66 @@ export type MessagesUpdateMessageSignatureV1ApiArg = { messageHash: string updateMessageSignatureDto: UpdateMessageSignatureDto } +export type TypedDataDomain = { + chainId?: number + name?: string + salt?: string + verifyingContract?: string + version?: string +} +export type TypedDataParameter = { + name: string + type: string +} +export type TypedData = { + domain: TypedDataDomain + primaryType: string + types: { + [key: string]: TypedDataParameter[] + } + message: { + [key: string]: any + } +} export type AddressInfo = { value: string name?: string | null logoUri?: string | null } +export type MessageConfirmation = { + owner: AddressInfo + signature: string +} export type Message = { messageHash: string - status: string + status: 'NEEDS_CONFIRMATION' | 'CONFIRMED' logoUri?: string | null name?: string | null - message: object + message: string | TypedData creationTimestamp: number modifiedTimestamp: number confirmationsSubmitted: number confirmationsRequired: number proposedBy: AddressInfo - confirmations: string[] + confirmations: MessageConfirmation[] preparedSignature?: string | null origin?: string | null } export type MessageItem = { messageHash: string - status: string + status: 'NEEDS_CONFIRMATION' | 'CONFIRMED' logoUri?: string | null name?: string | null - message: object + message: string | TypedData creationTimestamp: number modifiedTimestamp: number confirmationsSubmitted: number confirmationsRequired: number proposedBy: AddressInfo - confirmations: string[] + confirmations: MessageConfirmation[] preparedSignature?: string | null origin?: string | null - type: string + type: 'MESSAGE' } export type DateLabel = { type: 'DATE_LABEL' @@ -115,7 +140,7 @@ export type MessagePage = { results: (MessageItem | DateLabel)[] } export type CreateMessageDto = { - message: object + message: string | TypedData safeAppId?: number | null signature: string origin?: string | null @@ -125,7 +150,9 @@ export type UpdateMessageSignatureDto = { } export const { useMessagesGetMessageByHashV1Query, + useLazyMessagesGetMessageByHashV1Query, useMessagesGetMessagesBySafeV1Query, + useLazyMessagesGetMessagesBySafeV1Query, useMessagesCreateMessageV1Mutation, useMessagesUpdateMessageSignatureV1Mutation, } = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/notifications.ts b/packages/store/src/gateway/AUTO_GENERATED/notifications.ts index f93f99582d..c099302184 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/notifications.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/notifications.ts @@ -36,6 +36,17 @@ const injectedRtkApi = api }), invalidatesTags: ['notifications'], }), + notificationsDeleteAllSubscriptionsV2: build.mutation< + NotificationsDeleteAllSubscriptionsV2ApiResponse, + NotificationsDeleteAllSubscriptionsV2ApiArg + >({ + query: (queryArg) => ({ + url: `/v2/notifications/subscriptions`, + method: 'DELETE', + body: queryArg.deleteAllSubscriptionsDto, + }), + invalidatesTags: ['notifications'], + }), notificationsDeleteDeviceV2: build.mutation< NotificationsDeleteDeviceV2ApiResponse, NotificationsDeleteDeviceV2ApiArg @@ -93,6 +104,10 @@ export type NotificationsDeleteSubscriptionV2ApiArg = { chainId: string safeAddress: string } +export type NotificationsDeleteAllSubscriptionsV2ApiResponse = unknown +export type NotificationsDeleteAllSubscriptionsV2ApiArg = { + deleteAllSubscriptionsDto: DeleteAllSubscriptionsDto +} export type NotificationsDeleteDeviceV2ApiResponse = unknown export type NotificationsDeleteDeviceV2ApiArg = { chainId: string @@ -133,6 +148,20 @@ export type UpsertSubscriptionsDto = { deviceType: DeviceType deviceUuid?: string | null } +export type DeleteAllSubscriptionItemDto = { + chainId: string + deviceUuid: string + safeAddress: string + /** Optional signer address filter: + • Omitted (undefined): Deletes subscriptions regardless of signer address + • null: Deletes only subscriptions with no signer address + • Valid address: Deletes only subscriptions with that specific signer address */ + signerAddress?: string | null +} +export type DeleteAllSubscriptionsDto = { + /** At least one subscription is required */ + subscriptions: DeleteAllSubscriptionItemDto[] +} export type SafeRegistration = { chainId: string safes: string[] @@ -151,7 +180,9 @@ export type RegisterDeviceDto = { export const { useNotificationsUpsertSubscriptionsV2Mutation, useNotificationsGetSafeSubscriptionV2Query, + useLazyNotificationsGetSafeSubscriptionV2Query, useNotificationsDeleteSubscriptionV2Mutation, + useNotificationsDeleteAllSubscriptionsV2Mutation, useNotificationsDeleteDeviceV2Mutation, useNotificationsRegisterDeviceV1Mutation, useNotificationsUnregisterDeviceV1Mutation, diff --git a/packages/store/src/gateway/AUTO_GENERATED/owners.ts b/packages/store/src/gateway/AUTO_GENERATED/owners.ts index 1495f87dba..2a85ad5625 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/owners.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/owners.ts @@ -42,6 +42,9 @@ export type SafeList = { } export const { useOwnersGetSafesByOwnerV1Query, + useLazyOwnersGetSafesByOwnerV1Query, useOwnersGetAllSafesByOwnerV1Query, + useLazyOwnersGetAllSafesByOwnerV1Query, useOwnersGetAllSafesByOwnerV2Query, + useLazyOwnersGetAllSafesByOwnerV2Query, } = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/relay.ts b/packages/store/src/gateway/AUTO_GENERATED/relay.ts index 59c1cf9874..b34ef5ecc4 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/relay.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/relay.ts @@ -18,16 +18,19 @@ const injectedRtkApi = api overrideExisting: false, }) export { injectedRtkApi as cgwApi } -export type RelayRelayV1ApiResponse = unknown +export type RelayRelayV1ApiResponse = /** status 200 */ Relay export type RelayRelayV1ApiArg = { chainId: string relayDto: RelayDto } -export type RelayGetRelaysRemainingV1ApiResponse = unknown +export type RelayGetRelaysRemainingV1ApiResponse = /** status 200 */ RelaysRemaining export type RelayGetRelaysRemainingV1ApiArg = { chainId: string safeAddress: string } +export type Relay = { + taskId: string +} export type RelayDto = { version: string to: string @@ -37,4 +40,9 @@ export type RelayDto = { Gelato Relay execution overhead, reducing the chance of the task cancelling before it is executed on-chain. */ gasLimit?: string | null } -export const { useRelayRelayV1Mutation, useRelayGetRelaysRemainingV1Query } = injectedRtkApi +export type RelaysRemaining = { + remaining: number + limit: number +} +export const { useRelayRelayV1Mutation, useRelayGetRelaysRemainingV1Query, useLazyRelayGetRelaysRemainingV1Query } = + injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/safe-apps.ts b/packages/store/src/gateway/AUTO_GENERATED/safe-apps.ts index ca7e599756..7676871b46 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/safe-apps.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/safe-apps.ts @@ -53,4 +53,4 @@ export type SafeApp = { socialProfiles: SafeAppSocialProfile[] featured: boolean } -export const { useSafeAppsGetSafeAppsV1Query } = injectedRtkApi +export const { useSafeAppsGetSafeAppsV1Query, useLazySafeAppsGetSafeAppsV1Query } = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/safes.ts b/packages/store/src/gateway/AUTO_GENERATED/safes.ts index 059e249054..fb87c5994c 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/safes.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/safes.ts @@ -84,4 +84,11 @@ export type SafeOverview = { queued: number awaitingConfirmation?: number | null } -export const { useSafesGetSafeV1Query, useSafesGetNoncesV1Query, useSafesGetSafeOverviewV1Query } = injectedRtkApi +export const { + useSafesGetSafeV1Query, + useLazySafesGetSafeV1Query, + useSafesGetNoncesV1Query, + useLazySafesGetNoncesV1Query, + useSafesGetSafeOverviewV1Query, + useLazySafesGetSafeOverviewV1Query, +} = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/spaces.ts b/packages/store/src/gateway/AUTO_GENERATED/spaces.ts new file mode 100644 index 0000000000..a9c39bfa4d --- /dev/null +++ b/packages/store/src/gateway/AUTO_GENERATED/spaces.ts @@ -0,0 +1,299 @@ +import { cgwClient as api } from '../cgwClient' +export const addTagTypes = ['spaces'] as const +const injectedRtkApi = api + .enhanceEndpoints({ + addTagTypes, + }) + .injectEndpoints({ + endpoints: (build) => ({ + spacesCreateV1: build.mutation({ + query: (queryArg) => ({ url: `/v1/spaces`, method: 'POST', body: queryArg.createSpaceDto }), + invalidatesTags: ['spaces'], + }), + spacesGetV1: build.query({ + query: () => ({ url: `/v1/spaces` }), + providesTags: ['spaces'], + }), + spacesCreateWithUserV1: build.mutation({ + query: (queryArg) => ({ url: `/v1/spaces/create-with-user`, method: 'POST', body: queryArg.createSpaceDto }), + invalidatesTags: ['spaces'], + }), + spacesGetOneV1: build.query({ + query: (queryArg) => ({ url: `/v1/spaces/${queryArg.id}` }), + providesTags: ['spaces'], + }), + spacesUpdateV1: build.mutation({ + query: (queryArg) => ({ url: `/v1/spaces/${queryArg.id}`, method: 'PATCH', body: queryArg.updateSpaceDto }), + invalidatesTags: ['spaces'], + }), + spacesDeleteV1: build.mutation({ + query: (queryArg) => ({ url: `/v1/spaces/${queryArg.id}`, method: 'DELETE' }), + invalidatesTags: ['spaces'], + }), + spaceSafesCreateV1: build.mutation({ + query: (queryArg) => ({ + url: `/v1/spaces/${queryArg.spaceId}/safes`, + method: 'POST', + body: queryArg.createSpaceSafesDto, + }), + invalidatesTags: ['spaces'], + }), + spaceSafesGetV1: build.query({ + query: (queryArg) => ({ url: `/v1/spaces/${queryArg.spaceId}/safes` }), + providesTags: ['spaces'], + }), + spaceSafesDeleteV1: build.mutation({ + query: (queryArg) => ({ + url: `/v1/spaces/${queryArg.spaceId}/safes`, + method: 'DELETE', + body: queryArg.deleteSpaceSafesDto, + }), + invalidatesTags: ['spaces'], + }), + membersInviteUserV1: build.mutation({ + query: (queryArg) => ({ + url: `/v1/spaces/${queryArg.spaceId}/members/invite`, + method: 'POST', + body: queryArg.inviteUsersDto, + }), + invalidatesTags: ['spaces'], + }), + membersAcceptInviteV1: build.mutation({ + query: (queryArg) => ({ + url: `/v1/spaces/${queryArg.spaceId}/members/accept`, + method: 'POST', + body: queryArg.acceptInviteDto, + }), + invalidatesTags: ['spaces'], + }), + membersDeclineInviteV1: build.mutation({ + query: (queryArg) => ({ url: `/v1/spaces/${queryArg.spaceId}/members/decline`, method: 'POST' }), + invalidatesTags: ['spaces'], + }), + membersGetUsersV1: build.query({ + query: (queryArg) => ({ url: `/v1/spaces/${queryArg.spaceId}/members` }), + providesTags: ['spaces'], + }), + membersSelfRemoveV1: build.mutation({ + query: (queryArg) => ({ url: `/v1/spaces/${queryArg.spaceId}/members`, method: 'DELETE' }), + invalidatesTags: ['spaces'], + }), + membersUpdateRoleV1: build.mutation({ + query: (queryArg) => ({ + url: `/v1/spaces/${queryArg.spaceId}/members/${queryArg.userId}/role`, + method: 'PATCH', + body: queryArg.updateRoleDto, + }), + invalidatesTags: ['spaces'], + }), + membersUpdateAliasV1: build.mutation({ + query: (queryArg) => ({ + url: `/v1/spaces/${queryArg.spaceId}/members/alias`, + method: 'PATCH', + body: queryArg.updateMemberAliasDto, + }), + invalidatesTags: ['spaces'], + }), + membersRemoveUserV1: build.mutation({ + query: (queryArg) => ({ url: `/v1/spaces/${queryArg.spaceId}/members/${queryArg.userId}`, method: 'DELETE' }), + invalidatesTags: ['spaces'], + }), + }), + overrideExisting: false, + }) +export { injectedRtkApi as cgwApi } +export type SpacesCreateV1ApiResponse = /** status 200 Space created */ CreateSpaceResponse +export type SpacesCreateV1ApiArg = { + createSpaceDto: CreateSpaceDto +} +export type SpacesGetV1ApiResponse = /** status 200 Spaces found */ GetSpaceResponse[] +export type SpacesGetV1ApiArg = void +export type SpacesCreateWithUserV1ApiResponse = /** status 200 Space created */ CreateSpaceResponse +export type SpacesCreateWithUserV1ApiArg = { + createSpaceDto: CreateSpaceDto +} +export type SpacesGetOneV1ApiResponse = /** status 200 Space found */ GetSpaceResponse +export type SpacesGetOneV1ApiArg = { + id: number +} +export type SpacesUpdateV1ApiResponse = /** status 200 Space updated */ UpdateSpaceResponse +export type SpacesUpdateV1ApiArg = { + id: number + updateSpaceDto: UpdateSpaceDto +} +export type SpacesDeleteV1ApiResponse = unknown +export type SpacesDeleteV1ApiArg = { + id: number +} +export type SpaceSafesCreateV1ApiResponse = unknown +export type SpaceSafesCreateV1ApiArg = { + spaceId: number + createSpaceSafesDto: CreateSpaceSafesDto +} +export type SpaceSafesGetV1ApiResponse = /** status 200 Safes fetched successfully */ GetSpaceSafeResponse +export type SpaceSafesGetV1ApiArg = { + spaceId: number +} +export type SpaceSafesDeleteV1ApiResponse = unknown +export type SpaceSafesDeleteV1ApiArg = { + spaceId: number + deleteSpaceSafesDto: DeleteSpaceSafesDto +} +export type MembersInviteUserV1ApiResponse = /** status 200 Users invited */ Invitation[] +export type MembersInviteUserV1ApiArg = { + spaceId: number + inviteUsersDto: InviteUsersDto +} +export type MembersAcceptInviteV1ApiResponse = unknown +export type MembersAcceptInviteV1ApiArg = { + spaceId: number + acceptInviteDto: AcceptInviteDto +} +export type MembersDeclineInviteV1ApiResponse = unknown +export type MembersDeclineInviteV1ApiArg = { + spaceId: number +} +export type MembersGetUsersV1ApiResponse = /** status 200 Space and members list */ MembersDto +export type MembersGetUsersV1ApiArg = { + spaceId: number +} +export type MembersSelfRemoveV1ApiResponse = unknown +export type MembersSelfRemoveV1ApiArg = { + spaceId: number +} +export type MembersUpdateRoleV1ApiResponse = unknown +export type MembersUpdateRoleV1ApiArg = { + spaceId: number + userId: number + updateRoleDto: UpdateRoleDto +} +export type MembersUpdateAliasV1ApiResponse = unknown +export type MembersUpdateAliasV1ApiArg = { + spaceId: number + updateMemberAliasDto: UpdateMemberAliasDto +} +export type MembersRemoveUserV1ApiResponse = unknown +export type MembersRemoveUserV1ApiArg = { + spaceId: number + userId: number +} +export type CreateSpaceResponse = { + name: string + id: number +} +export type CreateSpaceDto = { + name: string +} +export type UserDto = { + id: number + status: 'PENDING' | 'ACTIVE' +} +export type MemberDto = { + id: number + role: 'ADMIN' | 'MEMBER' + name: string + invitedBy: string + status: 'INVITED' | 'ACTIVE' | 'DECLINED' + createdAt: string + updatedAt: string + user: UserDto +} +export type GetSpaceResponse = { + id: number + name: string + status: 'ACTIVE' + members: MemberDto[] +} +export type UpdateSpaceResponse = { + id: number +} +export type UpdateSpaceDto = { + name?: string + status?: 'ACTIVE' +} +export type CreateSpaceSafeDto = { + chainId: string + address: string +} +export type CreateSpaceSafesDto = { + safes: CreateSpaceSafeDto[] +} +export type GetSpaceSafeResponse = { + safes: { + [key: string]: string[] + } +} +export type DeleteSpaceSafeDto = { + chainId: string + address: string +} +export type DeleteSpaceSafesDto = { + safes: DeleteSpaceSafeDto[] +} +export type Invitation = { + userId: number + name: string + spaceId: number + role: 'ADMIN' | 'MEMBER' + status: 'INVITED' | 'ACTIVE' | 'DECLINED' + invitedBy?: string | null +} +export type InviteUserDto = { + address: string + name: string + role: 'ADMIN' | 'MEMBER' +} +export type InviteUsersDto = { + users: InviteUserDto[] +} +export type AcceptInviteDto = { + name: string +} +export type MemberUser = { + id: number + status: 'PENDING' | 'ACTIVE' +} +export type Member = { + id: number + role: 'ADMIN' | 'MEMBER' + status: 'INVITED' | 'ACTIVE' | 'DECLINED' + name: string + alias?: string | null + invitedBy?: string | null + createdAt: string + updatedAt: string + user: MemberUser +} +export type MembersDto = { + members: Member[] +} +export type UpdateRoleDto = { + role: 'ADMIN' | 'MEMBER' +} +export type UpdateMemberAliasDto = { + /** The new alias for the member */ + alias: string +} +export const { + useSpacesCreateV1Mutation, + useSpacesGetV1Query, + useLazySpacesGetV1Query, + useSpacesCreateWithUserV1Mutation, + useSpacesGetOneV1Query, + useLazySpacesGetOneV1Query, + useSpacesUpdateV1Mutation, + useSpacesDeleteV1Mutation, + useSpaceSafesCreateV1Mutation, + useSpaceSafesGetV1Query, + useLazySpaceSafesGetV1Query, + useSpaceSafesDeleteV1Mutation, + useMembersInviteUserV1Mutation, + useMembersAcceptInviteV1Mutation, + useMembersDeclineInviteV1Mutation, + useMembersGetUsersV1Query, + useLazyMembersGetUsersV1Query, + useMembersSelfRemoveV1Mutation, + useMembersUpdateRoleV1Mutation, + useMembersUpdateAliasV1Mutation, + useMembersRemoveUserV1Mutation, +} = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/targeted-messages.ts b/packages/store/src/gateway/AUTO_GENERATED/targeted-messages.ts index fa291291cf..26f1fa92a5 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/targeted-messages.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/targeted-messages.ts @@ -6,6 +6,15 @@ const injectedRtkApi = api }) .injectEndpoints({ endpoints: (build) => ({ + targetedMessagingGetTargetedSafeV1: build.query< + TargetedMessagingGetTargetedSafeV1ApiResponse, + TargetedMessagingGetTargetedSafeV1ApiArg + >({ + query: (queryArg) => ({ + url: `/v1/targeted-messaging/outreaches/${queryArg.outreachId}/chains/${queryArg.chainId}/safes/${queryArg.safeAddress}`, + }), + providesTags: ['targeted-messaging'], + }), targetedMessagingGetSubmissionV1: build.query< TargetedMessagingGetSubmissionV1ApiResponse, TargetedMessagingGetSubmissionV1ApiArg @@ -30,6 +39,12 @@ const injectedRtkApi = api overrideExisting: false, }) export { injectedRtkApi as cgwApi } +export type TargetedMessagingGetTargetedSafeV1ApiResponse = /** status 200 */ TargetedSafe +export type TargetedMessagingGetTargetedSafeV1ApiArg = { + outreachId: number + chainId: string + safeAddress: string +} export type TargetedMessagingGetSubmissionV1ApiResponse = /** status 200 */ Submission export type TargetedMessagingGetSubmissionV1ApiArg = { outreachId: number @@ -45,6 +60,10 @@ export type TargetedMessagingCreateSubmissionV1ApiArg = { signerAddress: string createSubmissionDto: CreateSubmissionDto } +export type TargetedSafe = { + outreachId: number + address: string +} export type Submission = { outreachId: number targetedSafeId: number @@ -54,5 +73,10 @@ export type Submission = { export type CreateSubmissionDto = { completed: boolean } -export const { useTargetedMessagingGetSubmissionV1Query, useTargetedMessagingCreateSubmissionV1Mutation } = - injectedRtkApi +export const { + useTargetedMessagingGetTargetedSafeV1Query, + useLazyTargetedMessagingGetTargetedSafeV1Query, + useTargetedMessagingGetSubmissionV1Query, + useLazyTargetedMessagingGetSubmissionV1Query, + useTargetedMessagingCreateSubmissionV1Mutation, +} = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/transactions.ts b/packages/store/src/gateway/AUTO_GENERATED/transactions.ts index 7650d45f71..e521478b92 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/transactions.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/transactions.ts @@ -13,6 +13,52 @@ const injectedRtkApi = api query: (queryArg) => ({ url: `/v1/chains/${queryArg.chainId}/transactions/${queryArg.id}` }), providesTags: ['transactions'], }), + transactionsGetDomainMultisigTransactionBySafeTxHashV1: build.query< + TransactionsGetDomainMultisigTransactionBySafeTxHashV1ApiResponse, + TransactionsGetDomainMultisigTransactionBySafeTxHashV1ApiArg + >({ + query: (queryArg) => ({ + url: `/v1/chains/${queryArg.chainId}/multisig-transactions/${queryArg.safeTxHash}/raw`, + }), + providesTags: ['transactions'], + }), + transactionsGetDomainMultisigTransactionsV1: build.query< + TransactionsGetDomainMultisigTransactionsV1ApiResponse, + TransactionsGetDomainMultisigTransactionsV1ApiArg + >({ + query: (queryArg) => ({ + url: `/v1/chains/${queryArg.chainId}/safes/${queryArg.safeAddress}/multisig-transactions/raw`, + params: { + failed: queryArg.failed, + modified__lt: queryArg.modifiedLt, + modified__gt: queryArg.modifiedGt, + modified__lte: queryArg.modifiedLte, + modified__gte: queryArg.modifiedGte, + nonce__lt: queryArg.nonceLt, + nonce__gt: queryArg.nonceGt, + nonce__lte: queryArg.nonceLte, + nonce__gte: queryArg.nonceGte, + nonce: queryArg.nonce, + safe_tx_hash: queryArg.safeTxHash, + to: queryArg.to, + value__lt: queryArg.valueLt, + value__gt: queryArg.valueGt, + value: queryArg.value, + executed: queryArg.executed, + has_confirmations: queryArg.hasConfirmations, + trusted: queryArg.trusted, + execution_date__gte: queryArg.executionDateGte, + execution_date__lte: queryArg.executionDateLte, + submission_date__gte: queryArg.submissionDateGte, + submission_date__lte: queryArg.submissionDateLte, + transaction_hash: queryArg.transactionHash, + ordering: queryArg.ordering, + limit: queryArg.limit, + offset: queryArg.offset, + }, + }), + providesTags: ['transactions'], + }), transactionsGetMultisigTransactionsV1: build.query< TransactionsGetMultisigTransactionsV1ApiResponse, TransactionsGetMultisigTransactionsV1ApiArg @@ -146,6 +192,13 @@ const injectedRtkApi = api }), providesTags: ['transactions'], }), + transactionsGetDomainCreationTransactionV1: build.query< + TransactionsGetDomainCreationTransactionV1ApiResponse, + TransactionsGetDomainCreationTransactionV1ApiArg + >({ + query: (queryArg) => ({ url: `/v1/chains/${queryArg.chainId}/safes/${queryArg.safeAddress}/creation/raw` }), + providesTags: ['transactions'], + }), }), overrideExisting: false, }) @@ -155,6 +208,43 @@ export type TransactionsGetTransactionByIdV1ApiArg = { chainId: string id: string } +export type TransactionsGetDomainMultisigTransactionBySafeTxHashV1ApiResponse = + /** status 200 */ TxsMultisigTransaction +export type TransactionsGetDomainMultisigTransactionBySafeTxHashV1ApiArg = { + chainId: string + safeTxHash: string +} +export type TransactionsGetDomainMultisigTransactionsV1ApiResponse = /** status 200 */ TxsMultisigTransactionPage +export type TransactionsGetDomainMultisigTransactionsV1ApiArg = { + chainId: string + safeAddress: string + failed?: boolean + modifiedLt?: string + modifiedGt?: string + modifiedLte?: string + modifiedGte?: string + nonceLt?: number + nonceGt?: number + nonceLte?: number + nonceGte?: number + nonce?: number + safeTxHash?: string + to?: string + valueLt?: number + valueGt?: number + value?: number + executed?: boolean + hasConfirmations?: boolean + trusted?: boolean + executionDateGte?: string + executionDateLte?: string + submissionDateGte?: string + submissionDateLte?: string + transactionHash?: string + ordering?: string + limit?: number + offset?: number +} export type TransactionsGetMultisigTransactionsV1ApiResponse = /** status 200 */ MultisigTransactionPage export type TransactionsGetMultisigTransactionsV1ApiArg = { chainId: string @@ -234,6 +324,11 @@ export type TransactionsGetCreationTransactionV1ApiArg = { chainId: string safeAddress: string } +export type TransactionsGetDomainCreationTransactionV1ApiResponse = /** status 200 */ TxsCreationTransaction +export type TransactionsGetDomainCreationTransactionV1ApiArg = { + chainId: string + safeAddress: string +} export type AddressInfo = { value: string name?: string | null @@ -258,34 +353,85 @@ export type CustomTransactionInfo = { methodName?: string | null actionCount?: number | null } +export type BaseDataDecoded = { + method: string + parameters?: DataDecodedParameter[] +} +export type MultiSend = { + operation: 0 | 1 + value: string + dataDecoded?: BaseDataDecoded + to: string + data?: object +} export type DataDecodedParameter = { name: string type: string value: object - valueDecoded?: ((object | null) | (object[] | null)) | null + valueDecoded?: BaseDataDecoded | MultiSend[] | null } export type DataDecoded = { method: string parameters?: DataDecodedParameter[] | null + accuracy?: 'FULL_MATCH' | 'PARTIAL_MATCH' | 'ONLY_FUNCTION_MATCH' | 'NO_MATCH' | 'UNKNOWN' +} +export type AddOwner = { + type: 'ADD_OWNER' + owner: AddressInfo + threshold: number +} +export type ChangeMasterCopy = { + type: 'CHANGE_MASTER_COPY' + implementation: AddressInfo +} +export type ChangeThreshold = { + type: 'CHANGE_THRESHOLD' + threshold: number +} +export type DeleteGuard = { + type: 'DELETE_GUARD' +} +export type DisableModule = { + type: 'DISABLE_MODULE' + module: AddressInfo +} +export type EnableModule = { + type: 'ENABLE_MODULE' + module: AddressInfo } -export type SettingsChange = { - type: - | 'ADD_OWNER' - | 'CHANGE_MASTER_COPY' - | 'CHANGE_THRESHOLD' - | 'DELETE_GUARD' - | 'DISABLE_MODULE' - | 'ENABLE_MODULE' - | 'REMOVE_OWNER' - | 'SET_FALLBACK_HANDLER' - | 'SET_GUARD' - | 'SWAP_OWNER' +export type RemoveOwner = { + type: 'REMOVE_OWNER' + owner: AddressInfo + threshold: number +} +export type SetFallbackHandler = { + type: 'SET_FALLBACK_HANDLER' + handler: AddressInfo +} +export type SetGuard = { + type: 'SET_GUARD' + guard: AddressInfo +} +export type SwapOwner = { + type: 'SWAP_OWNER' + oldOwner: AddressInfo + newOwner: AddressInfo } export type SettingsChangeTransaction = { type: 'SettingsChange' humanDescription?: string | null dataDecoded: DataDecoded - settingsInfo?: SettingsChange | null + settingsInfo: + | AddOwner + | ChangeMasterCopy + | ChangeThreshold + | DeleteGuard + | DisableModule + | EnableModule + | RemoveOwner + | SetFallbackHandler + | SetGuard + | SwapOwner } export type Erc20Transfer = { type: 'ERC20' @@ -358,8 +504,6 @@ export type SwapOrderTransactionInfo = { /** The URL to the explorer page of the order */ explorerUrl: string /** The amount of fees paid for this order. */ - executedSurplusFee: string - /** The amount of fees paid for this order. */ executedFee: string /** The token in which the fee was paid, expressed by SURPLUS tokens (BUY tokens for SELL orders and SELL tokens for BUY orders). */ executedFeeToken: string @@ -369,6 +513,53 @@ export type SwapOrderTransactionInfo = { /** The App Data for this order */ fullAppData?: object | null } +export type BridgeFee = { + tokenAddress: string + integratorFee: string + lifiFee: string +} +export type BridgeAndSwapTransactionInfo = { + type: 'SwapAndBridge' + humanDescription?: string | null + fromToken: TokenInfo + recipient: AddressInfo + explorerUrl: string | null + status: 'NOT_FOUND' | 'INVALID' | 'PENDING' | 'DONE' | 'FAILED' | 'UNKNOWN' | 'AWAITING_EXECUTION' + substatus: + | 'WAIT_SOURCE_CONFIRMATIONS' + | 'WAIT_DESTINATION_TRANSACTION' + | 'BRIDGE_NOT_AVAILABLE' + | 'CHAIN_NOT_AVAILABLE' + | 'REFUND_IN_PROGRESS' + | 'UNKNOWN_ERROR' + | 'COMPLETED' + | 'PARTIAL' + | 'REFUNDED' + | 'INSUFFICIENT_ALLOWANCE' + | 'INSUFFICIENT_BALANCE' + | 'OUT_OF_GAS' + | 'EXPIRED' + | 'SLIPPAGE_EXCEEDED' + | 'UNKNOWN_FAILED_ERROR' + | 'UNKNOWN' + | 'AWAITING_EXECUTION' + fees: BridgeFee | null + fromAmount: string + toChain: string + toToken: TokenInfo | null + toAmount: string | null +} +export type SwapTransactionInfo = { + type: 'Swap' + humanDescription?: string | null + recipient: AddressInfo + fees: BridgeFee | null + fromToken: TokenInfo + fromAmount: string + toToken: TokenInfo + toAmount: string + lifiExplorerUrl: string | null +} export type SwapTransferTransactionInfo = { type: 'SwapTransfer' humanDescription?: string | null @@ -398,8 +589,6 @@ export type SwapTransferTransactionInfo = { /** The URL to the explorer page of the order */ explorerUrl: string /** The amount of fees paid for this order. */ - executedSurplusFee: string - /** The amount of fees paid for this order. */ executedFee: string /** The token in which the fee was paid, expressed by SURPLUS tokens (BUY tokens for SELL orders and SELL tokens for BUY orders). */ executedFeeToken: TokenInfo @@ -409,6 +598,20 @@ export type SwapTransferTransactionInfo = { /** The App Data for this order */ fullAppData?: object | null } +export type DurationAuto = { + durationType: 'AUTO' +} +export type DurationLimit = { + durationType: 'LIMIT_DURATION' + duration: string +} +export type StartTimeAtMining = { + startType: 'AT_MINING_TIME' +} +export type StartTimeAtEpoch = { + startType: 'AT_EPOCH' + epoch: number +} export type TwapOrderTransactionInfo = { type: 'TwapOrder' humanDescription?: string | null @@ -429,8 +632,6 @@ export type TwapOrderTransactionInfo = { /** The executed buy token raw amount (no decimals), or null if there are too many parts */ executedBuyAmount?: string | null /** The executed surplus fee raw amount (no decimals), or null if there are too many parts */ - executedSurplusFee?: string | null - /** The executed surplus fee raw amount (no decimals), or null if there are too many parts */ executedFee?: string | null /** The token in which the fee was paid, expressed by SURPLUS tokens (BUY tokens for SELL orders and SELL tokens for BUY orders). */ executedFeeToken: string @@ -452,9 +653,9 @@ export type TwapOrderTransactionInfo = { /** The duration of the TWAP interval */ timeBetweenParts: number /** Whether the TWAP is valid for the entire interval or not */ - durationOfPart: object + durationOfPart: DurationAuto | DurationLimit /** The start time of the TWAP */ - startTime: object + startTime: StartTimeAtMining | StartTimeAtEpoch } export type NativeStakingDepositTransactionInfo = { type: 'NativeStakingDeposit' @@ -510,6 +711,69 @@ export type NativeStakingWithdrawTransactionInfo = { tokenInfo: TokenInfo validators: string[] } +export type VaultInfo = { + address: string + name: string + description: string + dashboardUri?: string | null + logoUri: string +} +export type VaultExtraReward = { + tokenInfo: TokenInfo + nrr: number + claimable: string + claimableNext: string +} +export type VaultDepositTransactionInfo = { + type: 'VaultDeposit' + humanDescription?: string | null + value: string + baseNrr: number + fee: number + tokenInfo: TokenInfo + vaultInfo: VaultInfo + currentReward: string + additionalRewardsNrr: number + additionalRewards: VaultExtraReward[] + expectedMonthlyReward: string + expectedAnnualReward: string +} +export type VaultRedeemTransactionInfo = { + type: 'VaultRedeem' + humanDescription?: string | null + value: string + baseNrr: number + fee: number + tokenInfo: TokenInfo + vaultInfo: VaultInfo + currentReward: string + additionalRewardsNrr: number + additionalRewards: VaultExtraReward[] +} +export type NativeToken = { + address: string + decimals: number + logoUri: string + name: string + symbol: string + type: 'NATIVE_TOKEN' +} +export type Erc20Token = { + address: string + decimals: number + logoUri: string + name: string + symbol: string + type: 'ERC20' +} +export type Erc721Token = { + address: string + decimals: number + logoUri: string + name: string + symbol: string + type: 'ERC721' +} export type TransactionData = { hexData?: string | null dataDecoded?: DataDecoded | null @@ -517,15 +781,17 @@ export type TransactionData = { value?: string | null operation: number trustedDelegateCallTarget?: boolean | null - addressInfoIndex?: object | null + addressInfoIndex?: { + [key: string]: AddressInfo + } | null + tokenInfoIndex?: { + [key: string]: NativeToken | Erc20Token | Erc721Token + } | null } -export type Token = { - address: string - decimals?: number - logoUri: string - name: string - symbol: string - type: 'ERC721' | 'ERC20' | 'NATIVE_TOKEN' | 'UNKNOWN' +export type MultisigConfirmationDetails = { + signer: AddressInfo + signature?: string | null + submittedAt: number } export type MultisigExecutionDetails = { type: 'MULTISIG' @@ -538,11 +804,11 @@ export type MultisigExecutionDetails = { refundReceiver: AddressInfo safeTxHash: string executor?: AddressInfo | null - signers: string[] + signers: AddressInfo[] confirmationsRequired: number - confirmations: string[] + confirmations: MultisigConfirmationDetails[] rejectors: AddressInfo[] - gasTokenInfo?: Token | null + gasTokenInfo?: (NativeToken | Erc20Token | Erc721Token) | null trusted: boolean proposer?: AddressInfo | null proposedByDelegate?: AddressInfo | null @@ -563,11 +829,15 @@ export type TransactionDetails = { | SettingsChangeTransaction | TransferTransactionInfo | SwapOrderTransactionInfo + | BridgeAndSwapTransactionInfo + | SwapTransactionInfo | SwapTransferTransactionInfo | TwapOrderTransactionInfo | NativeStakingDepositTransactionInfo | NativeStakingValidatorsExitTransactionInfo | NativeStakingWithdrawTransactionInfo + | VaultDepositTransactionInfo + | VaultRedeemTransactionInfo safeAddress: string txId: string executedAt?: number | null @@ -578,6 +848,44 @@ export type TransactionDetails = { safeAppInfo?: SafeAppInfo | null note?: string | null } +export type TxsMultisigTransaction = { + safe: string + to: string + value: string + data: object + operation: number + gasToken: object + safeTxGas: object + baseGas: object + gasPrice: object + proposer: object + proposedByDelegate: object + refundReceiver: object + nonce: number + executionDate: object + submissionDate: string + modified: object + blockNumber: object + transactionHash: object + safeTxHash: string + executor: object + isExecuted: boolean + isSuccessful: object + ethGasPrice: object + gasUsed: object + fee: object + origin: object + confirmationsRequired: number + confirmations: object + signatures: object + trusted: boolean +} +export type TxsMultisigTransactionPage = { + count?: number | null + next?: string | null + previous?: string | null + results: TxsMultisigTransaction[] +} export type MultisigExecutionInfo = { type: 'MULTISIG' nonce: number @@ -596,11 +904,15 @@ export type Transaction = { | SettingsChangeTransaction | TransferTransactionInfo | SwapOrderTransactionInfo + | BridgeAndSwapTransactionInfo + | SwapTransactionInfo | SwapTransferTransactionInfo | TwapOrderTransactionInfo | NativeStakingDepositTransactionInfo | NativeStakingValidatorsExitTransactionInfo | NativeStakingWithdrawTransactionInfo + | VaultDepositTransactionInfo + | VaultRedeemTransactionInfo id: string txHash?: string | null timestamp: number @@ -634,7 +946,7 @@ export type ModuleTransactionPage = { results: ModuleTransaction[] } export type AddConfirmationDto = { - signedSafeTxHash: string + signature: string } export type IncomingTransfer = { type: 'TRANSACTION' @@ -654,11 +966,15 @@ export type TransactionPreview = { | SettingsChangeTransaction | TransferTransactionInfo | SwapOrderTransactionInfo + | BridgeAndSwapTransactionInfo + | SwapTransactionInfo | SwapTransferTransactionInfo | TwapOrderTransactionInfo | NativeStakingDepositTransactionInfo | NativeStakingValidatorsExitTransactionInfo | NativeStakingWithdrawTransactionInfo + | VaultDepositTransactionInfo + | VaultRedeemTransactionInfo txData: TransactionData } export type PreviewTransactionDto = { @@ -727,16 +1043,38 @@ export type CreationTransaction = { saltNonce?: string | null dataDecoded?: DataDecoded | null } +export type TxsCreationTransaction = { + created: string + creator: string + transactionHash: string + factoryAddress: string + masterCopy: object + setupData: object + saltNonce: object +} export const { useTransactionsGetTransactionByIdV1Query, + useLazyTransactionsGetTransactionByIdV1Query, + useTransactionsGetDomainMultisigTransactionBySafeTxHashV1Query, + useLazyTransactionsGetDomainMultisigTransactionBySafeTxHashV1Query, + useTransactionsGetDomainMultisigTransactionsV1Query, + useLazyTransactionsGetDomainMultisigTransactionsV1Query, useTransactionsGetMultisigTransactionsV1Query, + useLazyTransactionsGetMultisigTransactionsV1Query, useTransactionsDeleteTransactionV1Mutation, useTransactionsGetModuleTransactionsV1Query, + useLazyTransactionsGetModuleTransactionsV1Query, useTransactionsAddConfirmationV1Mutation, useTransactionsGetIncomingTransfersV1Query, + useLazyTransactionsGetIncomingTransfersV1Query, useTransactionsPreviewTransactionV1Mutation, useTransactionsGetTransactionQueueV1Query, + useLazyTransactionsGetTransactionQueueV1Query, useTransactionsGetTransactionsHistoryV1Query, + useLazyTransactionsGetTransactionsHistoryV1Query, useTransactionsProposeTransactionV1Mutation, useTransactionsGetCreationTransactionV1Query, + useLazyTransactionsGetCreationTransactionV1Query, + useTransactionsGetDomainCreationTransactionV1Query, + useLazyTransactionsGetDomainCreationTransactionV1Query, } = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/users.ts b/packages/store/src/gateway/AUTO_GENERATED/users.ts new file mode 100644 index 0000000000..0b0019eafd --- /dev/null +++ b/packages/store/src/gateway/AUTO_GENERATED/users.ts @@ -0,0 +1,76 @@ +import { cgwClient as api } from '../cgwClient' +export const addTagTypes = ['users'] as const +const injectedRtkApi = api + .enhanceEndpoints({ + addTagTypes, + }) + .injectEndpoints({ + endpoints: (build) => ({ + usersGetWithWalletsV1: build.query({ + query: () => ({ url: `/v1/users` }), + providesTags: ['users'], + }), + usersDeleteV1: build.mutation({ + query: () => ({ url: `/v1/users`, method: 'DELETE' }), + invalidatesTags: ['users'], + }), + usersCreateWithWalletV1: build.mutation({ + query: () => ({ url: `/v1/users/wallet`, method: 'POST' }), + invalidatesTags: ['users'], + }), + usersAddWalletToUserV1: build.mutation({ + query: (queryArg) => ({ url: `/v1/users/wallet/add`, method: 'POST', body: queryArg.siweDto }), + invalidatesTags: ['users'], + }), + usersDeleteWalletFromUserV1: build.mutation< + UsersDeleteWalletFromUserV1ApiResponse, + UsersDeleteWalletFromUserV1ApiArg + >({ + query: (queryArg) => ({ url: `/v1/users/wallet/${queryArg.walletAddress}`, method: 'DELETE' }), + invalidatesTags: ['users'], + }), + }), + overrideExisting: false, + }) +export { injectedRtkApi as cgwApi } +export type UsersGetWithWalletsV1ApiResponse = /** status 200 */ UserWithWallets +export type UsersGetWithWalletsV1ApiArg = void +export type UsersDeleteV1ApiResponse = unknown +export type UsersDeleteV1ApiArg = void +export type UsersCreateWithWalletV1ApiResponse = /** status 200 */ CreatedUserWithWallet +export type UsersCreateWithWalletV1ApiArg = void +export type UsersAddWalletToUserV1ApiResponse = /** status 200 */ WalletAddedToUser +export type UsersAddWalletToUserV1ApiArg = { + siweDto: SiweDto +} +export type UsersDeleteWalletFromUserV1ApiResponse = unknown +export type UsersDeleteWalletFromUserV1ApiArg = { + walletAddress: string +} +export type UserWallet = { + id: number + address: string +} +export type UserWithWallets = { + id: number + status: 0 | 1 + wallets: UserWallet[] +} +export type CreatedUserWithWallet = { + id: number +} +export type WalletAddedToUser = { + id: number +} +export type SiweDto = { + message: string + signature: string +} +export const { + useUsersGetWithWalletsV1Query, + useLazyUsersGetWithWalletsV1Query, + useUsersDeleteV1Mutation, + useUsersCreateWithWalletV1Mutation, + useUsersAddWalletToUserV1Mutation, + useUsersDeleteWalletFromUserV1Mutation, +} = injectedRtkApi diff --git a/packages/store/src/gateway/cgwClient-hooks.test.ts b/packages/store/src/gateway/cgwClient-hooks.test.ts new file mode 100644 index 0000000000..ba61c68934 --- /dev/null +++ b/packages/store/src/gateway/cgwClient-hooks.test.ts @@ -0,0 +1,74 @@ +import type { BaseQueryApi } from '@reduxjs/toolkit/query/react' +import * as cgwClient from './cgwClient' + +/** + * I had to move these tests to a separate file, otherwise they were failing when ran with the other tests. + * I think it has to do with the way we import the cgwClient + */ +describe('cgwClient hooks', () => { + let testApi: BaseQueryApi + let originalFetch: typeof global.fetch + + beforeAll(() => { + cgwClient.setBaseUrl('https://test.com') + }) + + beforeEach(() => { + originalFetch = global.fetch + // Mock fetch for all tests in this describe block + // Ensure the mocked response has a headers object for the prepareHeaders test + global.fetch = jest.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: new Headers() }), // Headers need to be mutable for set + ) + + // Reset hooks to default implementations + cgwClient.setPrepareHeadersHook((headers) => headers) + cgwClient.setHandleResponseHook(() => {}) + + testApi = { + dispatch: jest.fn(), + getState: jest.fn(), + abort: jest.fn(), + signal: new AbortController().signal, + type: 'query' as const, // Ensure 'type' is treated as a literal type + endpoint: 'testEndpoint', + extra: {}, + } as BaseQueryApi // Cast to BaseQueryApi + }) + + afterEach(() => { + // Restore original fetch + global.fetch = originalFetch + }) + + it('should call custom prepareHeadersHook and set header when fetchBaseQuery is used', async () => { + const mockHeaderFunction = jest.fn((headers: Headers, _url: string, _endpoint: string) => { + headers.set('X-Test-Header', 'test-value') + return headers + }) + + cgwClient.setPrepareHeadersHook(mockHeaderFunction) + + await cgwClient.dynamicBaseQuery('/test-prepare-headers', testApi, {}) + + expect(mockHeaderFunction).toHaveBeenCalled() + + const mockFetch = global.fetch as jest.Mock + expect(mockFetch).toHaveBeenCalled() + const request = mockFetch.mock.calls[0][0] as Request + expect(request.headers.get('X-Test-Header')).toBe('test-value') + }) + + it('should call custom handleResponseHook when fetchBaseQuery is used', async () => { + const mockResponseFunction = jest.fn() + cgwClient.setHandleResponseHook(mockResponseFunction) + + await cgwClient.dynamicBaseQuery('/test-response', testApi, {}) + + const mockFetch = global.fetch as jest.Mock + expect(mockFetch).toHaveBeenCalled() + + expect(mockResponseFunction).toHaveBeenCalled() + expect(mockResponseFunction).toHaveBeenCalledWith(expect.any(Response), '/test-response') + }) +}) diff --git a/packages/store/src/gateway/cgwClient.test.ts b/packages/store/src/gateway/cgwClient.test.ts index 4b4b12aa53..853279bb38 100644 --- a/packages/store/src/gateway/cgwClient.test.ts +++ b/packages/store/src/gateway/cgwClient.test.ts @@ -1,4 +1,5 @@ import type { FetchArgs, BaseQueryApi } from '@reduxjs/toolkit/query/react' +import * as cgwClient from './cgwClient' describe('dynamicBaseQuery', () => { const api: BaseQueryApi = { @@ -11,57 +12,85 @@ describe('dynamicBaseQuery', () => { type: 'query', } + const mockRawBaseQuery = jest.spyOn(cgwClient, 'rawBaseQuery') + beforeEach(() => { - jest.clearAllMocks() + jest.resetAllMocks() }) it('throws an error if baseUrl is not set', async () => { - jest.isolateModules(async () => { - const { dynamicBaseQuery } = await import('./cgwClient') - // Note: We do NOT set baseUrl here, so it remains null by default. - await expect(dynamicBaseQuery('/test', api, {})).rejects.toThrow( - 'baseUrl not set. Call setBaseUrl before using the cgwClient', - ) - }) + // Note: We do NOT set baseUrl here, so it remains null by default. + await expect(cgwClient.dynamicBaseQuery('/test', api, {})).rejects.toThrow( + 'baseUrl not set. Call setBaseUrl before using the cgwClient', + ) }) it('calls rawBaseQuery with correct url when baseUrl is set and args is a string', async () => { - jest.isolateModules(async () => { - // Re-import a fresh instance of the module - const { dynamicBaseQuery, setBaseUrl, rawBaseQuery } = await import('./cgwClient') - // Mock rawBaseQuery - const mockRawBaseQuery = jest.fn().mockResolvedValue({ data: 'stringResult' }) - Object.assign(rawBaseQuery, mockRawBaseQuery) - - // Set the baseUrl - setBaseUrl('http://example.com') + mockRawBaseQuery.mockResolvedValue({ data: 'stringResult' }) + // Set the baseUrl + cgwClient.setBaseUrl('http://example.com') - const result = await dynamicBaseQuery('/test', api, {}) + const result = await cgwClient.dynamicBaseQuery('/test', api, {}) - expect(mockRawBaseQuery).toHaveBeenCalledWith('http://example.com//test', api) - expect(result).toEqual({ data: 'stringResult' }) - }) + expect(mockRawBaseQuery).toHaveBeenCalledWith( + { + method: 'GET', + url: 'http://example.com/test', + credentials: 'omit', + }, + api, + {}, + ) + expect(result).toEqual({ data: 'stringResult' }) }) it('calls rawBaseQuery with correct url when baseUrl is set and args is FetchArgs', async () => { - jest.isolateModules(async () => { - const { dynamicBaseQuery, setBaseUrl, rawBaseQuery } = await import('./cgwClient') - const mockRawBaseQuery = jest.fn().mockResolvedValue({ data: 'objectResult' }) - Object.assign(rawBaseQuery, mockRawBaseQuery) + mockRawBaseQuery.mockResolvedValue({ data: 'objectResult' }) + cgwClient.setBaseUrl('http://example.com') + + const args: FetchArgs = { url: 'endpoint', method: 'POST', body: { hello: 'world' } } + const extraOptions = { extra: 'options' } + + const result = await cgwClient.dynamicBaseQuery(args, api, extraOptions) + + expect(mockRawBaseQuery).toHaveBeenCalledWith( + { + url: 'http://example.comendpoint', + method: 'POST', + body: { hello: 'world' }, + credentials: 'omit', + }, + api, + extraOptions, + ) + expect(result).toEqual({ data: 'objectResult' }) + }) - setBaseUrl('http://example.com') + it.each([ + '/v1/auth', + '/v2/register/notifications', + `/v2/chains/1/notifications/devices/${crypto.randomUUID()}/safes/0x0000000000000000000000000000000000000000`, + '/v2/chains/1/notifications/devices/0x0000000000000000000000000000000000000000', + ])('calls rawBaseQuery with credentials for %s', async (url) => { + const mockRawBaseQuery = jest.spyOn(cgwClient, 'rawBaseQuery') + mockRawBaseQuery.mockResolvedValue({ data: 'objectResult' }) + cgwClient.setBaseUrl('http://example.com') - const args: FetchArgs = { url: 'endpoint', method: 'POST', body: { hello: 'world' } } - const extraOptions = { credentials: 'include' } + const args: FetchArgs = { url, method: 'POST', body: { hello: 'world' } } + const extraOptions = { credentials: 'include' } - const result = await dynamicBaseQuery(args, api, extraOptions) + const result = await cgwClient.dynamicBaseQuery(args, api, extraOptions) - expect(mockRawBaseQuery).toHaveBeenCalledWith( - { url: 'http://example.com//endpoint', method: 'POST', body: { hello: 'world' } }, - api, - extraOptions, - ) - expect(result).toEqual({ data: 'objectResult' }) - }) + expect(mockRawBaseQuery).toHaveBeenCalledWith( + { + url: `http://example.com${url}`, + method: 'POST', + body: { hello: 'world' }, + credentials: 'include', + }, + api, + extraOptions, + ) + expect(result).toEqual({ data: 'objectResult' }) }) }) diff --git a/packages/store/src/gateway/cgwClient.ts b/packages/store/src/gateway/cgwClient.ts index 9e42e7f6ba..c67a308704 100644 --- a/packages/store/src/gateway/cgwClient.ts +++ b/packages/store/src/gateway/cgwClient.ts @@ -1,5 +1,21 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' import type { BaseQueryFn, FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query/react' +import { REHYDRATE } from 'redux-persist' +import type { UnknownAction } from '@reduxjs/toolkit' +import type { CombinedState } from '@reduxjs/toolkit/query' + +// Export these route patterns for use in platform-specific code +export const CREDENTIAL_ROUTES = [ + /\/v1\/users/, + /\/v1\/spaces/, + /\/v1\/auth/, + /\/v2\/register\/notifications$/, + /\/v2\/chains\/[^\/]+\/notifications\/devices/, +] + +export function isCredentialRoute(url: string) { + return CREDENTIAL_ROUTES.some((route) => url.match(route)) +} let baseUrl: null | string = null export const setBaseUrl = (url: string) => { @@ -9,8 +25,55 @@ export const setBaseUrl = (url: string) => { export const getBaseUrl = () => { return baseUrl } + +// Hook for customizing headers - this can be overridden by platform-specific code +type PrepareHeadersHook = (headers: Headers, url: string, endpoint: string) => Headers | Promise + +// Default implementation (does nothing) +let customPrepareHeaders: PrepareHeadersHook = (headers) => headers + +// Setter for the custom hook +export const setPrepareHeadersHook = (hook: PrepareHeadersHook) => { + customPrepareHeaders = hook +} + +// Hook for handling response - this can be overridden by platform-specific code +type HandleResponseHook = (response: Response, url: string) => void | Promise + +// Default implementation (does nothing) +let customHandleResponse: HandleResponseHook = () => {} + +// Setter for the custom hook +export const setHandleResponseHook = (hook: HandleResponseHook) => { + customHandleResponse = hook +} + export const rawBaseQuery = fetchBaseQuery({ baseUrl: '/', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + prepareHeaders: async (headers, api) => { + // Extract URL from API arguments + let url = '' + + if (typeof api.endpoint === 'string') { + url = api.endpoint + } + + if (api.arg) { + // Handle both string and object arg types + if (typeof api.arg === 'string') { + url = api.arg + } else if (typeof api.arg === 'object' && 'url' in api.arg) { + url = api.arg.url as string + } + } + + // Apply platform-specific header customization + return customPrepareHeaders(headers, url, api.endpoint as string) + }, }) export const dynamicBaseQuery: BaseQueryFn = async ( @@ -26,12 +89,45 @@ export const dynamicBaseQuery: BaseQueryFn ({}), + extractRehydrationInfo: (action: UnknownAction, { reducerPath }): CombinedState<{}, never, 'api'> | undefined => { + if (action.type === REHYDRATE && action.payload) { + // Use type assertion to tell TypeScript the expected structure + const payload = action.payload as { + [key: string]: { api?: unknown } + } + + if (payload[reducerPath] && 'api' in payload[reducerPath]) { + return payload[reducerPath].api as CombinedState<{}, never, 'api'> + } + } + return undefined + }, }) diff --git a/packages/store/src/gateway/chains/index.ts b/packages/store/src/gateway/chains/index.ts index d47946706b..a6790fd679 100644 --- a/packages/store/src/gateway/chains/index.ts +++ b/packages/store/src/gateway/chains/index.ts @@ -1,7 +1,7 @@ import { type Chain as ChainInfo } from '../AUTO_GENERATED/chains' import { createEntityAdapter, EntityState } from '@reduxjs/toolkit' import { cgwClient, getBaseUrl } from '../cgwClient' -import { QueryReturnValue, FetchBaseQueryError, FetchBaseQueryMeta } from '@reduxjs/toolkit/dist/query' +import { QueryReturnValue, FetchBaseQueryError, FetchBaseQueryMeta } from '@reduxjs/toolkit/query' export const chainsAdapter = createEntityAdapter({ selectId: (chain: ChainInfo) => chain.chainId }) export const initialState = chainsAdapter.getInitialState() diff --git a/packages/store/src/gateway/index.ts b/packages/store/src/gateway/index.ts index e7965a4c9d..00b8f49448 100644 --- a/packages/store/src/gateway/index.ts +++ b/packages/store/src/gateway/index.ts @@ -3,3 +3,5 @@ export { useTransactionsGetTransactionQueueV1Query as useGetPendingTxsQuery, useTransactionsGetTransactionsHistoryV1Query as useGetTxsHistoryQuery, } from './AUTO_GENERATED/transactions' + +export { useGetTxsHistoryInfiniteQuery } from './transactions' diff --git a/packages/store/src/gateway/safes/index.ts b/packages/store/src/gateway/safes/index.ts new file mode 100644 index 0000000000..5864d7d901 --- /dev/null +++ b/packages/store/src/gateway/safes/index.ts @@ -0,0 +1,69 @@ +import { cgwClient } from '../cgwClient' +import { SafesGetSafeOverviewV1ApiArg, SafesGetSafeOverviewV1ApiResponse } from '../AUTO_GENERATED/safes' +import type { FetchBaseQueryError } from '@reduxjs/toolkit/query' +import { addTagTypes } from '../AUTO_GENERATED/safes' + +function chunkArray(array: T[], size: number): T[][] { + const chunks: T[][] = [] + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)) + } + return chunks +} + +const MAX_SAFES_PER_REQUEST = 10 + +export const additionalSafesRtkApi = cgwClient + .enhanceEndpoints({ + addTagTypes, + }) + .injectEndpoints({ + endpoints: (build) => ({ + safesGetOverviewForMany: build.query< + SafesGetSafeOverviewV1ApiResponse, + Omit & { safes: string[] } + >({ + async queryFn(args, _api, _extraOptions, fetchWithBaseQuery) { + const { safes, currency, trusted, excludeSpam, walletAddress } = args + const chunkedSafes = chunkArray(safes, MAX_SAFES_PER_REQUEST) + + let combinedData: SafesGetSafeOverviewV1ApiResponse = [] + + // Fetch each chunk + for (const chunk of chunkedSafes) { + const chunkArg: SafesGetSafeOverviewV1ApiArg = { + currency, + safes: chunk.join(','), // convert the chunk back to comma-separated + trusted, + excludeSpam, + walletAddress, + } + + // Call the same underlying URL/params as the original endpoint + const result = await fetchWithBaseQuery({ + url: '/v1/safes', + params: { + currency: chunkArg.currency, + safes: chunkArg.safes, + trusted: chunkArg.trusted, + exclude_spam: chunkArg.excludeSpam, + wallet_address: chunkArg.walletAddress, + }, + }) + + if (result.error) { + return { error: result.error as FetchBaseQueryError } + } + + combinedData = combinedData.concat(result.data as SafesGetSafeOverviewV1ApiResponse) + } + + return { data: combinedData } + }, + providesTags: ['safes'], + }), + }), + overrideExisting: true, + }) + +export const { useSafesGetOverviewForManyQuery, useLazySafesGetOverviewForManyQuery } = additionalSafesRtkApi diff --git a/packages/store/src/gateway/transactions.ts b/packages/store/src/gateway/transactions.ts new file mode 100644 index 0000000000..7ac371a8d2 --- /dev/null +++ b/packages/store/src/gateway/transactions.ts @@ -0,0 +1,69 @@ +import { cgwClient as api } from './cgwClient' +import type { TransactionItemPage, TransactionsGetTransactionsHistoryV1ApiArg } from './AUTO_GENERATED/transactions' + +// Define types needed for infinite query +export type TxHistoryInfiniteQueryArg = Omit + +// Create an infinite query endpoint for transaction history +export const txHistoryApi = api.injectEndpoints({ + endpoints: (build) => ({ + // Infinite query version of the transaction history query + getTxsHistoryInfinite: build.infiniteQuery< + TransactionItemPage, // Page content type (entire page response) + TxHistoryInfiniteQueryArg, // Query arg type (without cursor) + string | null // Page param type (cursor) + >({ + // Define infinite query options + infiniteQueryOptions: { + initialPageParam: null, // Start with null cursor + + // Function to get the next page param + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { + // If there's no lastPage or no next page URL, return undefined + if (!lastPage || !lastPage.next) { + return undefined + } + + // Extract the cursor from the next URL using URLSearchParams + // This is more robust than using string.split when dealing with complex URLs + try { + // The URL might be a relative URL like /v1/chains/{chainId}/safes/{safeAddress}/transactions/history?cursor=XYZ&other=param + // or a full URL with hostname + const urlParts = lastPage.next.split('?') + if (urlParts.length < 2) { + return undefined // No query string in the URL + } + + const queryString = urlParts[1] + const searchParams = new URLSearchParams(queryString) + const cursor = searchParams.get('cursor') + + if (!cursor) { + return undefined + } + + return cursor + } catch (error) { + console.error('Error extracting cursor from next URL:', error) + return undefined + } + }, + }, + + // Query function + query: ({ queryArg, pageParam }) => ({ + url: `/v1/chains/${queryArg.chainId}/safes/${queryArg.safeAddress}/transactions/history`, + params: { + timezone_offset: queryArg.timezoneOffset, + trusted: queryArg.trusted, + imitation: queryArg.imitation, + timezone: queryArg.timezone, + cursor: pageParam, + }, + }), + }), + }), +}) + +// Export the generated hook directly +export const useGetTxsHistoryInfiniteQuery = txHistoryApi.endpoints.getTxsHistoryInfinite.useInfiniteQuery diff --git a/packages/store/src/gateway/types.ts b/packages/store/src/gateway/types.ts index b1d9bb2fa6..b0a4dff8d8 100644 --- a/packages/store/src/gateway/types.ts +++ b/packages/store/src/gateway/types.ts @@ -7,12 +7,24 @@ import { SwapTransferTransactionInfo, ModuleExecutionInfo, MultisigExecutionInfo, - Transaction, + AddressInfo, } from './AUTO_GENERATED/transactions' import { SafeOverview } from './AUTO_GENERATED/safes' +import { MessageItem, MessagePage, TypedData } from './AUTO_GENERATED/messages' + +export enum RPC_AUTHENTICATION { + API_KEY_PATH = 'API_KEY_PATH', + NO_AUTHENTICATION = 'NO_AUTHENTICATION', + UNKNOWN = 'UNKNOWN', +} export type ExecutionInfo = ModuleExecutionInfo | MultisigExecutionInfo +export type SafeMessageListItemType = MessageItem['type'] +export type SafeMessageStatus = MessageItem['status'] +export type SafeMessageListItem = MessagePage['results'][number] +export type TypedMessageTypes = TypedData['types'] + export enum TransactionStatus { AWAITING_CONFIRMATIONS = 'AWAITING_CONFIRMATIONS', AWAITING_EXECUTION = 'AWAITING_EXECUTION', @@ -54,6 +66,9 @@ export enum TransactionInfoType { SWAP_ORDER = 'SwapOrder', TWAP_ORDER = 'TwapOrder', SWAP_TRANSFER = 'SwapTransfer', + NATIVE_STAKING_DEPOSIT = 'NativeStakingDeposit', + NATIVE_STAKING_VALIDATORS_EXIT = 'NativeStakingValidatorsExit', + NATIVE_STAKING_WITHDRAW = 'NativeStakingWithdraw', } export enum ConflictType { @@ -89,7 +104,30 @@ export type SafeOverviewResult = { data: SafeOverview[]; error: unknown; isLoadi export type OrderTransactionInfo = SwapOrderTransactionInfo | TwapOrderTransactionInfo | SwapTransferTransactionInfo +export enum StartTimeValue { + AT_MINING_TIME = 'AT_MINING_TIME', + AT_EPOCH = 'AT_EPOCH', +} + export type PendingTransactionItems = QueuedItemPage['results'][number] export type HistoryTransactionItems = TransactionItemPage['results'][number] +// TODO: fix CGW DataDecodedParameter type. The decodedValue is typed only as an object or object[] there. +export type ActionValueDecoded = { + data: string + dataDecoded: { + method: string + parameters: { + name: string + type: string + value: string + }[] + } + operation: number + to: string + value: string +} + +export type AddressInfoIndex = Record + export type { BalancesGetSupportedFiatCodesV1ApiResponse as FiatCurrencies } from './AUTO_GENERATED/balances' diff --git a/packages/store/src/settingsSlice.ts b/packages/store/src/settingsSlice.ts new file mode 100644 index 0000000000..33478afc25 --- /dev/null +++ b/packages/store/src/settingsSlice.ts @@ -0,0 +1,9 @@ +export type EnvState = { + tenderly: { + url: string + accessToken: string + } + rpc: { + [chainId: string]: string + } +} diff --git a/packages/store/src/slices/SafeInfo/types.ts b/packages/store/src/slices/SafeInfo/types.ts new file mode 100644 index 0000000000..cc7ea1ddd9 --- /dev/null +++ b/packages/store/src/slices/SafeInfo/types.ts @@ -0,0 +1,3 @@ +import { SafeState } from '../../gateway/AUTO_GENERATED/safes' + +export type ExtendedSafeInfo = SafeState & { deployed: boolean } diff --git a/packages/store/src/slices/SafeInfo/utils.ts b/packages/store/src/slices/SafeInfo/utils.ts new file mode 100644 index 0000000000..fc219dc3b1 --- /dev/null +++ b/packages/store/src/slices/SafeInfo/utils.ts @@ -0,0 +1,21 @@ +import type { ExtendedSafeInfo } from './types' +import type { SafeState } from '../../gateway/AUTO_GENERATED/safes' + +export const defaultSafeInfo: ExtendedSafeInfo = { + address: { value: '' }, + chainId: '', + nonce: -1, + threshold: 0, + owners: [], + implementation: { value: '' }, + implementationVersionState: '' as SafeState['implementationVersionState'], + modules: null, + guard: null, + fallbackHandler: { value: '' }, + version: '', + collectiblesTag: '', + txQueuedTag: '', + txHistoryTag: '', + messagesTag: '', + deployed: true, +} diff --git a/packages/store/src/utils/persistTransformFilter.test.ts b/packages/store/src/utils/persistTransformFilter.test.ts new file mode 100644 index 0000000000..98058449d4 --- /dev/null +++ b/packages/store/src/utils/persistTransformFilter.test.ts @@ -0,0 +1,224 @@ +import createFilter, { + createWhitelistFilter, + createBlacklistFilter, + persistFilter, + Path, +} from './persistTransformFilter' + +// Interface for the transform results to fix typings +interface TransformResult { + in: (state: Record, key: string, config?: unknown) => Record + out: (state: Record, key: string, config?: unknown) => Record +} + +describe('redux-persist-transform-filter', () => { + describe('persistFilter', () => { + it('should be a function', () => { + expect(typeof persistFilter).toBe('function') + }) + + it('should return a subset, given one key', () => { + expect(persistFilter({ a: 'a', b: 'b', c: 'c' }, 'a')).toEqual({ a: 'a' }) + }) + + it('should return a subset, given an array of keys', () => { + expect(persistFilter({ a: 'a', b: 'b', c: 'c' }, ['a'])).toEqual({ a: 'a' }) + expect(persistFilter({ a: 'a', b: 'b', c: 'c' }, ['a', 'b'])).toEqual({ a: 'a', b: 'b' }) + }) + + it('should return a subset, given one key path', () => { + expect(persistFilter({ a: { b: 'b', c: 'c' }, d: 'd' }, 'a.b')).toEqual({ a: { b: 'b' } }) + expect(persistFilter({ a: { b: 'b', c: 'c' }, d: 'd' }, 'a.c')).toEqual({ a: { c: 'c' } }) + }) + + it('should return a subset, given an array of key paths', () => { + expect(persistFilter({ a: { b: 'b', c: 'c' }, d: 'd' }, ['a.b'])).toEqual({ a: { b: 'b' } }) + expect(persistFilter({ a: { b: 'b', c: 'c' }, d: 'd' }, [['a', 'b']])).toEqual({ a: { b: 'b' } }) + expect(persistFilter({ a: { b: 'b', c: 'c' }, d: 'd' }, ['a.b', 'a.c'])).toEqual({ a: { b: 'b', c: 'c' } }) + expect( + persistFilter({ a: { b: 'b', c: 'c' }, d: 'd' }, [ + ['a', 'b'], + ['a', 'c'], + ]), + ).toEqual({ a: { b: 'b', c: 'c' } }) + }) + + it('should return a subset, given an object that contains a path and a filterFunction', () => { + const store = { a: { id1: { x: true, b: 'b' }, id2: { x: true, b: 'bb' }, id3: { x: false, b: 'bbb' } }, d: 'd' } + expect( + persistFilter(store, [{ path: 'a', filterFunction: (item: unknown) => (item as { x: boolean }).x }]), + ).toEqual({ + a: { id1: { x: true, b: 'b' }, id2: { x: true, b: 'bb' } }, + }) + expect( + persistFilter(store, [{ path: 'a', filterFunction: (item: unknown) => (item as { b: string }).b === 'bb' }]), + ).toEqual({ + a: { id2: { x: true, b: 'bb' } }, + }) + }) + }) + + describe('persistFilter (blacklist)', () => { + it('should return a subset, given one key', () => { + expect(persistFilter({ a: 'a', b: 'b', c: 'c' }, 'a', 'blacklist')).toEqual({ b: 'b', c: 'c' }) + }) + + it('should return a subset, given an array of keys', () => { + expect(persistFilter({ a: 'a', b: 'b', c: 'c' }, ['a'], 'blacklist')).toEqual({ b: 'b', c: 'c' }) + expect(persistFilter({ a: 'a', b: 'b', c: 'c' }, ['a', 'b'], 'blacklist')).toEqual({ c: 'c' }) + }) + + it('should return a subset, given one key path', () => { + expect(persistFilter({ a: { b: 'b', c: 'c' }, d: 'd' }, 'a.b', 'blacklist')).toEqual({ a: { c: 'c' }, d: 'd' }) + expect(persistFilter({ a: { b: 'b', c: 'c' }, d: 'd' }, 'a.c', 'blacklist')).toEqual({ a: { b: 'b' }, d: 'd' }) + }) + + it('should return a subset, given an array of key paths', () => { + expect(persistFilter({ a: { b: 'b', c: 'c' }, d: 'd' }, ['a.b'], 'blacklist')).toEqual({ a: { c: 'c' }, d: 'd' }) + expect(persistFilter({ a: { b: 'b', c: 'c' }, d: 'd' }, [['a', 'b']], 'blacklist')).toEqual({ + a: { c: 'c' }, + d: 'd', + }) + expect(persistFilter({ a: { b: 'b', c: 'c' }, d: 'd' }, ['a.b', 'a.c'], 'blacklist')).toEqual({ a: {}, d: 'd' }) + expect( + persistFilter( + { a: { b: 'b', c: 'c' }, d: 'd' }, + [ + ['a', 'b'], + ['a', 'c'], + ], + 'blacklist', + ), + ).toEqual({ a: {}, d: 'd' }) + }) + + it('should return a subset, given an object that contains a path and a filterFunction', () => { + const store = { a: { id1: { x: true, b: 'b' }, id2: { x: true, b: 'bb' }, id3: { x: false, b: 'bbb' } }, d: 'd' } + expect( + persistFilter( + JSON.parse(JSON.stringify(store)), + [{ path: 'a', filterFunction: (item: unknown) => (item as { x: boolean }).x }], + 'blacklist', + ), + ).toEqual({ a: { id3: { x: false, b: 'bbb' } }, d: 'd' }) + expect( + persistFilter( + JSON.parse(JSON.stringify(store)), + [{ path: 'a', filterFunction: (item: unknown) => (item as { b: string }).b === 'bb' }], + 'blacklist', + ), + ).toEqual({ a: { id1: { x: true, b: 'b' }, id3: { x: false, b: 'bbb' } }, d: 'd' }) + }) + + it('should return a subset, given an object that contains a path and a filterFunction to reduce array', () => { + const store = { a: [1, 2, 3], b: 'b' } + const result = persistFilter( + JSON.parse(JSON.stringify(store)) as Record, + [{ path: 'a', filterFunction: (_item: unknown) => true }], + 'blacklist', + ) + + expect(result).toEqual({ a: [], b: 'b' }) + + expect(Object.keys(result.a || {}).length).toBe(0) + }) + }) + + describe('createFilter', () => { + it('should be a function', () => { + expect(typeof createFilter).toBe('function') + }) + + it('should return an object with in and out functions', () => { + const myFilter = createFilter('reducerName', 'a.b' as Path, 'a.c' as Path, 'whitelist') + expect(typeof myFilter).toBe('object') + expect(myFilter).toHaveProperty('in') + expect(myFilter).toHaveProperty('out') + expect(typeof myFilter.in).toBe('function') + expect(typeof myFilter.out).toBe('function') + }) + + it('should save a subset', () => { + const myFilter = createFilter('reducerName', ['a.b', 'd'] as Path[], undefined, 'whitelist') as TransformResult + + const result = myFilter.in({ a: { b: 'b', c: 'c' }, d: 'd' }, 'reducerName', {}) + + expect(result).toEqual({ a: { b: 'b' }, d: 'd' }) + }) + + it('should load a subset', () => { + const myFilter = createFilter('reducerName', undefined, ['a.b', 'd'] as Path[], 'whitelist') as TransformResult + + const result = myFilter.out({ a: { b: 'b', c: 'c' }, d: 'd' }, 'reducerName', {}) + + expect(result).toEqual({ a: { b: 'b' }, d: 'd' }) + }) + }) + + describe('createWhitelistFilter', () => { + it('should be a function', () => { + expect(typeof createWhitelistFilter).toBe('function') + }) + + it('should return an object with in and out functions', () => { + const myFilter = createWhitelistFilter('reducerName', 'a.b' as Path, 'a.c' as Path) + + expect(typeof myFilter).toBe('object') + expect(myFilter).toHaveProperty('in') + expect(myFilter).toHaveProperty('out') + expect(typeof myFilter.in).toBe('function') + expect(typeof myFilter.out).toBe('function') + }) + + it('should save a subset', () => { + const myFilter = createWhitelistFilter('reducerName', ['a.b', 'd'] as Path[]) as TransformResult + + const result = myFilter.in({ a: { b: 'b', c: 'c' }, d: 'd' }, 'reducerName', {}) + + expect(result).toEqual({ a: { b: 'b' }, d: 'd' }) + }) + + it('should load a subset', () => { + const myFilter = createWhitelistFilter('reducerName', undefined, ['a.b', 'd'] as Path[]) as TransformResult + + const result = myFilter.out({ a: { b: 'b', c: 'c' }, d: 'd' }, 'reducerName', {}) + + expect(result).toEqual({ a: { b: 'b' }, d: 'd' }) + }) + }) + + describe('createBlacklistFilter', () => { + it('should export functions', () => { + expect(typeof createBlacklistFilter).toBe('function') + }) + + it('should return an object with in and out functions', () => { + const myFilter = createBlacklistFilter('reducerName', 'a.b' as Path, 'a.c' as Path) + expect(typeof myFilter).toBe('object') + expect(myFilter).toHaveProperty('in') + expect(myFilter).toHaveProperty('out') + expect(typeof myFilter.in).toBe('function') + expect(typeof myFilter.out).toBe('function') + }) + + it('should save a subset', () => { + const state = { a: { b: 'b', c: 'c' }, d: 'd' } + const myFilter = createBlacklistFilter('reducerName', ['a.b', 'd'] as Path[]) as TransformResult + + const result = myFilter.in(state, 'reducerName', {}) + + expect(result).toEqual({ a: { c: 'c' } }) + expect(state).toEqual({ a: { b: 'b', c: 'c' }, d: 'd' }) + }) + + it('should load a subset', () => { + const state = { a: { b: 'b', c: 'c' }, d: 'd' } + const myFilter = createBlacklistFilter('reducerName', undefined, ['a.b', 'd'] as Path[]) as TransformResult + + const result = myFilter.out(state, 'reducerName', {}) + + expect(result).toEqual({ a: { c: 'c' } }) + expect(state).toEqual({ a: { b: 'b', c: 'c' }, d: 'd' }) + }) + }) +}) diff --git a/packages/store/src/utils/persistTransformFilter.ts b/packages/store/src/utils/persistTransformFilter.ts new file mode 100644 index 0000000000..ec86a6caf6 --- /dev/null +++ b/packages/store/src/utils/persistTransformFilter.ts @@ -0,0 +1,154 @@ +// this is copy-paste from https://github.com/edy/redux-persist-transform-filter/tree/master +// I've changed to typescript and modified the tests to work with jest + +import { createTransform, Transform } from 'redux-persist' +import { set, get, unset, isEmpty, cloneDeep } from 'lodash' + +type TransformType = 'whitelist' | 'blacklist' +type State = Record + +export type Path = string | string[] | { path: string; filterFunction: (value: unknown) => boolean } + +export interface FilterObj { + path: string + filterFunction: (value: unknown) => boolean +} + +/** + * Filter object based on provided paths + */ +export const persistFilter = ( + state: State, + paths: Path[] | Path, + transformType: TransformType = 'whitelist', +): State => { + // Convert single path to array for consistent handling + const pathsArray: Path[] = Array.isArray(paths) ? paths : [paths] + + // For whitelist, start with empty object and add whitelisted properties + if (transformType === 'whitelist') { + const subset: State = {} + + pathsArray.forEach((path) => { + if (typeof path === 'object' && 'path' in path) { + // Handle filter function paths + const { path: pathStr, filterFunction } = path + const value = get(state, pathStr) + + if (typeof value === 'object' && value !== null) { + if (Array.isArray(value)) { + // Handle arrays + const filtered = value.filter(filterFunction) + if (filtered.length > 0) { + set(subset, pathStr, filtered) + } + } else { + // Handle objects + const filtered: Record = {} + + Object.entries(value as Record).forEach(([key, val]) => { + if (filterFunction(val)) { + filtered[key] = val + } + }) + + if (!isEmpty(filtered)) { + set(subset, pathStr, filtered) + } + } + } + } else { + // Handle string or array paths + const pathStr = Array.isArray(path) ? path.join('.') : path + const value = get(state, pathStr) + + if (typeof value !== 'undefined') { + set(subset, pathStr, value) + } + } + }) + + return subset + } else { + // For blacklist, start with deep copy of state and remove blacklisted properties + const subset = cloneDeep(state) + + pathsArray.forEach((path) => { + if (typeof path === 'object' && 'path' in path) { + // Handle filter function paths + const { path: pathStr, filterFunction } = path + const value = get(state, pathStr) + + if (typeof value === 'object' && value !== null) { + if (Array.isArray(value)) { + // For arrays, just empty the array if using filter functions + set(subset, pathStr, []) + } else { + // For objects, remove keys where filter function is true + const currentValue = get(subset, pathStr) + + if (typeof currentValue === 'object' && currentValue !== null && !Array.isArray(currentValue)) { + Object.entries(currentValue as Record).forEach(([key, val]) => { + if (filterFunction(val)) { + unset(subset, `${pathStr}.${key}`) + } + }) + } + } + } + } else { + // Handle string or array paths + const pathStr = Array.isArray(path) ? path.join('.') : path + unset(subset, pathStr) + } + }) + + return subset + } +} + +/** + * Create a filter for redux-persist + */ +export function createFilter( + reducerName: string, + inboundPaths?: Path[] | Path, + outboundPaths?: Path[] | Path, + transformType: TransformType = 'whitelist', +): Transform { + return createTransform( + // inbound + (inboundState: State): State => { + return inboundPaths ? persistFilter(inboundState, inboundPaths, transformType) : inboundState + }, + // outbound + (outboundState: State): State => { + return outboundPaths ? persistFilter(outboundState, outboundPaths, transformType) : outboundState + }, + { whitelist: [reducerName] }, + ) +} + +/** + * Create a whitelist filter for redux-persist + */ +export function createWhitelistFilter( + reducerName: string, + inboundPaths?: Path[] | Path, + outboundPaths?: Path[] | Path, +): Transform { + return createFilter(reducerName, inboundPaths, outboundPaths, 'whitelist') +} + +/** + * Create a blacklist filter for redux-persist + */ +export function createBlacklistFilter( + reducerName: string, + inboundPaths?: Path[] | Path, + outboundPaths?: Path[] | Path, +): Transform { + return createFilter(reducerName, inboundPaths, outboundPaths, 'blacklist') +} + +export default createFilter diff --git a/packages/store/tsconfig.json b/packages/store/tsconfig.json index c31f8ba2f2..7f26ed5d8d 100644 --- a/packages/store/tsconfig.json +++ b/packages/store/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../config/tsconfig/confs/base.json", + "include": ["**/*.ts"], "compilerOptions": { - "rootDir": "./" - }, - "include": ["**/*.ts"] + "baseUrl": "." + } } diff --git a/packages/utils/.gitignore b/packages/utils/.gitignore new file mode 100644 index 0000000000..358a21f8f2 --- /dev/null +++ b/packages/utils/.gitignore @@ -0,0 +1,2 @@ +# Types +src/types/contracts diff --git a/packages/utils/jest.config.js b/packages/utils/jest.config.js new file mode 100644 index 0000000000..6e6235c8e7 --- /dev/null +++ b/packages/utils/jest.config.js @@ -0,0 +1,7 @@ +const preset = require('../../config/test/presets/jest-preset') + +module.exports = { + ...preset, + collectCoverage: true, + collectCoverageFrom: ['/src/**/*.ts'], +} diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 0000000000..079674f572 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,25 @@ +{ + "private": true, + "name": "@safe-global/utils", + "description": "A collection of utility functions used across the Safe apps", + "version": "1.0.0", + "scripts": { + "test": "jest", + "test:coverage": "jest --coverage" + }, + "devDependencies": { + "@faker-js/faker": "^9.0.3", + "@types/jest": "^29.5.14", + "ethers": "^6.14.3", + "jest": "^29.7.0" + }, + "dependencies": { + "@cowprotocol/app-data": "^3.1.0", + "ts-node": "^10.9.2" + }, + "peerDependencies": { + "@safe-global/protocol-kit": "^5.x", + "@safe-global/types-kit": "^1.x", + "ethers": "^6.x" + } +} diff --git a/packages/utils/src/components/confirmation-views/BridgeTransaction/BridgeWarnings.ts b/packages/utils/src/components/confirmation-views/BridgeTransaction/BridgeWarnings.ts new file mode 100644 index 0000000000..8ed31f0264 --- /dev/null +++ b/packages/utils/src/components/confirmation-views/BridgeTransaction/BridgeWarnings.ts @@ -0,0 +1,40 @@ +export type WarningSeverity = 'warning' | 'error' + +export interface BridgeWarning { + title: string + description: string + severity: WarningSeverity +} + +export const BridgeWarnings: Record = { + DIFFERENT_SETUP: { + title: 'Different Safe setup on target chain', + description: + 'Your Safe exists on the target chain but with a different configuration. Review carefully before proceeding. Funds sent may be inaccessible if the setup is incorrect.', + severity: 'warning', + }, + NO_MULTICHAIN_SUPPORT: { + title: 'Incompatible Safe version', + description: + 'This Safe account cannot add new networks. You will not be able to claim ownership of the same address on other networks. Funds sent may be inaccessible.', + severity: 'error', + }, + SAFE_NOT_DEPLOYED: { + title: 'No ownership on target chain', + description: + 'This Safe account is not activated on the target chain. First, create the Safe, execute a test transaction, and then proceed with bridging. Funds sent may be inaccessible.', + severity: 'warning', + }, + DIFFERENT_ADDRESS: { + title: 'Unknown address', + description: + 'The recipient is not a Safe you own or a known recipient in your address book. If this address is incorrect, your funds could be lost permanently.', + severity: 'warning', + }, + UNKNOWN_CHAIN: { + title: 'The target network is not supported', + description: + 'app.safe.global does not support the network. Unless you have a wallet deployed there, we recommend not to bridge. Funds sent may be inaccessible.', + severity: 'error', + }, +} as const diff --git a/packages/utils/src/components/confirmation-views/BridgeTransaction/useBridgeWarningLogic.ts b/packages/utils/src/components/confirmation-views/BridgeTransaction/useBridgeWarningLogic.ts new file mode 100644 index 0000000000..85592d4a79 --- /dev/null +++ b/packages/utils/src/components/confirmation-views/BridgeTransaction/useBridgeWarningLogic.ts @@ -0,0 +1,71 @@ +import { useMemo } from 'react' +import { BridgeWarnings, type BridgeWarning } from './BridgeWarnings' + +export interface BridgeWarningData { + // Address comparison + isSameAddress: boolean + + // Chain and safe validation + isDestinationChainSupported: boolean + isMultiChainSafe: boolean + + // Destination safe information + otherSafeExists: boolean + hasSameSetup: boolean + + // Recipient validation + isRecipientInAddressBook: boolean + isRecipientOwnedSafe: boolean +} + +/** + * Shared hook that contains all the bridge warning logic. + * Takes platform-specific data and returns the appropriate warning to display. + * + * @param data - The bridge warning data gathered from platform-specific sources + * @returns The warning to display, or null if no warning is needed + */ +export const useBridgeWarningLogic = (data: BridgeWarningData): BridgeWarning | null => { + return useMemo(() => { + const { + isSameAddress, + isDestinationChainSupported, + isMultiChainSafe, + otherSafeExists, + hasSameSetup, + isRecipientInAddressBook, + isRecipientOwnedSafe, + } = data + + // When bridging to the same address (own safe) + if (isSameAddress) { + // Check if destination chain is supported + if (!isDestinationChainSupported) { + return BridgeWarnings.UNKNOWN_CHAIN + } + + // If safe exists on destination chain + if (otherSafeExists) { + // Check if setup matches + if (hasSameSetup) { + return null // All good, no warning needed + } + return BridgeWarnings.DIFFERENT_SETUP + } + + // Safe doesn't exist on destination chain + if (!isMultiChainSafe) { + return BridgeWarnings.NO_MULTICHAIN_SUPPORT + } + + return BridgeWarnings.SAFE_NOT_DEPLOYED + } + + // When bridging to a different address + if (!isRecipientInAddressBook && !isRecipientOwnedSafe) { + return BridgeWarnings.DIFFERENT_ADDRESS + } + + return null // No warning needed + }, [data]) +} diff --git a/packages/utils/src/components/tx/ApprovalEditor/utils/approvals.ts b/packages/utils/src/components/tx/ApprovalEditor/utils/approvals.ts new file mode 100644 index 0000000000..0313c8c657 --- /dev/null +++ b/packages/utils/src/components/tx/ApprovalEditor/utils/approvals.ts @@ -0,0 +1,6 @@ +import { ERC20__factory } from '@safe-global/utils/types/contracts' +import { id } from 'ethers' + +export const APPROVAL_SIGNATURE_HASH = id('approve(address,uint256)').slice(0, 10) +export const INCREASE_ALLOWANCE_SIGNATURE_HASH = id('increaseAllowance(address,uint256)').slice(0, 10) +export const ERC20_INTERFACE = ERC20__factory.createInterface() diff --git a/packages/utils/src/components/tx/security/blockaid/utils.ts b/packages/utils/src/components/tx/security/blockaid/utils.ts new file mode 100644 index 0000000000..f5c114b807 --- /dev/null +++ b/packages/utils/src/components/tx/security/blockaid/utils.ts @@ -0,0 +1,39 @@ +import type { + ModulesChangeManagement, + OwnershipChangeManagement, + ProxyUpgradeManagement, +} from '@safe-global/utils/services/security/modules/BlockaidModule/types' + +export const REASON_MAPPING: Record = { + raw_ether_transfer: 'transfers native currency', + signature_farming: 'is a raw signed transaction', + transfer_farming: 'transfers tokens', + approval_farming: 'approves erc20 tokens', + set_approval_for_all: 'approves all tokens of the account', + permit_farming: 'authorizes access or permissions', + seaport_farming: 'authorizes transfer of assets via Opeansea marketplace', + blur_farming: 'authorizes transfer of assets via Blur marketplace', + delegatecall_execution: 'involves a delegate call', +} +export const CLASSIFICATION_MAPPING: Record = { + known_malicious: 'to a known malicious address', + unverified_contract: 'to an unverified contract', + new_address: 'to a new address', + untrusted_address: 'to an untrusted address', + address_poisoning: 'to a poisoned address', + losing_mint: 'resulting in a mint for a new token with a significantly higher price than the known price', + losing_assets: 'resulting in a loss of assets without any compensation', + losing_trade: 'resulting in a losing trade', + drainer_contract: 'to a known drainer contract', + user_mistake: 'resulting in a loss of assets due to an innocent mistake', + gas_farming_attack: 'resulting in a waste of the account address’ gas to generate tokens for a scammer', + other: 'resulting in a malicious outcome', +} +export const CONTRACT_CHANGE_TITLES_MAPPING: Record< + ProxyUpgradeManagement['type'] | OwnershipChangeManagement['type'] | ModulesChangeManagement['type'], + string +> = { + PROXY_UPGRADE: 'This transaction will change the mastercopy of the Safe', + OWNERSHIP_CHANGE: 'This transaction will change the ownership of the Safe', + MODULES_CHANGE: 'This transaction contains a Safe modules change', +} diff --git a/packages/utils/src/components/tx/security/shared/types.ts b/packages/utils/src/components/tx/security/shared/types.ts new file mode 100644 index 0000000000..51a4d0e6d1 --- /dev/null +++ b/packages/utils/src/components/tx/security/shared/types.ts @@ -0,0 +1,24 @@ +import type { BlockaidModuleResponse } from '@safe-global/utils/services/security/modules/BlockaidModule' +import { SecuritySeverity } from '@safe-global/utils/services/security/modules/types' +import type { Dispatch, SetStateAction } from 'react' + +export type TxSecurityContextProps = { + blockaidResponse: + | { + description: BlockaidModuleResponse['description'] + classification: BlockaidModuleResponse['classification'] + reason: BlockaidModuleResponse['reason'] + warnings: NonNullable + balanceChange: BlockaidModuleResponse['balanceChange'] | undefined + severity: SecuritySeverity | undefined + contractManagement: BlockaidModuleResponse['contractManagement'] | undefined + isLoading: boolean + error: Error | undefined + } + | undefined + needsRiskConfirmation: boolean + isRiskConfirmed: boolean + setIsRiskConfirmed: Dispatch> + isRiskIgnored: boolean + setIsRiskIgnored: Dispatch> +} diff --git a/packages/utils/src/components/tx/security/shared/utils.ts b/packages/utils/src/components/tx/security/shared/utils.ts new file mode 100644 index 0000000000..f6cfc0ca83 --- /dev/null +++ b/packages/utils/src/components/tx/security/shared/utils.ts @@ -0,0 +1,20 @@ +import { SecuritySeverity } from '@safe-global/utils/services/security/modules/types' + +export const defaultSecurityContextValues = { + blockaidResponse: { + warnings: [], + description: undefined, + classification: undefined, + reason: undefined, + balanceChange: undefined, + severity: SecuritySeverity.NONE, + contractManagement: undefined, + isLoading: false, + error: undefined, + }, + needsRiskConfirmation: false, + isRiskConfirmed: false, + setIsRiskConfirmed: () => {}, + isRiskIgnored: false, + setIsRiskIgnored: () => {}, +} diff --git a/packages/utils/src/components/tx/security/tenderly/__tests__/utils.test.ts b/packages/utils/src/components/tx/security/tenderly/__tests__/utils.test.ts new file mode 100644 index 0000000000..fe558d6fe3 --- /dev/null +++ b/packages/utils/src/components/tx/security/tenderly/__tests__/utils.test.ts @@ -0,0 +1,134 @@ +import { ethers, toBeHex, ZeroAddress } from 'ethers' +import { + getStateOverwrites, + THRESHOLD_STORAGE_POSITION, + THRESHOLD_OVERWRITE, + NONCE_STORAGE_POSITION, + GUARD_STORAGE_POSITION, +} from '../utils' +import { ImplementationVersionState, type SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeTransaction, SafeSignature } from '@safe-global/types-kit' +import type { SingleTransactionSimulationParams } from '../utils' +import { faker } from '@faker-js/faker' +import { EthSafeSignature } from '@safe-global/protocol-kit' + +describe('getStateOverwrites', () => { + const mockOwners = [faker.finance.ethereumAddress(), faker.finance.ethereumAddress(), faker.finance.ethereumAddress()] + const safeAddress = faker.finance.ethereumAddress() + const mockSafe = { + address: { value: safeAddress }, + chainId: '1', + nonce: 5, + threshold: 2, + guard: { value: ZeroAddress }, + version: '1.4.1', + owners: mockOwners.map((owner) => ({ value: owner })), + implementation: { value: ZeroAddress }, + implementationVersionState: ImplementationVersionState.UP_TO_DATE, + modules: [], + fallbackHandler: { value: ZeroAddress }, + collectiblesTag: '0', + txQueuedTag: '0', + txHistoryTag: '0', + messagesTag: '0', + } + const mockSafeWithGuard = { + ...mockSafe, + guard: { value: faker.finance.ethereumAddress() }, + } + + const mockSignature = new EthSafeSignature(mockOwners[0], faker.string.hexadecimal({ length: 66 })) + + const mockTransaction: SafeTransaction = { + data: { + to: faker.finance.ethereumAddress(), + value: '0', + data: '0x', + operation: 0, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: ZeroAddress, + refundReceiver: ZeroAddress, + nonce: 5, + }, + signatures: new Map(), + getSignature: () => undefined, + addSignature: () => {}, + encodedSignatures: () => '', + } + + mockTransaction.signatures.set(mockOwners[0], mockSignature) + + it('should return empty object when no overwrites are needed', () => { + // Threshold 2, one signature in the tx and the execution owner is the second owner + const params: SingleTransactionSimulationParams = { + safe: mockSafe, + executionOwner: mockOwners[1], + transactions: mockTransaction, + } + + const result = getStateOverwrites(params) + expect(result).toEqual({}) + }) + + it('should include threshold overwrite when signatures are below threshold', () => { + const params: SingleTransactionSimulationParams = { + safe: { ...mockSafe, threshold: 3 }, + executionOwner: mockOwners[1], + transactions: mockTransaction, + } + + const result = getStateOverwrites(params) + expect(result).toEqual({ + [THRESHOLD_STORAGE_POSITION]: THRESHOLD_OVERWRITE, + }) + }) + + it('should include nonce overwrite when transaction nonce is higher than safe nonce', () => { + const params: SingleTransactionSimulationParams = { + safe: mockSafe, + executionOwner: mockOwners[1], + transactions: { ...mockTransaction, data: { ...mockTransaction.data, nonce: 6 } }, + } + + const result = getStateOverwrites(params) + expect(result).toEqual({ + [NONCE_STORAGE_POSITION]: toBeHex('0x6', 32), + }) + }) + + it('should include guard overwrite when safe has a guard', () => { + const params: SingleTransactionSimulationParams = { + safe: mockSafeWithGuard, + executionOwner: mockOwners[1], + transactions: mockTransaction, + } + + const result = getStateOverwrites(params) + expect(result).toEqual({ + [GUARD_STORAGE_POSITION]: toBeHex(ZeroAddress, 32), + }) + }) + + it('should combine multiple overwrites when multiple conditions are met', () => { + const params: SingleTransactionSimulationParams = { + safe: { ...mockSafe, guard: { value: faker.finance.ethereumAddress() }, threshold: 3 }, + executionOwner: mockOwners[1], + transactions: { + ...mockTransaction, + data: { + ...mockTransaction.data, + nonce: 6, + }, + }, + } + + const result = getStateOverwrites(params) + expect(result).toEqual({ + [THRESHOLD_STORAGE_POSITION]: THRESHOLD_OVERWRITE, + [NONCE_STORAGE_POSITION]: toBeHex('0x6', 32), + [GUARD_STORAGE_POSITION]: toBeHex(ZeroAddress, 32), + }) + }) +}) diff --git a/apps/web/src/components/tx/security/tenderly/types.ts b/packages/utils/src/components/tx/security/tenderly/types.ts similarity index 100% rename from apps/web/src/components/tx/security/tenderly/types.ts rename to packages/utils/src/components/tx/security/tenderly/types.ts diff --git a/packages/utils/src/components/tx/security/tenderly/useSimulation.ts b/packages/utils/src/components/tx/security/tenderly/useSimulation.ts new file mode 100644 index 0000000000..53418258f2 --- /dev/null +++ b/packages/utils/src/components/tx/security/tenderly/useSimulation.ts @@ -0,0 +1,20 @@ +import { FETCH_STATUS, type TenderlySimulation } from '@safe-global/utils/components/tx/security/tenderly/types' +import type { SimulationTxParams } from '@safe-global/utils/components/tx/security/tenderly/utils' + +export type UseSimulationReturn = + | { + _simulationRequestStatus: FETCH_STATUS.NOT_ASKED | FETCH_STATUS.ERROR | FETCH_STATUS.LOADING + simulation: undefined + simulateTransaction: (params: SimulationTxParams) => void + simulationLink: string + requestError?: string + resetSimulation: () => void + } + | { + _simulationRequestStatus: FETCH_STATUS.SUCCESS + simulation: TenderlySimulation + simulateTransaction: (params: SimulationTxParams) => void + simulationLink: string + requestError?: string + resetSimulation: () => void + } diff --git a/packages/utils/src/components/tx/security/tenderly/utils.ts b/packages/utils/src/components/tx/security/tenderly/utils.ts new file mode 100644 index 0000000000..34b1672ed8 --- /dev/null +++ b/packages/utils/src/components/tx/security/tenderly/utils.ts @@ -0,0 +1,192 @@ +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' +import type { MetaTransactionData, SafeTransaction } from '@safe-global/types-kit' +import { + TENDERLY_ORG_NAME, + TENDERLY_PROJECT_NAME, + TENDERLY_SIMULATE_ENDPOINT_URL, +} from '@safe-global/utils/config/constants' +import { FEATURES, hasFeature } from '@safe-global/utils/utils/chains' +import { + FETCH_STATUS, + type StateObject, + type TenderlySimulatePayload, + type TenderlySimulation, +} from '@safe-global/utils/components/tx/security/tenderly/types' +import type { EnvState } from '@safe-global/store/settingsSlice' +import { toBeHex, ZeroAddress } from 'ethers' +import { UseSimulationReturn } from './useSimulation' + +export const getSimulationLink = (simulationId: string): string => { + return `https://dashboard.tenderly.co/shared/simulation/${simulationId}` +} + +export type SingleTransactionSimulationParams = { + safe: SafeInfo + executionOwner: string + transactions: SafeTransaction + gasLimit?: number +} +export type MultiSendTransactionSimulationParams = { + safe: SafeInfo + executionOwner: string + transactions: MetaTransactionData[] + gasLimit?: number +} +export type SimulationTxParams = SingleTransactionSimulationParams | MultiSendTransactionSimulationParams +export const isTxSimulationEnabled = (chain?: Pick): boolean => { + if (!chain) { + return false + } + + const isSimulationEnvSet = + Boolean(TENDERLY_SIMULATE_ENDPOINT_URL) && Boolean(TENDERLY_ORG_NAME) && Boolean(TENDERLY_PROJECT_NAME) + + return isSimulationEnvSet && hasFeature(chain, FEATURES.TX_SIMULATION) +} +export const getSimulation = async ( + tx: TenderlySimulatePayload, + customTenderly: EnvState['tenderly'] | undefined, +): Promise => { + const requestObject: RequestInit = { + method: 'POST', + body: JSON.stringify(tx), + } + + if (customTenderly?.accessToken) { + requestObject.headers = { + 'content-type': 'application/JSON', + 'X-Access-Key': customTenderly.accessToken, + } + } + + const data = await fetch( + customTenderly?.url ? customTenderly.url : TENDERLY_SIMULATE_ENDPOINT_URL, + requestObject, + ).then((res) => { + if (res.ok) { + return res.json() + } + return res.json().then((data) => { + throw new Error(`${res.status} - ${res.statusText}: ${data?.error?.message}`) + }) + }) + + return data as TenderlySimulation +} /* We need to overwrite the nonce if we simulate a (partially) signed transaction which is not at the top position of the tx queue. + The nonce can be found in storage slot 5 and uses a full 32 bytes slot. */ +export const _getStateOverride = ( + address: string, + balance?: string, + code?: string, + storage?: Record, +): Record => { + return { + [address]: { + balance, + code, + storage, + }, + } +} +export const isSingleTransactionSimulation = ( + params: SimulationTxParams, +): params is SingleTransactionSimulationParams => { + return !Array.isArray(params.transactions) +} +/** + * @returns true for single MultiSig transactions if the provided signatures plus the current owner's signature (if missing) + * do not reach the safe's threshold. + */ +const isOverwriteThreshold = (params: SimulationTxParams) => { + if (!isSingleTransactionSimulation(params)) { + return false + } + const tx = params.transactions + const hasOwnerSig = tx.signatures.has(params.executionOwner) || tx.signatures.has(params.executionOwner.toLowerCase()) + const effectiveSigs = tx.signatures.size + (hasOwnerSig ? 0 : 1) + return params.safe.threshold > effectiveSigs +} +const getNonceOverwrite = (params: SimulationTxParams): number | undefined => { + if (!isSingleTransactionSimulation(params)) { + return + } + const txNonce = params.transactions.data.nonce + const safeNonce = params.safe.nonce + if (txNonce > safeNonce) { + return txNonce + } +} +const getGuardOverwrite = (params: SimulationTxParams): string | undefined => { + const hasGuard = params.safe.guard?.value !== undefined && params.safe.guard.value !== ZeroAddress + if (hasGuard) { + return ZeroAddress + } +} +/* We need to overwrite the threshold stored in smart contract storage to 1 + to do a proper simulation that takes transaction guards into account. + The threshold is stored in storage slot 4 and uses full 32 bytes slot. + Safe storage layout can be found here: + https://github.com/gnosis/safe-contracts/blob/main/contracts/libraries/SafeStorage.sol */ +export const THRESHOLD_STORAGE_POSITION = toBeHex('0x4', 32) +export const THRESHOLD_OVERWRITE = toBeHex('0x1', 32) +export const NONCE_STORAGE_POSITION = toBeHex('0x5', 32) +/** keccak256("guard_manager.guard.address" */ +export const GUARD_STORAGE_POSITION = '0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8' + +export const getStateOverwrites = (params: SimulationTxParams) => { + const nonceOverwrite = getNonceOverwrite(params) + const isThresholdOverwrite = isOverwriteThreshold(params) + const guardOverwrite = getGuardOverwrite(params) + const storageOverwrites: Record = {} as Record + + if (isThresholdOverwrite) { + storageOverwrites[THRESHOLD_STORAGE_POSITION] = THRESHOLD_OVERWRITE + } + if (nonceOverwrite !== undefined) { + storageOverwrites[NONCE_STORAGE_POSITION] = toBeHex('0x' + BigInt(nonceOverwrite).toString(16), 32) + } + if (guardOverwrite !== undefined) { + storageOverwrites[GUARD_STORAGE_POSITION] = toBeHex(guardOverwrite, 32) + } + + return storageOverwrites +} + +export const getCallTraceErrors = (simulation?: TenderlySimulation) => { + if (!simulation || !simulation.simulation.status) { + return [] + } + + return simulation.transaction.call_trace.filter((call) => call.error) +} + +export type SimulationStatus = { + isLoading: boolean + isFinished: boolean + isSuccess: boolean + isCallTraceError: boolean + isError: boolean +} + +export const getSimulationStatus = (simulation: UseSimulationReturn): SimulationStatus => { + const isLoading = simulation._simulationRequestStatus === FETCH_STATUS.LOADING + + const isFinished = + simulation._simulationRequestStatus === FETCH_STATUS.SUCCESS || + simulation._simulationRequestStatus === FETCH_STATUS.ERROR + + const isSuccess = simulation.simulation?.simulation.status || false + + // Safe can emit failure event even though Tenderly simulation succeeds + const isCallTraceError = isSuccess && getCallTraceErrors(simulation.simulation).length > 0 + const isError = simulation._simulationRequestStatus === FETCH_STATUS.ERROR + + return { + isLoading, + isFinished, + isSuccess, + isCallTraceError, + isError, + } +} diff --git a/packages/utils/src/config/chains.ts b/packages/utils/src/config/chains.ts new file mode 100644 index 0000000000..05243aaada --- /dev/null +++ b/packages/utils/src/config/chains.ts @@ -0,0 +1,20 @@ +import { networks } from '@safe-global/protocol-kit/dist/src/utils/eip-3770/config' + +/** + * A static shortName<->chainId dictionary + * E.g.: + * + * { + * eth: '1', + * gor: '5', + * ... + * } + */ +type Chains = Record + +const chains = networks.reduce((result, { shortName, chainId }) => { + result[shortName] = chainId.toString() + return result +}, {}) + +export default chains diff --git a/packages/utils/src/config/constants.ts b/packages/utils/src/config/constants.ts new file mode 100644 index 0000000000..77b161c588 --- /dev/null +++ b/packages/utils/src/config/constants.ts @@ -0,0 +1,53 @@ +export const LATEST_SAFE_VERSION = + process.env.NEXT_PUBLIC_SAFE_VERSION || process.env.EXPO_PUBLIC_SAFE_VERSION || '1.4.1' + +// Risk mitigation (Blockaid) +export const BLOCKAID_API = + process.env.NEXT_PUBLIC_BLOCKAID_API || process.env.EXPO_PUBLIC_BLOCKAID_API || 'https://client.blockaid.io' +export const BLOCKAID_CLIENT_ID = + process.env.NEXT_PUBLIC_BLOCKAID_CLIENT_ID || process.env.EXPO_PUBLIC_BLOCKAID_CLIENT_ID || '' +// Access keys +export const INFURA_TOKEN = process.env.NEXT_PUBLIC_INFURA_TOKEN || process.env.EXPO_PUBLIC_INFURA_TOKEN || '' +// Safe Apps +export const SAFE_APPS_INFURA_TOKEN = + process.env.NEXT_PUBLIC_SAFE_APPS_INFURA_TOKEN || process.env.EXPO_PUBLIC_SAFE_APPS_INFURA_TOKEN || INFURA_TOKEN + +// Tenderly - API docs: https://www.notion.so/Simulate-API-Documentation-6f7009fe6d1a48c999ffeb7941efc104 +export const TENDERLY_SIMULATE_ENDPOINT_URL = + process.env.NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL || process.env.EXPO_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL || '' +export const TENDERLY_PROJECT_NAME = + process.env.NEXT_PUBLIC_TENDERLY_PROJECT_NAME || process.env.EXPO_PUBLIC_TENDERLY_PROJECT_NAME || '' +export const TENDERLY_ORG_NAME = + process.env.NEXT_PUBLIC_TENDERLY_ORG_NAME || process.env.EXPO_PUBLIC_TENDERLY_ORG_NAME || '' + +// Help Center +export const HELP_CENTER_URL = 'https://help.safe.global' +export const HelpCenterArticle = { + ADDRESS_BOOK_DATA: `${HELP_CENTER_URL}/en/articles/40811-address-book-export-and-import`, + ADVANCED_PARAMS: `${HELP_CENTER_URL}/en/articles/40837-advanced-transaction-parameters`, + CANCELLING_TRANSACTIONS: `${HELP_CENTER_URL}/en/articles/40836-why-do-i-need-to-pay-for-cancelling-a-transaction`, + COOKIES: `${HELP_CENTER_URL}/en/articles/40797-why-do-i-need-to-enable-third-party-cookies-for-safe-apps`, + CONFLICTING_TRANSACTIONS: `${HELP_CENTER_URL}/en/articles/40839-why-are-transactions-with-the-same-nonce-conflicting-with-each-other`, + FALLBACK_HANDLER: `${HELP_CENTER_URL}/en/articles/40838-what-is-a-fallback-handler-and-how-does-it-relate-to-safe`, + MOBILE_SAFE: `${HELP_CENTER_URL}/en/articles/40801-connect-to-web-with-mobile-safe`, + RECOVERY: `${HELP_CENTER_URL}/en/articles/110656-account-recovery-in-safe-wallet`, + RELAYING: `${HELP_CENTER_URL}/en/articles/59203-what-is-gas-fee-sponsoring`, + SAFE_SETUP: `${HELP_CENTER_URL}/en/articles/40835-what-safe-setup-should-i-use`, + SIGNED_MESSAGES: `${HELP_CENTER_URL}/en/articles/40783-what-are-signed-messages`, + SPAM_TOKENS: `${HELP_CENTER_URL}/en/articles/40784-default-token-list-local-hiding-of-spam-tokens`, + SPENDING_LIMITS: `${HELP_CENTER_URL}/en/articles/40842-set-up-and-use-spending-limits`, + TRANSACTION_GUARD: `${HELP_CENTER_URL}/en/articles/40809-what-is-a-transaction-guard`, + UNEXPECTED_DELEGATE_CALL: `${HELP_CENTER_URL}/en/articles/40794-why-do-i-see-an-unexpected-delegate-call-warning-in-my-transaction`, + PROPOSERS: `${HELP_CENTER_URL}/en/articles/235770-proposers`, + PUSH_NOTIFICATIONS: `${HELP_CENTER_URL}/en/articles/99197-how-to-start-receiving-web-push-notifications-in-the-web-wallet`, + SWAP_WIDGET_FEES: `${HELP_CENTER_URL}/en/articles/178530-how-does-the-widget-fee-work-for-native-swaps`, + VERIFY_TX_DETAILS: `${HELP_CENTER_URL}/en/articles/276343-how-to-perform-basic-transactions-checks-on-safe-wallet`, + BULK_IMPORT_OLD_DATA: `${HELP_CENTER_URL}/en/articles/359254-new-mobile-apps-bulk-import-of-old-app-data-ios-only`, +} as const +export const HelperCenterArticleTitles = { + RECOVERY: 'Learn more about the Account recovery process', +} +// Social +export const DISCORD_URL = 'https://chat.safe.global' +export const TWITTER_URL = 'https://twitter.com/safe' +export const SAFE_TO_L2_MIGRATION_VERSION = '1.4.1' diff --git a/packages/utils/src/features/counterfactual/store/types.ts b/packages/utils/src/features/counterfactual/store/types.ts new file mode 100644 index 0000000000..2256a74191 --- /dev/null +++ b/packages/utils/src/features/counterfactual/store/types.ts @@ -0,0 +1,43 @@ +import type { SafeVersion } from '@safe-global/types-kit' +import type { PredictedSafeProps } from '@safe-global/protocol-kit' +import type { PayMethod } from '@safe-global/utils/features/counterfactual/types' + +export enum PendingSafeStatus { + AWAITING_EXECUTION = 'AWAITING_EXECUTION', + PROCESSING = 'PROCESSING', + RELAYING = 'RELAYING', +} + +export type UndeployedSafeStatus = { + status: PendingSafeStatus + type: PayMethod + txHash?: string + taskId?: string + startBlock?: number + submittedAt?: number + signerAddress?: string + signerNonce?: number | null +} +export type ReplayedSafeProps = { + factoryAddress: string + masterCopy: string + safeAccountConfig: { + threshold: number + owners: string[] + fallbackHandler: string + to: string + data: string + paymentToken?: string + payment?: number + paymentReceiver: string + } + saltNonce: string + safeVersion: SafeVersion +} +export type UndeployedSafeProps = PredictedSafeProps | ReplayedSafeProps +export type UndeployedSafe = { + status: UndeployedSafeStatus + props: UndeployedSafeProps +} +type UndeployedSafesSlice = { [address: string]: UndeployedSafe } +export type UndeployedSafesState = { [chainId: string]: UndeployedSafesSlice } diff --git a/packages/utils/src/features/counterfactual/types.ts b/packages/utils/src/features/counterfactual/types.ts new file mode 100644 index 0000000000..6443972b23 --- /dev/null +++ b/packages/utils/src/features/counterfactual/types.ts @@ -0,0 +1,4 @@ +export const enum PayMethod { + PayNow = 'PayNow', + PayLater = 'PayLater', +} diff --git a/packages/utils/src/features/swap/helpers/__tests__/utils.test.ts b/packages/utils/src/features/swap/helpers/__tests__/utils.test.ts new file mode 100644 index 0000000000..f4c0f91236 --- /dev/null +++ b/packages/utils/src/features/swap/helpers/__tests__/utils.test.ts @@ -0,0 +1,662 @@ +import { + getExecutionPrice, + getFilledPercentage, + getLimitPrice, + getPartiallyFilledSurplus, + getSurplusPrice, + isOrderPartiallyFilled, + isSettingTwapFallbackHandler, + TWAP_FALLBACK_HANDLER, + getOrderFeeBps, +} from '../utils' +import type { + DataDecoded, + TwapOrderTransactionInfo as TwapOrder, + SwapOrderTransactionInfo as SwapOrder, +} from '@safe-global/store/gateway/AUTO_GENERATED/transactions' + +describe('Swap helpers', () => { + test('sellAmount bigger than buyAmount', () => { + const mockOrder = { + executedSellAmount: '100000000000000000000', // 100 tokens + executedBuyAmount: '50000000000000000000', // 50 tokens + buyToken: { decimals: 18 }, + sellToken: { decimals: 18 }, + sellAmount: '100000000000000000000', + buyAmount: '50000000000000000000', + } as unknown as SwapOrder + + const executionPrice = getExecutionPrice(mockOrder) + const limitPrice = getLimitPrice(mockOrder) + const surplusPrice = getSurplusPrice(mockOrder) + + expect(executionPrice).toBe(2) + expect(limitPrice).toBe(2) + expect(surplusPrice).toBe(0) + }) + + test('sellAmount smaller than buyAmount', () => { + const mockOrder = { + executedSellAmount: '50000000000000000000', // 50 tokens + executedBuyAmount: '100000000000000000000', // 100 tokens + buyToken: { decimals: 18 }, + sellToken: { decimals: 18 }, + sellAmount: '50000000000000000000', + buyAmount: '100000000000000000000', + } as unknown as SwapOrder + + const executionPrice = getExecutionPrice(mockOrder) + const limitPrice = getLimitPrice(mockOrder) + const surplusPrice = getSurplusPrice(mockOrder) + + expect(executionPrice).toBe(0.5) + expect(limitPrice).toBe(0.5) + expect(surplusPrice).toBe(0) + }) + + test('buyToken has more decimals than sellToken', () => { + const mockOrder = { + executedSellAmount: '10000000000', // 100 tokens + executedBuyAmount: '50000000000000000000', // 50 tokens + buyToken: { decimals: 18 }, + sellToken: { decimals: 8 }, + sellAmount: '10000000000', + buyAmount: '50000000000000000000', + } as unknown as SwapOrder + + const executionPrice = getExecutionPrice(mockOrder) + const limitPrice = getLimitPrice(mockOrder) + const surplusPrice = getSurplusPrice(mockOrder) + + expect(executionPrice).toBe(2) + expect(limitPrice).toBe(2) + expect(surplusPrice).toBe(0) + }) + + test('sellToken has more decimals than buyToken', () => { + const mockOrder = { + executedSellAmount: '100000000000000000000', // 100 tokens + executedBuyAmount: '5000000000', // 50 tokens + buyToken: { decimals: 8 }, + sellToken: { decimals: 18 }, + sellAmount: '100000000000000000000', + buyAmount: '5000000000', + } as unknown as SwapOrder + + const executionPrice = getExecutionPrice(mockOrder) + const limitPrice = getLimitPrice(mockOrder) + const surplusPrice = getSurplusPrice(mockOrder) + + expect(executionPrice).toBe(2) + expect(limitPrice).toBe(2) + expect(surplusPrice).toBe(0) + }) + + test('twap order with unknown executed sell and buy amounts', () => { + const mockOrder = { + executedSellAmount: null, + executedBuyAmount: null, + buyToken: { decimals: 8 }, + sellToken: { decimals: 18 }, + sellAmount: '100000000000000000000', + buyAmount: '5000000000', + } as unknown as TwapOrder + + const executionPrice = getExecutionPrice(mockOrder) + const limitPrice = getLimitPrice(mockOrder) + const surplusPrice = getSurplusPrice(mockOrder) + + expect(executionPrice).toBe(0) + expect(limitPrice).toBe(2) + expect(surplusPrice).toBe(0) + }) + + describe('getFilledPercentage', () => { + it('returns 0 if no amount was executed', () => { + const mockOrder = { + executedSellAmount: '0', + executedBuyAmount: '0', + buyToken: { decimals: 8 }, + sellToken: { decimals: 18 }, + sellAmount: '100000000000000000000', + buyAmount: '5000000000', + } as unknown as SwapOrder + + const result = getFilledPercentage(mockOrder) + + expect(result).toEqual('0') + }) + + it('returns the percentage for buy orders', () => { + const mockOrder = { + executedSellAmount: '10000000000000000000', + executedBuyAmount: '50000000', + buyToken: { decimals: 8 }, + sellToken: { decimals: 18 }, + sellAmount: '100000000000000000000', + buyAmount: '5000000000', + kind: 'buy', + } as unknown as SwapOrder + + const result = getFilledPercentage(mockOrder) + + expect(result).toEqual('1') + }) + + it('returns the percentage for sell orders', () => { + const mockOrder = { + executedSellAmount: '10000000000000000000', + executedBuyAmount: '50000000', + buyToken: { decimals: 8 }, + sellToken: { decimals: 18 }, + sellAmount: '100000000000000000000', + buyAmount: '5000000000', + kind: 'sell', + } as unknown as SwapOrder + + const result = getFilledPercentage(mockOrder) + + expect(result).toEqual('10') + }) + + it('returns 0 if the executed amount is below 1%', () => { + const mockOrder = { + executedSellAmount: '10000000000000000000', + executedBuyAmount: '50', + buyToken: { decimals: 8 }, + sellToken: { decimals: 18 }, + sellAmount: '100000000000000000000', + buyAmount: '5000000000', + kind: 'buy', + } as unknown as SwapOrder + + const result = getFilledPercentage(mockOrder) + + expect(result).toEqual('0') + }) + + it('returns the surplus amount for buy orders', () => { + const mockOrder = { + executedSellAmount: '10000000000000000000', //10 + executedBuyAmount: '50', + buyToken: { decimals: 8 }, + sellToken: { decimals: 18 }, + sellAmount: '15000000000000000000', //15 + buyAmount: '5000000000', + kind: 'buy', + } as unknown as SwapOrder + + const result = getSurplusPrice(mockOrder) + + expect(result).toEqual(5) + }) + + it('returns the surplus amount for sell orders', () => { + const mockOrder = { + executedSellAmount: '100000000000000000000', + executedBuyAmount: '10000000000', //100 + buyToken: { decimals: 8 }, + sellToken: { decimals: 18 }, + sellAmount: '100000000000000000000', + buyAmount: '5000000000', //50 + kind: 'sell', + } as unknown as SwapOrder + + const result = getSurplusPrice(mockOrder) + + expect(result).toEqual(50) + }) + }) + + describe('isOrderPartiallyFilled', () => { + it('returns true if a buy order is partially filled', () => { + const mockOrder = { + executedBuyAmount: '10', + buyAmount: '100000000000000000000', // 100 tokens + executedSellAmount: '50000000000000000000', // 50 tokens + sellAmount: '100000000000000000000', // 100 tokens + kind: 'buy', + } as unknown as SwapOrder + + const result = isOrderPartiallyFilled(mockOrder) + + expect(result).toBe(true) + }) + + it('returns false if a buy order is not fully filled or fully filled', () => { + const mockOrder = { + executedBuyAmount: '0', + buyAmount: '100000000000000000000', // 100 tokens + executedSellAmount: '100000000000000000000', // 100 tokens + sellAmount: '100000000000000000000', // 100 tokens + kind: 'buy', + } as unknown as SwapOrder + + const result = isOrderPartiallyFilled(mockOrder) + + expect(result).toBe(false) + + const result1 = isOrderPartiallyFilled({ + ...mockOrder, + executedBuyAmount: '100000000000000000000', // 100 tokens + }) + expect(result1).toBe(false) + }) + + it('returns true if a sell order is partially filled', () => { + const mockOrder = { + sellAmount: '100000000000000000000', + executedSellAmount: '10', + executedBuyAmount: '50000000000000000000', // 50 tokens + buyAmount: '100000000000000000000', // 100 tokens + kind: 'sell', + } as unknown as SwapOrder + + const result = isOrderPartiallyFilled(mockOrder) + + expect(result).toBe(true) + }) + + it('returns false if a sell order is not fully filled or fully filled', () => { + const mockOrder = { + sellAmount: '100000000000000000000', + executedSellAmount: '0', + executedBuyAmount: '100000000000000000000', // 100 tokens + buyAmount: '100000000000000000000', // 100 tokens + kind: 'sell', + } as unknown as SwapOrder + + const result = isOrderPartiallyFilled(mockOrder) + + expect(result).toBe(false) + + const result1 = isOrderPartiallyFilled({ + ...mockOrder, + executedSellAmount: '100000000000000000000', // 100 tokens + }) + + expect(result1).toBe(false) + }) + }) + describe('getPartiallyFilledSurplusPrice', () => { + it('returns 0 for partially filled sell order with no surplus', () => { + const mockOrder = { + sellAmount: '100000000000000000000', // 100 tokens + executedSellAmount: '50000000000000000000', // 50 tokens + executedBuyAmount: '50000000000000000000', // 50 tokens + buyAmount: '100000000000000000000', // 100 tokens + kind: 'sell', + buyToken: { decimals: 18 }, + sellToken: { decimals: 18 }, + } as unknown as SwapOrder + + const result = getPartiallyFilledSurplus(mockOrder) + + expect(result).toEqual(0) + }) + it('returns 0 for partially filled buy order with no surplus', () => { + const mockOrder = { + sellAmount: '100000000000000000000', // 100 tokens + executedSellAmount: '50000000000000000000', // 50 tokens + executedBuyAmount: '50000000000000000000', // 50 tokens + buyAmount: '100000000000000000000', // 100 tokens + kind: 'buy', + buyToken: { decimals: 18 }, + sellToken: { decimals: 18 }, + } as unknown as SwapOrder + + const result = getPartiallyFilledSurplus(mockOrder) + + expect(result).toEqual(0) + }) + it('returns surplus for partially filled sell orders', () => { + const mockOrder = { + sellAmount: '100000000000000000000', // 100 tokens + executedSellAmount: '50000000000000000000', // 50 tokens + executedBuyAmount: '55000000000000000000', // 55 tokens + buyAmount: '100000000000000000000', // 100 tokens + kind: 'sell', + buyToken: { decimals: 18 }, + sellToken: { decimals: 18 }, + } as unknown as SwapOrder + + const result = getPartiallyFilledSurplus(mockOrder) + expect(result).toEqual(5) + }) + it('returns surplus for partially filled buy orders', () => { + const mockOrder = { + sellAmount: '100000000000000000000', // 100 tokens + executedSellAmount: '45000000000000000000', // 50 tokens + executedBuyAmount: '50000000000000000000', // 55 tokens + buyAmount: '100000000000000000000', // 100 tokens + kind: 'buy', + buyToken: { decimals: 18 }, + sellToken: { decimals: 18 }, + } as unknown as SwapOrder + + const result = getPartiallyFilledSurplus(mockOrder) + + expect(result).toEqual(5) + }) + }) + + describe('isSettingTwapFallbackHandler', () => { + it('should return true when handler is TWAP_FALLBACK_HANDLER', () => { + const decodedData = { + parameters: [ + { + valueDecoded: [ + { + dataDecoded: { + method: 'setFallbackHandler', + parameters: [{ name: 'handler', value: TWAP_FALLBACK_HANDLER }], + }, + }, + ], + }, + ], + } as unknown as DataDecoded + expect(isSettingTwapFallbackHandler(decodedData)).toBe(true) + }) + + it('should return false when handler is not TWAP_FALLBACK_HANDLER', () => { + const decodedData = { + parameters: [ + { + valueDecoded: [ + { + dataDecoded: { + method: 'setFallbackHandler', + parameters: [{ name: 'handler', value: '0xDifferentHandler' }], + }, + }, + ], + }, + ], + } as unknown as DataDecoded + expect(isSettingTwapFallbackHandler(decodedData)).toBe(false) + }) + + it('should return false when method is not setFallbackHandler', () => { + const decodedData = { + parameters: [ + { + valueDecoded: [ + { + dataDecoded: { + method: 'differentMethod', + parameters: [{ name: 'handler', value: TWAP_FALLBACK_HANDLER }], + }, + }, + ], + }, + ], + } as unknown as DataDecoded + expect(isSettingTwapFallbackHandler(decodedData)).toBe(false) + }) + + it('should return false when parameters are missing', () => { + const decodedData = {} as unknown as DataDecoded + expect(isSettingTwapFallbackHandler(decodedData)).toBe(false) + }) + + it('should return false when valueDecoded is missing', () => { + const decodedData = { + parameters: [ + { + valueDecoded: null, + }, + ], + } as unknown as DataDecoded + expect(isSettingTwapFallbackHandler(decodedData)).toBe(false) + }) + + it('should return false when dataDecoded is missing', () => { + const decodedData = { + parameters: [ + { + valueDecoded: [ + { + dataDecoded: null, + }, + ], + }, + ], + } as unknown as DataDecoded + expect(isSettingTwapFallbackHandler(decodedData)).toBe(false) + }) + }) + + describe('getOrderFeeBps', () => { + describe('undefined partnerFee cases', () => { + it('should return 0 when fullAppData is null', () => { + const mockOrder = { + fullAppData: null, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(0) + }) + + it('should return 0 when fullAppData is undefined', () => { + const mockOrder = { + fullAppData: undefined, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(0) + }) + + it('should return 0 when metadata is missing', () => { + const mockOrder = { + fullAppData: {}, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(0) + }) + + it('should return 0 when metadata is null', () => { + const mockOrder = { + fullAppData: { + metadata: null, + }, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(0) + }) + + it('should return 0 when partnerFee is undefined', () => { + const mockOrder = { + fullAppData: { + metadata: { + partnerFee: undefined, + }, + }, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(0) + }) + + it('should return 0 when partnerFee is null', () => { + const mockOrder = { + fullAppData: { + metadata: { + partnerFee: null, + }, + }, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(0) + }) + }) + + describe('legacy partnerFee format (v1.3.0)', () => { + it('should return bps value for valid legacy partnerFee', () => { + const mockOrder = { + fullAppData: { + metadata: { + partnerFee: { + bps: 25, + recipient: '0x1234567890123456789012345678901234567890', + }, + }, + }, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(25) + }) + + it('should return 0 when legacy partnerFee has no bps property', () => { + const mockOrder = { + fullAppData: { + metadata: { + partnerFee: { + recipient: '0x1234567890123456789012345678901234567890', + }, + }, + }, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(0) + }) + + it('should return 0 when legacy partnerFee bps is not a number', () => { + const mockOrder = { + fullAppData: { + metadata: { + partnerFee: { + bps: 'invalid', + recipient: '0x1234567890123456789012345678901234567890', + }, + }, + }, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(0) + }) + }) + + describe('modern partnerFee format (v1.4.0)', () => { + it('should return volumeBps for volume fee', () => { + const mockOrder = { + fullAppData: { + metadata: { + partnerFee: { + volumeBps: 30, + recipient: '0x1234567890123456789012345678901234567890', + }, + }, + }, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(30) + }) + + it('should return 0 for surplus fee (not volume fee)', () => { + const mockOrder = { + fullAppData: { + metadata: { + partnerFee: { + surplusBps: 25, + maxVolumeBps: 50, + recipient: '0x1234567890123456789012345678901234567890', + }, + }, + }, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(0) + }) + + it('should return 0 for price improvement fee (not volume fee)', () => { + const mockOrder = { + fullAppData: { + metadata: { + partnerFee: { + priceImprovementBps: 15, + maxVolumeBps: 40, + recipient: '0x1234567890123456789012345678901234567890', + }, + }, + }, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(0) + }) + + it('should sum volumeBps from array of fees', () => { + const mockOrder = { + fullAppData: { + metadata: { + partnerFee: [ + { + volumeBps: 20, + recipient: '0x1234567890123456789012345678901234567890', + }, + { + volumeBps: 15, + recipient: '0x0987654321098765432109876543210987654321', + }, + { + surplusBps: 10, // This should be ignored + maxVolumeBps: 30, + recipient: '0x1111111111111111111111111111111111111111', + }, + ], + }, + }, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(35) // 20 + 15, surplus fee ignored + }) + + it('should return 0 for array with no volume fees', () => { + const mockOrder = { + fullAppData: { + metadata: { + partnerFee: [ + { + surplusBps: 25, + maxVolumeBps: 50, + recipient: '0x1234567890123456789012345678901234567890', + }, + { + priceImprovementBps: 15, + maxVolumeBps: 40, + recipient: '0x0987654321098765432109876543210987654321', + }, + ], + }, + }, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(0) + }) + + it('should return 0 for empty array', () => { + const mockOrder = { + fullAppData: { + metadata: { + partnerFee: [], + }, + }, + } as unknown as SwapOrder + + const result = getOrderFeeBps(mockOrder) + expect(result).toBe(0) + }) + }) + }) +}) diff --git a/packages/utils/src/features/swap/helpers/fee.ts b/packages/utils/src/features/swap/helpers/fee.ts new file mode 100644 index 0000000000..632dd83d59 --- /dev/null +++ b/packages/utils/src/features/swap/helpers/fee.ts @@ -0,0 +1,91 @@ +import { v1_4_0, v1_3_0, LatestAppDataDocVersion } from '@cowprotocol/app-data' +import type { OrderTransactionInfo as SwapOrder } from '@safe-global/store/gateway/types' + +type VolumeFee = { + volumeBps: v1_4_0.VolumeBasisPointBPS + recipient: v1_4_0.PartnerAccount +} + +type SurplusFee = { + surplusBps: v1_4_0.SurplusBasisPointBPS + maxVolumeBps: v1_4_0.MaxVolumeBasisPointBPS + recipient: v1_4_0.PartnerAccount +} + +type PriceImprovementFee = { + priceImprovementBps: v1_4_0.PriceImprovementBasisPointBPS + maxVolumeBps: v1_4_0.MaxVolumeBasisPointBPS + recipient: v1_4_0.PartnerAccount +} + +// Add proper type definitions that account for undefined +type LegacyPartnerFee = v1_3_0.PartnerFee | undefined +type ModernPartnerFee = v1_4_0.PartnerFee | v1_4_0.PartnerFee[] | undefined + +function isVolumeFee(fee: v1_4_0.PartnerFee): fee is VolumeFee { + return typeof (fee as VolumeFee).volumeBps === 'number' +} + +function isSurplusFee(fee: v1_4_0.PartnerFee): fee is SurplusFee { + return typeof (fee as SurplusFee).surplusBps === 'number' +} + +function isPriceImprovementFee(fee: v1_4_0.PartnerFee): fee is PriceImprovementFee { + return typeof (fee as PriceImprovementFee).priceImprovementBps === 'number' +} + +/** + * Right now it doesn't look like we need the surplus and price improvement fees. + * and that's why we don't use this function yet. + */ +function resolveNewPartnerFeeBps(fee: v1_4_0.PartnerFee): number | null { + if (isVolumeFee(fee)) { + return fee.volumeBps + } + if (isSurplusFee(fee)) { + return fee.surplusBps + } + if (isPriceImprovementFee(fee)) { + return fee.priceImprovementBps + } + return null +} + +export const getOrderFeeBps = (order: Pick): number => { + const fullAppData = order.fullAppData as unknown as LatestAppDataDocVersion + + if (!fullAppData?.metadata) { + return 0 + } + + // Handle legacy partner fee format (v1.3.0) with proper null checks + const oldPartnerFee = fullAppData.metadata.partnerFee as unknown as LegacyPartnerFee + + // Check if it's the legacy format and has bps property + if ( + oldPartnerFee && + typeof oldPartnerFee === 'object' && + 'bps' in oldPartnerFee && + typeof oldPartnerFee.bps === 'number' + ) { + return Number(oldPartnerFee.bps) + } + + // Handle modern partner fee format (v1.4.0) with proper null checks + const newPartnerFee = fullAppData.metadata.partnerFee as unknown as ModernPartnerFee + + if (!newPartnerFee) { + return 0 + } + + if (Array.isArray(newPartnerFee)) { + return newPartnerFee.reduce((acc, fee) => { + if (isVolumeFee(fee)) { + return acc + Number(fee.volumeBps) + } + return acc + }, 0) + } + + return isVolumeFee(newPartnerFee) ? Number(newPartnerFee.volumeBps) : 0 +} diff --git a/packages/utils/src/features/swap/helpers/utils.ts b/packages/utils/src/features/swap/helpers/utils.ts new file mode 100644 index 0000000000..4e17c02a7c --- /dev/null +++ b/packages/utils/src/features/swap/helpers/utils.ts @@ -0,0 +1,217 @@ +import type { OrderTransactionInfo as SwapOrder } from '@safe-global/store/gateway/types' +import type { DataDecoded } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { formatUnits } from 'ethers' +import type { AnyAppDataDocVersion, latest } from '@cowprotocol/app-data' + +import { TradeType, UiOrderType } from '@safe-global/utils/features/swap/types' +import { getOrderFeeBps as getOrderFeeBpsHelper } from '@safe-global/utils/features/swap/helpers/fee' + +type Quantity = { + amount: string | number | bigint + decimals: number +} + +export enum OrderKind { + SELL = 'sell', + BUY = 'buy', +} + +function calculateDifference(amountA: string, amountB: string, decimals: number): number { + return asDecimal(BigInt(amountA), decimals) - asDecimal(BigInt(amountB), decimals) +} + +function asDecimal(amount: number | bigint, decimals: number): number { + return Number(formatUnits(amount, decimals)) +} + +export const TWAP_FALLBACK_HANDLER = '0x2f55e8b20D0B9FEFA187AA7d00B6Cbe563605bF5' + +// https://github.com/cowprotocol/composable-cow/blob/main/networks.json +export const TWAP_FALLBACK_HANDLER_NETWORKS = ['1', '100', '11155111', '42161'] + +export const getExecutionPrice = ( + order: Pick, +): number => { + const { executedSellAmount, executedBuyAmount, buyToken, sellToken } = order + + const ratio = calculateRatio( + { amount: executedSellAmount || '0', decimals: sellToken.decimals }, + { + amount: executedBuyAmount || '0', + decimals: buyToken.decimals, + }, + ) + + return ratio +} + +export const getLimitPrice = ( + order: Pick, +): number => { + const { sellAmount, buyAmount, buyToken, sellToken } = order + + const ratio = calculateRatio( + { amount: sellAmount, decimals: sellToken.decimals }, + { amount: buyAmount, decimals: buyToken.decimals }, + ) + + return ratio +} + +const calculateRatio = (a: Quantity, b: Quantity) => { + if (BigInt(b.amount) === 0n) { + return 0 + } + return asDecimal(BigInt(a.amount), a.decimals) / asDecimal(BigInt(b.amount), b.decimals) +} + +export const getSurplusPrice = ( + order: Pick< + SwapOrder, + 'executedBuyAmount' | 'buyAmount' | 'buyToken' | 'executedSellAmount' | 'sellAmount' | 'sellToken' | 'kind' + >, +): number => { + const { kind, executedSellAmount, sellAmount, sellToken, executedBuyAmount, buyAmount, buyToken } = order + if (kind === OrderKind.BUY) { + return calculateDifference(sellAmount, executedSellAmount || '', sellToken.decimals) + } else if (kind === OrderKind.SELL) { + return calculateDifference(executedBuyAmount || '', buyAmount, buyToken.decimals) + } else { + return 0 + } +} + +export const getPartiallyFilledSurplus = (order: SwapOrder): number => { + if (order.kind === OrderKind.BUY) { + return getPartiallyFilledBuySurplus(order) + } else if (order.kind === OrderKind.SELL) { + return getPartiallyFilledSellSurplus(order) + } else { + return 0 + } +} + +const getPartiallyFilledBuySurplus = ( + order: Pick< + SwapOrder, + 'executedBuyAmount' | 'buyAmount' | 'buyToken' | 'executedSellAmount' | 'sellAmount' | 'sellToken' | 'kind' + >, +): number => { + const { executedSellAmount, sellAmount, sellToken, executedBuyAmount, buyAmount, buyToken } = order + + const limitPrice = calculateRatio( + { amount: sellAmount, decimals: sellToken.decimals }, + { amount: buyAmount, decimals: buyToken.decimals }, + ) + const maximumSellAmount = asDecimal(BigInt(executedBuyAmount || 0n), buyToken.decimals) * limitPrice + return maximumSellAmount - asDecimal(BigInt(executedSellAmount || 0n), sellToken.decimals) +} + +const getPartiallyFilledSellSurplus = ( + order: Pick< + SwapOrder, + 'executedBuyAmount' | 'buyAmount' | 'buyToken' | 'executedSellAmount' | 'sellAmount' | 'sellToken' | 'kind' + >, +): number => { + const { executedSellAmount, sellAmount, sellToken, executedBuyAmount, buyAmount, buyToken } = order + + const limitPrice = calculateRatio( + { amount: buyAmount, decimals: buyToken.decimals }, + { amount: sellAmount, decimals: sellToken.decimals }, + ) + + const minimumBuyAmount = asDecimal(BigInt(executedSellAmount || 0n), sellToken.decimals) * limitPrice + return asDecimal(BigInt(executedBuyAmount || 0n), buyToken.decimals) - minimumBuyAmount +} + +export const getFilledPercentage = ( + order: Pick, +): string => { + let executed: number + let total: number + + if (order.kind === OrderKind.BUY) { + executed = Number(order.executedBuyAmount) + total = Number(order.buyAmount) + } else if (order.kind === OrderKind.SELL) { + executed = Number(order.executedSellAmount) + total = Number(order.sellAmount) + } else { + return '0' + } + + return ((executed / total) * 100).toFixed(0) +} + +export const getFilledAmount = ( + order: Pick, +): string => { + if (order.kind === OrderKind.BUY) { + return formatUnits(order.executedBuyAmount || 0n, order.buyToken.decimals) + } else if (order.kind === OrderKind.SELL) { + return formatUnits(order.executedSellAmount || 0n, order.sellToken.decimals) + } else { + return '0' + } +} + +export const getSlippageInPercent = (order: Pick): string => { + const fullAppData = order.fullAppData as AnyAppDataDocVersion + const slippageBips = (fullAppData?.metadata?.quote as latest.Quote)?.slippageBips || 0 + + return (Number(slippageBips) / 100).toFixed(2) +} + +export const getOrderClass = (order: Pick): latest.OrderClass1 => { + const fullAppData = order.fullAppData as AnyAppDataDocVersion + const orderClass = (fullAppData?.metadata?.orderClass as latest.OrderClass)?.orderClass + + return orderClass || 'market' +} + +export const getOrderFeeBps = (order: Pick): number => { + return getOrderFeeBpsHelper(order) +} + +export const isOrderPartiallyFilled = ( + order: Pick, +): boolean => { + const executedBuyAmount = BigInt(order.executedBuyAmount || 0) + const buyAmount = BigInt(order.buyAmount) + const executedSellAmount = BigInt(order.executedSellAmount || 0) + const sellAmount = BigInt(order.sellAmount) + + if (order.kind === OrderKind.BUY) { + return executedBuyAmount !== 0n && executedBuyAmount < buyAmount + } + + return BigInt(executedSellAmount) !== 0n && executedSellAmount < sellAmount +} + +export const UiOrderTypeToOrderType = (orderType: UiOrderType): TradeType => { + switch (orderType) { + case UiOrderType.SWAP: + return TradeType.SWAP + case UiOrderType.LIMIT: + return TradeType.LIMIT + case UiOrderType.TWAP: + return TradeType.ADVANCED + } +} + +export const isSettingTwapFallbackHandler = (decodedData: DataDecoded) => { + return ( + decodedData.parameters?.some( + (item) => + Array.isArray(item?.valueDecoded) && + item.valueDecoded.some( + (decoded) => + decoded.dataDecoded?.method === 'setFallbackHandler' && + decoded.dataDecoded.parameters?.some( + // @ts-ignore + (parameter) => parameter.name === 'handler' && parameter.value === TWAP_FALLBACK_HANDLER, + ), + ), + ) || false + ) +} diff --git a/apps/web/src/features/swap/types.ts b/packages/utils/src/features/swap/types.ts similarity index 100% rename from apps/web/src/features/swap/types.ts rename to packages/utils/src/features/swap/types.ts diff --git a/packages/utils/src/hooks/coreSDK/types.ts b/packages/utils/src/hooks/coreSDK/types.ts new file mode 100644 index 0000000000..18172c4550 --- /dev/null +++ b/packages/utils/src/hooks/coreSDK/types.ts @@ -0,0 +1,13 @@ +import type { JsonRpcProvider } from 'ethers' +import type { SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import type { UndeployedSafe } from '@safe-global/utils/features/counterfactual/store/types' + +export type SafeCoreSDKProps = { + provider: JsonRpcProvider + chainId: SafeState['chainId'] + address: SafeState['address']['value'] + version: SafeState['version'] + implementationVersionState: SafeState['implementationVersionState'] + implementation: SafeState['implementation']['value'] + undeployedSafe?: UndeployedSafe +} diff --git a/packages/utils/src/hooks/coreSDK/utils.ts b/packages/utils/src/hooks/coreSDK/utils.ts new file mode 100644 index 0000000000..779461289a --- /dev/null +++ b/packages/utils/src/hooks/coreSDK/utils.ts @@ -0,0 +1,8 @@ +import { sameAddress } from '@safe-global/utils/utils/addresses' + +export const isInDeployments = (address: string, deployments: string | string[] | undefined): boolean => { + if (Array.isArray(deployments)) { + return deployments.some((deployment) => sameAddress(deployment, address)) + } + return sameAddress(address, deployments) +} diff --git a/apps/web/src/hooks/useAsync.ts b/packages/utils/src/hooks/useAsync.ts similarity index 96% rename from apps/web/src/hooks/useAsync.ts rename to packages/utils/src/hooks/useAsync.ts index 594183afbd..59db700a6e 100644 --- a/apps/web/src/hooks/useAsync.ts +++ b/packages/utils/src/hooks/useAsync.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react' -import { asError } from '@/services/exceptions/utils' +import { asError } from '@safe-global/utils/services/exceptions/utils' export type AsyncResult = [result: T | undefined, error: Error | undefined, loading: boolean] diff --git a/packages/utils/src/hooks/useTxTokenInfo.ts b/packages/utils/src/hooks/useTxTokenInfo.ts new file mode 100644 index 0000000000..5b5597debe --- /dev/null +++ b/packages/utils/src/hooks/useTxTokenInfo.ts @@ -0,0 +1,51 @@ +import { type NativeToken, type TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' +import { useMemo } from 'react' +import { ERC20__factory } from '@safe-global/utils/types/contracts' + +import { isEmptyHexData } from '../utils/hex' + +const ERC20_INTERFACE = ERC20__factory.createInterface() + +export const useTxTokenInfo = ( + data: string | undefined, + value: string | undefined, + to: string, + nativeTokenInfo: NativeToken, + tokenInfoIndex?: NonNullable['tokenInfoIndex'], +) => { + const isERC20Transfer = Boolean(data?.startsWith(ERC20_INTERFACE.getFunction('transfer').selector)) + const isNativeTransfer = value !== '0' && (!data || isEmptyHexData(data)) + + return useMemo(() => { + if (!isERC20Transfer && !isNativeTransfer) { + return + } + try { + if (isERC20Transfer) { + if (!data) { + return + } + const [recipient, transferValue] = ERC20_INTERFACE.decodeFunctionData('transfer', data) + const tokenInfo = isERC20Transfer ? tokenInfoIndex?.[to] : undefined + + if (tokenInfo?.type !== 'ERC20') { + return + } + + return { recipient, transferValue, tokenInfo } + } + + if (!value || value === '0') { + return + } + + return { + recipient: to, + transferValue: value, + tokenInfo: nativeTokenInfo, + } + } catch (error) { + return + } + }, [isERC20Transfer, isNativeTransfer, value, nativeTokenInfo, to, data, tokenInfoIndex]) +} diff --git a/apps/web/src/services/ExternalStore.ts b/packages/utils/src/services/ExternalStore.ts similarity index 100% rename from apps/web/src/services/ExternalStore.ts rename to packages/utils/src/services/ExternalStore.ts diff --git a/apps/web/src/services/contracts/deployments.ts b/packages/utils/src/services/contracts/deployments.ts similarity index 82% rename from apps/web/src/services/contracts/deployments.ts rename to packages/utils/src/services/contracts/deployments.ts index ad454b084a..3737a27f73 100644 --- a/apps/web/src/services/contracts/deployments.ts +++ b/packages/utils/src/services/contracts/deployments.ts @@ -10,10 +10,11 @@ import { getCreateCallDeployment, } from '@safe-global/safe-deployments' import type { SingletonDeployment, DeploymentFilter, SingletonDeploymentV2 } from '@safe-global/safe-deployments' -import type { ChainInfo, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { getLatestSafeVersion } from '@/utils/chains' -import { sameAddress } from '@/utils/addresses' -import { type SafeVersion } from '@safe-global/safe-core-sdk-types' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { sameAddress } from '@safe-global/utils/utils/addresses' +import { type SafeVersion } from '@safe-global/types-kit' +import { getLatestSafeVersion } from '@safe-global/utils/utils/chains' +import { SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' const toNetworkAddressList = (addresses: string | string[]) => (Array.isArray(addresses) ? addresses : [addresses]) @@ -57,7 +58,7 @@ export const hasMatchingDeployment = ( export const _tryDeploymentVersions = ( getDeployment: (filter?: DeploymentFilter) => SingletonDeployment | undefined, network: ChainInfo, - version: SafeInfo['version'], + version: SafeState['version'], ): SingletonDeployment | undefined => { // Unsupported Safe version if (version === null) { @@ -75,16 +76,16 @@ export const _tryDeploymentVersions = ( }) } -export const _isLegacy = (safeVersion: SafeInfo['version']): boolean => { +export const _isLegacy = (safeVersion: SafeState['version']): boolean => { const LEGACY_VERSIONS = '<=1.0.0' return !!safeVersion && semverSatisfies(safeVersion, LEGACY_VERSIONS) } -export const _isL2 = (chain: ChainInfo, safeVersion: SafeInfo['version']): boolean => { +export const _isL2 = (chain: ChainInfo, safeVersion: SafeState['version']): boolean => { const L2_VERSIONS = '>=1.3.0' // Unsupported safe version - if (safeVersion === null) { + if (typeof safeVersion === 'undefined' || safeVersion === null) { return chain.l2 } @@ -94,7 +95,7 @@ export const _isL2 = (chain: ChainInfo, safeVersion: SafeInfo['version']): boole export const getSafeContractDeployment = ( chain: ChainInfo, - safeVersion: SafeInfo['version'], + safeVersion: SafeState['version'], ): SingletonDeployment | undefined => { // Check if prior to 1.0.0 to keep minimum compatibility if (_isLegacy(safeVersion)) { @@ -106,26 +107,26 @@ export const getSafeContractDeployment = ( return _tryDeploymentVersions(getDeployment, chain, safeVersion) } -export const getMultiSendCallOnlyContractDeployment = (chain: ChainInfo, safeVersion: SafeInfo['version']) => { +export const getMultiSendCallOnlyContractDeployment = (chain: ChainInfo, safeVersion: SafeState['version']) => { return _tryDeploymentVersions(getMultiSendCallOnlyDeployment, chain, safeVersion) } -export const getMultiSendContractDeployment = (chain: ChainInfo, safeVersion: SafeInfo['version']) => { +export const getMultiSendContractDeployment = (chain: ChainInfo, safeVersion: SafeState['version']) => { return _tryDeploymentVersions(getMultiSendDeployment, chain, safeVersion) } -export const getFallbackHandlerContractDeployment = (chain: ChainInfo, safeVersion: SafeInfo['version']) => { +export const getFallbackHandlerContractDeployment = (chain: ChainInfo, safeVersion: SafeState['version']) => { return _tryDeploymentVersions(getFallbackHandlerDeployment, chain, safeVersion) } -export const getProxyFactoryContractDeployment = (chain: ChainInfo, safeVersion: SafeInfo['version']) => { +export const getProxyFactoryContractDeployment = (chain: ChainInfo, safeVersion: SafeState['version']) => { return _tryDeploymentVersions(getProxyFactoryDeployment, chain, safeVersion) } -export const getSignMessageLibContractDeployment = (chain: ChainInfo, safeVersion: SafeInfo['version']) => { +export const getSignMessageLibContractDeployment = (chain: ChainInfo, safeVersion: SafeState['version']) => { return _tryDeploymentVersions(getSignMessageLibDeployment, chain, safeVersion) } -export const getCreateCallContractDeployment = (chain: ChainInfo, safeVersion: SafeInfo['version']) => { +export const getCreateCallContractDeployment = (chain: ChainInfo, safeVersion: SafeState['version']) => { return _tryDeploymentVersions(getCreateCallDeployment, chain, safeVersion) } diff --git a/packages/utils/src/services/contracts/safeContracts.ts b/packages/utils/src/services/contracts/safeContracts.ts new file mode 100644 index 0000000000..3b9ccd3c2b --- /dev/null +++ b/packages/utils/src/services/contracts/safeContracts.ts @@ -0,0 +1,37 @@ +import type { SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { type GetContractProps } from '@safe-global/protocol-kit' +import type { SafeVersion } from '@safe-global/types-kit' +import { assertValidSafeVersion } from '@safe-global/utils/services/contracts/utils' +import { getSafeMigrationDeployment } from '@safe-global/safe-deployments' +import { SAFE_TO_L2_MIGRATION_VERSION } from '@safe-global/utils/config/constants' + +// `UNKNOWN` is returned if the mastercopy does not match supported ones +// @see https://github.com/safe-global/safe-client-gateway/blob/main/src/routes/safes/handlers/safes.rs#L28-L31 +// https://github.com/safe-global/safe-client-gateway/blob/main/src/routes/safes/converters.rs#L77-L79 +export const isValidMasterCopy = (implementationVersionState: SafeState['implementationVersionState']): boolean => { + return implementationVersionState !== 'UNKNOWN' +} + +export const _getValidatedGetContractProps = ( + safeVersion: SafeState['version'], +): Pick => { + assertValidSafeVersion(safeVersion) + + // SDK request here: https://github.com/safe-global/safe-core-sdk/issues/261 + // Remove '+L2'/'+Circles' metadata from version + const [noMetadataVersion] = safeVersion.split('+') + + return { + safeVersion: noMetadataVersion as SafeVersion, + } +} +export const isMigrationToL2Possible = (safe: SafeState): boolean => { + return ( + safe.nonce === 0 && + Boolean( + getSafeMigrationDeployment({ network: safe.chainId, version: SAFE_TO_L2_MIGRATION_VERSION })?.networkAddresses[ + safe.chainId + ], + ) + ) +} diff --git a/packages/utils/src/services/contracts/utils.ts b/packages/utils/src/services/contracts/utils.ts new file mode 100644 index 0000000000..a2ab048139 --- /dev/null +++ b/packages/utils/src/services/contracts/utils.ts @@ -0,0 +1,18 @@ +import semverSatisfies from 'semver/functions/satisfies' +import type { SafeVersion } from '@safe-global/types-kit' +import { invariant } from '@safe-global/utils/utils/helpers' +import type { SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' + +export const isLegacyVersion = (safeVersion: string): boolean => { + const LEGACY_VERSION = '<1.3.0' + return semverSatisfies(safeVersion, LEGACY_VERSION) +} +export const isValidSafeVersion = (safeVersion?: SafeState['version']): safeVersion is SafeVersion => { + const SAFE_VERSIONS: SafeVersion[] = ['1.4.1', '1.3.0', '1.2.0', '1.1.1', '1.0.0'] + return !!safeVersion && SAFE_VERSIONS.some((version) => semverSatisfies(safeVersion, version)) +} + +// `assert` does not work with arrow functions +export function assertValidSafeVersion(safeVersion?: T): asserts safeVersion { + return invariant(isValidSafeVersion(safeVersion), `${safeVersion} is not a valid Safe Account version`) +} diff --git a/packages/utils/src/services/delegates/index.ts b/packages/utils/src/services/delegates/index.ts new file mode 100644 index 0000000000..dd40b75404 --- /dev/null +++ b/packages/utils/src/services/delegates/index.ts @@ -0,0 +1,32 @@ +/** + * Generates typed data for delegate registration according to EIP-712 + * Used by both web and mobile apps for consistent delegate registration + */ +export const getDelegateTypedData = (chainId: string, delegateAddress: string) => { + const totp = Math.floor(Date.now() / 1000 / 3600) + + const domain = { + name: 'Safe Transaction Service', + version: '1.0', + chainId: Number(chainId), + } + + const types = { + Delegate: [ + { name: 'delegateAddress', type: 'address' }, + { name: 'totp', type: 'uint256' }, + ], + } + + const message = { + delegateAddress, + totp, + } + + return { + domain, + types, + message, + primaryType: 'Delegate' as const, + } +} diff --git a/apps/web/src/services/exceptions/ErrorCodes.ts b/packages/utils/src/services/exceptions/ErrorCodes.ts similarity index 91% rename from apps/web/src/services/exceptions/ErrorCodes.ts rename to packages/utils/src/services/exceptions/ErrorCodes.ts index 1f9ff94e2a..51cadf43c3 100644 --- a/apps/web/src/services/exceptions/ErrorCodes.ts +++ b/packages/utils/src/services/exceptions/ErrorCodes.ts @@ -13,12 +13,11 @@ enum ErrorCodes { _104 = '104: Invalid chain short name in the URL', _105 = '105: Error connecting to the blockchain', _106 = '106: Failed to get connected wallet', + _107 = '107: Error connecting to the wallet', + _108 = '108: Error disconnecting the wallet', - _302 = '302: Error connecting to the wallet', - _304 = '304: Error enabling MFA', - _305 = '305: Error exporting account key', - _306 = '306: Error logging in', - _307 = '307: Error attempted new account creation', + _200 = '200: Tenderly simulation failed', + _201 = '201: Blockaid scan failed', _400 = '400: Error requesting browser notification permissions', _401 = '401: Error tracking push notifications', @@ -42,8 +41,7 @@ enum ErrorCodes { _631 = '631: Transaction failed to be relayed', _632 = '632: Error fetching relay task status', _633 = '633: Notification (un-)registration failed', - _640 = '640: User account not found', - _641 = '641: Error creating user account', + _640 = '640: Error signing in with Ethereum', _700 = '700: Failed to read from local/session storage', _701 = '701: Failed to write to local/session storage', @@ -72,6 +70,7 @@ enum ErrorCodes { _816 = '816: Error computing replay Safe creation data', _817 = '817: Error sending a transaction through nested Safe provider', _818 = '818: Error validating transaction data', + _819 = '819: Error adding a transaction to the batch', _900 = '900: Error loading Safe App', _901 = '901: Error processing Safe Apps SDK request', diff --git a/apps/web/src/services/exceptions/utils.ts b/packages/utils/src/services/exceptions/utils.ts similarity index 100% rename from apps/web/src/services/exceptions/utils.ts rename to packages/utils/src/services/exceptions/utils.ts diff --git a/apps/web/src/services/security/modules/ApprovalModule/index.ts b/packages/utils/src/services/security/modules/ApprovalModule/index.ts similarity index 81% rename from apps/web/src/services/security/modules/ApprovalModule/index.ts rename to packages/utils/src/services/security/modules/ApprovalModule/index.ts index d513507b0c..5589c518bf 100644 --- a/apps/web/src/services/security/modules/ApprovalModule/index.ts +++ b/packages/utils/src/services/security/modules/ApprovalModule/index.ts @@ -1,14 +1,14 @@ -import { - APPROVAL_SIGNATURE_HASH, - INCREASE_ALLOWANCE_SIGNATURE_HASH, -} from '@/components/tx/ApprovalEditor/utils/approvals' -import { ERC20__factory } from '@/types/contracts' -import { normalizeTypedData } from '@/utils/web3' -import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' -import { type EIP712TypedData } from '@safe-global/safe-gateway-typescript-sdk' +import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' +import { ERC20__factory } from '@safe-global/utils/types/contracts' +import { normalizeTypedData } from '@safe-global/utils/utils/web3' +import { type SafeTransaction } from '@safe-global/types-kit' import { id } from 'ethers' import { type SecurityResponse, type SecurityModule, SecuritySeverity } from '../types' import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' +import { + APPROVAL_SIGNATURE_HASH, + INCREASE_ALLOWANCE_SIGNATURE_HASH, +} from '@safe-global/utils/components/tx/ApprovalEditor/utils/approvals' export type ApprovalModuleResponse = Approval[] @@ -17,14 +17,14 @@ export type ApprovalModuleRequest = { } export type ApprovalModuleMessageRequest = { - safeMessage: EIP712TypedData + safeMessage: TypedData } export type Approval = { spender: any amount: any tokenAddress: string - method: 'approve' | 'increaseAllowance' | 'Permit2' + method: 'approve' | 'increaseAllowance' | 'Permit2' | 'Permit' transactionIndex: number } @@ -101,6 +101,20 @@ export class ApprovalModule implements SecurityModule 0) { return { diff --git a/apps/web/src/services/security/modules/BlockaidModule/index.ts b/packages/utils/src/services/security/modules/BlockaidModule/index.ts similarity index 79% rename from apps/web/src/services/security/modules/BlockaidModule/index.ts rename to packages/utils/src/services/security/modules/BlockaidModule/index.ts index be434f5faf..0e54fa4faf 100644 --- a/apps/web/src/services/security/modules/BlockaidModule/index.ts +++ b/packages/utils/src/services/security/modules/BlockaidModule/index.ts @@ -1,12 +1,18 @@ -import { isEIP712TypedData } from '@/utils/safe-messages' -import { normalizeTypedData } from '@/utils/web3' -import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' +import { isEIP712TypedData } from '@safe-global/utils/utils/safe-messages' +import { normalizeTypedData } from '@safe-global/utils/utils/web3' +import { type SafeTransaction } from '@safe-global/types-kit' import { generateTypedData } from '@safe-global/protocol-kit/dist/src/utils/eip-712' -import type { EIP712TypedData } from '@safe-global/safe-gateway-typescript-sdk' import { type SecurityResponse, type SecurityModule, SecuritySeverity } from '../types' -import type { AssetDiff, TransactionScanResponse } from './types' -import { BLOCKAID_API, BLOCKAID_CLIENT_ID } from '@/config/constants' -import { numberToHex } from '@/utils/hex' +import type { + AssetDiff, + ModulesChangeManagement, + OwnershipChangeManagement, + ProxyUpgradeManagement, + TransactionScanResponse, +} from './types' +import { BLOCKAID_API, BLOCKAID_CLIENT_ID } from '@safe-global/utils/config/constants' +import { numberToHex } from '@safe-global/web/src/utils/hex' /** @see https://docs.blockaid.io/docs/supported-chains */ @@ -21,7 +27,7 @@ export type BlockaidModuleRequest = { chainId: number safeAddress: string walletAddress: string - data: SafeTransaction | EIP712TypedData + data: SafeTransaction | TypedData threshold: number origin?: string } @@ -35,6 +41,7 @@ export type BlockaidModuleResponse = { description: string }[] balanceChange: AssetDiff[] + contractManagement: Array error: Error | undefined } @@ -82,12 +89,12 @@ export class BlockaidModule implements SecurityModule = [] let error: Error | undefined = undefined if (simulation?.status === 'Success') { balanceChange = simulation.assets_diffs[safeAddress] + contractManagement = simulation.contract_management?.[safeAddress] || [] } else if (simulation?.status === 'Error') { - error = new Error('Simulation failed') + error = new Error(simulation.error) } // Sometimes the validation is missing @@ -148,6 +157,7 @@ export class BlockaidModule implements SecurityModule> + + /** + * dictionary describes contract management changes as a result of this transaction + * for every involved address + */ + // contract_management?: Record> + contract_management?: Record< + string, + Array + > +} + +export interface ProxyUpgradeManagement { + /** + * The state after the transaction + */ + after: ProxyUpgradeManagement.After + + /** + * The state before the transaction + */ + before: ProxyUpgradeManagement.Before + + /** + * The type of the state change + */ + type: 'PROXY_UPGRADE' +} + +export namespace ProxyUpgradeManagement { + /** + * The state after the transaction + */ + export interface After { + address: string + } + + /** + * The state before the transaction + */ + export interface Before { + address: string + } +} + +export interface OwnershipChangeManagement { + /** + * The state after the transaction + */ + after: OwnershipChangeManagement.After + + /** + * The state before the transaction + */ + before: OwnershipChangeManagement.Before + + /** + * The type of the state change + */ + type: 'OWNERSHIP_CHANGE' +} + +export namespace OwnershipChangeManagement { + /** + * The state after the transaction + */ + export interface After { + owners: Array + } + + /** + * The state before the transaction + */ + export interface Before { + owners: Array + } +} + +export interface ModulesChangeManagement { + /** + * The state after the transaction + */ + after: ModulesChangeManagement.After + + /** + * The state before the transaction + */ + before: ModulesChangeManagement.Before + + /** + * The type of the state change + */ + type: 'MODULES_CHANGE' +} + +export namespace ModulesChangeManagement { + /** + * The state after the transaction + */ + export interface After { + modules: Array + } + + /** + * The state before the transaction + */ + export interface Before { + modules: Array + } } export namespace TransactionSimulation { diff --git a/apps/web/src/services/security/modules/DelegateCallModule/index.test.ts b/packages/utils/src/services/security/modules/DelegateCallModule/index.test.ts similarity index 85% rename from apps/web/src/services/security/modules/DelegateCallModule/index.test.ts rename to packages/utils/src/services/security/modules/DelegateCallModule/index.test.ts index c8d3506f88..8d0fb6757c 100644 --- a/apps/web/src/services/security/modules/DelegateCallModule/index.test.ts +++ b/packages/utils/src/services/security/modules/DelegateCallModule/index.test.ts @@ -1,15 +1,16 @@ -import { OperationType } from '@safe-global/safe-core-sdk-types' +import { OperationType } from '@safe-global/types-kit' import { getMultiSendCallOnlyDeployment } from '@safe-global/safe-deployments' import { toBeHex } from 'ethers' -import { DelegateCallModule } from '.' -import { createMockSafeTransaction, getMockMultiSendCalldata } from '@/tests/transactions' -import { chainBuilder } from '@/tests/builders/chains' +import { DelegateCallModule } from './index' +import { createMockSafeTransaction, getMockMultiSendCalldata } from '@safe-global/utils/tests/transactions' +import { chainBuilder } from '@safe-global/utils/tests/builders/chains' +import { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' describe('DelegateCallModule', () => { const DelegateCallModuleInstance = new DelegateCallModule() - const chainInfo = chainBuilder().with({ chainId: '1' }).build() + const chainInfo = chainBuilder().with({ chainId: '1', chainLogoUri: null }).build() as unknown as ChainInfo it('should not warn about Call operation transactions', async () => { const recipient = toBeHex('0x1', 20) diff --git a/apps/web/src/services/security/modules/DelegateCallModule/index.ts b/packages/utils/src/services/security/modules/DelegateCallModule/index.ts similarity index 79% rename from apps/web/src/services/security/modules/DelegateCallModule/index.ts rename to packages/utils/src/services/security/modules/DelegateCallModule/index.ts index 4fe516f744..20e28b2f7a 100644 --- a/apps/web/src/services/security/modules/DelegateCallModule/index.ts +++ b/packages/utils/src/services/security/modules/DelegateCallModule/index.ts @@ -1,7 +1,8 @@ -import { OperationType } from '@safe-global/safe-core-sdk-types' -import { getMultiSendCallOnlyContractDeployment } from '@/services/contracts/deployments' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import type { ChainInfo, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { OperationType } from '@safe-global/types-kit' +import { getMultiSendCallOnlyContractDeployment } from '@safe-global/utils/services/contracts/deployments' +import type { SafeTransaction } from '@safe-global/types-kit' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState as SafeInfo } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { SecuritySeverity } from '../types' import type { SecurityModule, SecurityResponse } from '../types' diff --git a/apps/web/src/services/security/modules/types.ts b/packages/utils/src/services/security/modules/types.ts similarity index 100% rename from apps/web/src/services/security/modules/types.ts rename to packages/utils/src/services/security/modules/types.ts diff --git a/packages/utils/src/tests/Builder.ts b/packages/utils/src/tests/Builder.ts new file mode 100644 index 0000000000..139302194a --- /dev/null +++ b/packages/utils/src/tests/Builder.ts @@ -0,0 +1,30 @@ +export interface IBuilder { + with(override: Partial): IBuilder + + build(): T +} + +export class Builder implements IBuilder { + private constructor(private target: Partial) {} + + /** + * Returns a new {@link Builder} with the property {@link key} set to {@link value}. + * + * @param override - the override value to apply + */ + with(override: Partial): IBuilder { + const target: Partial = { ...this.target, ...override } + return new Builder(target) + } + + /** + * Returns an instance of T with the values that were set so far + */ + build(): T { + return this.target as T + } + + public static new(): IBuilder { + return new Builder({}) + } +} diff --git a/packages/utils/src/tests/builders/chains.ts b/packages/utils/src/tests/builders/chains.ts new file mode 100644 index 0000000000..308cf0d430 --- /dev/null +++ b/packages/utils/src/tests/builders/chains.ts @@ -0,0 +1,107 @@ +import { faker } from '@faker-js/faker' + +import { + BlockExplorerUriTemplate, + Chain as ChainInfo, + GasPriceFixed, + GasPriceFixedEip1559, + GasPriceOracle, + NativeCurrency, + RpcUri, + Theme, +} from '@safe-global/store/gateway/AUTO_GENERATED/chains' + +import { Builder } from '@safe-global/utils/tests/Builder' +import { generateRandomArray } from '@safe-global/utils/tests/utils' +import type { IBuilder } from '@safe-global/utils/tests/Builder' +import { FEATURES } from '@safe-global/utils/utils/chains' + +const rpcUriBuilder = (): IBuilder => { + return Builder.new().with({ + authentication: 'NO_AUTHENTICATION' as const, + value: faker.internet.url({ appendSlash: false }), + }) +} + +const blockExplorerUriTemplateBuilder = (): IBuilder => { + return Builder.new().with({ + address: faker.internet.url({ appendSlash: false }), + txHash: faker.internet.url({ appendSlash: false }), + api: faker.internet.url({ appendSlash: false }), + }) +} + +const nativeCurrencyBuilder = (): IBuilder => { + return Builder.new().with({ + name: faker.finance.currencyName(), + symbol: faker.finance.currencySymbol(), + decimals: 18, + logoUri: faker.internet.url({ appendSlash: false }), + }) +} + +const themeBuilder = (): IBuilder => { + return Builder.new().with({ + textColor: faker.color.rgb(), + backgroundColor: faker.color.rgb(), + }) +} + +const gasPriceFixedBuilder = (): IBuilder => { + return Builder.new().with({ + type: 'FIXED', + weiValue: faker.string.numeric(), + }) +} + +const gasPriceFixedEIP1559Builder = (): IBuilder => { + return Builder.new().with({ + type: 'FIXED_1559', + maxFeePerGas: faker.string.numeric(), + maxPriorityFeePerGas: faker.string.numeric(), + }) +} + +const gasPriceOracleBuilder = (): IBuilder => { + return Builder.new().with({ + type: 'ORACLE', + uri: faker.internet.url({ appendSlash: false }), + gasParameter: faker.word.sample(), + gweiFactor: faker.string.numeric(), + }) +} + +const getRandomGasPriceBuilder = () => { + const gasPriceBuilders = [ + gasPriceFixedBuilder(), + gasPriceFixedEIP1559Builder(), + gasPriceOracleBuilder(), + // gasPriceOracleUnknownBuilder(), + ] + + const randomIndex = Math.floor(Math.random() * gasPriceBuilders.length) + return gasPriceBuilders[randomIndex] +} + +export const chainBuilder = (): IBuilder => { + return Builder.new().with({ + chainId: faker.string.numeric(), + chainLogoUri: faker.internet.url({ appendSlash: false }), + chainName: faker.word.sample(), + description: faker.word.words(), + l2: faker.datatype.boolean(), + shortName: faker.word.sample(), + rpcUri: rpcUriBuilder().build(), + safeAppsRpcUri: rpcUriBuilder().build(), + publicRpcUri: rpcUriBuilder().build(), + blockExplorerUriTemplate: blockExplorerUriTemplateBuilder().build(), + nativeCurrency: nativeCurrencyBuilder().build(), + transactionService: faker.internet.url({ appendSlash: false }), + theme: themeBuilder().build(), + gasPrice: generateRandomArray(() => getRandomGasPriceBuilder().build(), { min: 1, max: 4 }), + ensRegistryAddress: faker.finance.ethereumAddress(), + disabledWallets: generateRandomArray(() => faker.word.sample(), { min: 1, max: 10 }), + features: generateRandomArray(() => faker.helpers.enumValue(FEATURES), { min: 1, max: 10 }), + recommendedMasterCopyVersion: faker.system.semver(), + }) +} diff --git a/packages/utils/src/tests/transactions.ts b/packages/utils/src/tests/transactions.ts new file mode 100644 index 0000000000..d70744921c --- /dev/null +++ b/packages/utils/src/tests/transactions.ts @@ -0,0 +1,111 @@ +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { solidityPacked, concat } from 'ethers' +import { OperationType } from '@safe-global/types-kit' +import type { SafeTransaction } from '@safe-global/types-kit' + +import { ERC20__factory, ERC721__factory, Multi_send__factory } from '@safe-global/utils/types/contracts' +import EthSafeTransaction from '@safe-global/protocol-kit/dist/src/utils/transactions/SafeTransaction' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { TransactionStatus } from '@safe-global/safe-apps-sdk' + +export const getMockErc20TransferCalldata = (to: string) => { + const erc20Interface = ERC20__factory.createInterface() + return erc20Interface.encodeFunctionData('transfer', [ + to, + 0, // value + ]) +} + +export const getMockErc721TransferFromCalldata = (to: string) => { + const erc721Interface = ERC721__factory.createInterface() + return erc721Interface.encodeFunctionData('transferFrom', [ + ZERO_ADDRESS, // from + to, + 0, // value + ]) +} + +export const getMockErc721SafeTransferFromCalldata = (to: string) => { + const erc721Interface = ERC721__factory.createInterface() + return erc721Interface.encodeFunctionData('safeTransferFrom(address,address,uint256)', [ + ZERO_ADDRESS, // from + to, + 0, // value + ]) +} + +export const getMockErc721SafeTransferFromWithBytesCalldata = (to: string) => { + const erc721Interface = ERC721__factory.createInterface() + return erc721Interface.encodeFunctionData('safeTransferFrom(address,address,uint256,bytes)', [ + ZERO_ADDRESS, // from + to, + 0, // value + '0x', // bytes + ]) +} + +export const getMockMultiSendCalldata = (recipients: Array): string => { + // MultiSendCallOnly + const OPERATION = 0 + + const data = '0x' + + const internalTransactions = recipients.map((recipient) => { + return solidityPacked( + ['uint8', 'address', 'uint256', 'uint256', 'bytes'], + [ + OPERATION, + recipient, + 0, // value + data.length, // dataLength + data, // data + ], + ) + }) + + const multiSendInterface = Multi_send__factory.createInterface() + return multiSendInterface.encodeFunctionData('multiSend', [concat(internalTransactions)]) +} + +export const createMockTransactionDetails = ({ + txInfo, + txData, + detailedExecutionInfo, +}: { + txInfo: TransactionDetails['txInfo'] + txData: TransactionDetails['txData'] + detailedExecutionInfo: TransactionDetails['detailedExecutionInfo'] +}): TransactionDetails => ({ + safeAddress: 'sep:0xE20CcFf2c38Ef3b64109361D7b7691ff2c7D5f67', + txId: 'multisig_0xBd69b0a9DC90eB6F9bAc3E4a5875f437348b6415_0xcb83bc36cf4a2998e7fe222e36c458c59c3778f65b4e5bb361c29a73c2de62cc', + txStatus: TransactionStatus.AWAITING_CONFIRMATIONS, + txInfo, + txData, + detailedExecutionInfo, +}) + +// TODO: Replace with safeTxBuilder +export const createMockSafeTransaction = ({ + to, + data, + operation = OperationType.Call, + value, +}: { + to: string + data: string + operation?: OperationType + value?: string +}): SafeTransaction => { + return new EthSafeTransaction({ + to, + data, + operation, + value: value || '0', + baseGas: '0', + gasPrice: '0', + gasToken: ZERO_ADDRESS, + nonce: 0, + refundReceiver: ZERO_ADDRESS, + safeTxGas: '0', + }) +} diff --git a/packages/utils/src/tests/utils.ts b/packages/utils/src/tests/utils.ts new file mode 100644 index 0000000000..5892653303 --- /dev/null +++ b/packages/utils/src/tests/utils.ts @@ -0,0 +1,6 @@ +import { faker } from '@faker-js/faker' +import type { NumberModule } from '@faker-js/faker' + +export const generateRandomArray = (generator: () => T, options?: Parameters[0]): Array => { + return Array.from({ length: faker.number.int(options) }, generator) +} diff --git a/packages/utils/src/tests/web3Provider.ts b/packages/utils/src/tests/web3Provider.ts new file mode 100644 index 0000000000..a6ac11dac7 --- /dev/null +++ b/packages/utils/src/tests/web3Provider.ts @@ -0,0 +1,106 @@ +import { JsonRpcProvider, id, AbiCoder, Network, Interface } from 'ethers' +import { MULTICALL_ABI } from '../utils/multicall' +import { sameAddress } from '../utils/addresses' + +export type MockCallImplementation = { + signature: string + to?: string + returnType: string + returnValue: unknown +} + +const MULTI_CALL_INTERFACE = new Interface(MULTICALL_ABI) + +/** + * Creates a getWeb3 spy which returns a Web3Provider with a mocked `call` and `resolveName` function. + * It will automatically handle multicalls. + * + * @param callImplementations list of supported function calls and the mocked return value. i.e. + * ``` + * [{ + * signature: "balanceOf(address)", + * returnType: "uint256", + * returnValue: "200" + * }] + * ``` + * @param resolveName mock ens resolveName implementation + * @param chainId mock chainId + * @returns web3provider jest spy + */ +export const createMockWeb3Provider = ( + callImplementations: MockCallImplementation[], + resolveName?: (name: string) => string, + chainId?: string, +): JsonRpcProvider => { + const findImplementation = (data: string, to: string) => { + return callImplementations.find((implementation) => { + const sigHash = implementation.signature.startsWith('0x') + ? implementation.signature + : id(implementation.signature) + return data?.startsWith(sigHash.slice(0, 10)) && (implementation.to ? sameAddress(to, implementation.to) : true) + }) + } + + const mockWeb3ReadOnly = { + getNetwork: jest.fn(() => { + return new Network('mock', BigInt(chainId ?? 1)) + }), + call: jest.fn((tx: { data: string; to: string }) => { + const multiCallSignature = MULTI_CALL_INTERFACE.getFunction('aggregate3')?.selector! + // Auto handle multicalls + if ( + tx.data.startsWith(multiCallSignature) && + !callImplementations.some((implementation) => implementation.signature === multiCallSignature) + ) { + // Unwrap multicall and check if any selectors match + const calls = MULTI_CALL_INTERFACE.decodeFunctionData('aggregate3', tx.data)[0] + const results: { success: boolean; returnData: string }[] = [] + for (const call of calls) { + const [target, allowFailure, callData] = call + const matchedImplementation = findImplementation(callData, target) + if (!matchedImplementation) { + console.log('No matched implementation for call', callData) + results.push({ + success: false, + returnData: '0x', + }) + } else { + const returnData = + matchedImplementation.returnType === 'raw' + ? (matchedImplementation.returnValue as string) + : AbiCoder.defaultAbiCoder().encode( + [matchedImplementation.returnType], + [matchedImplementation.returnValue], + ) + results.push({ + success: true, + returnData, + }) + } + } + + return MULTI_CALL_INTERFACE.encodeFunctionResult('aggregate3', [results]) + } + + const matchedImplementation = findImplementation(tx.data, tx.to) + + if (!matchedImplementation) { + throw new Error(`No matcher for call data: ${tx.data}`) + } + + if (matchedImplementation.returnType === 'raw') { + return matchedImplementation.returnValue as string + } + + return AbiCoder.defaultAbiCoder().encode([matchedImplementation.returnType], [matchedImplementation.returnValue]) + }), + estimateGas: jest.fn(() => { + return Promise.resolve(50_000n) + }), + getTransactionReceipt: jest.fn(), + _isProvider: true, + resolveName, + } as unknown as JsonRpcProvider + + return mockWeb3ReadOnly +} diff --git a/apps/web/src/utils/__tests__/addresses.test.ts b/packages/utils/src/utils/__tests__/addresses.test.ts similarity index 100% rename from apps/web/src/utils/__tests__/addresses.test.ts rename to packages/utils/src/utils/__tests__/addresses.test.ts diff --git a/apps/web/src/utils/__tests__/date.test.ts b/packages/utils/src/utils/__tests__/date.test.ts similarity index 100% rename from apps/web/src/utils/__tests__/date.test.ts rename to packages/utils/src/utils/__tests__/date.test.ts diff --git a/apps/web/src/utils/__tests__/formatNumber.test.ts b/packages/utils/src/utils/__tests__/formatNumber.test.ts similarity index 73% rename from apps/web/src/utils/__tests__/formatNumber.test.ts rename to packages/utils/src/utils/__tests__/formatNumber.test.ts index d222a0e5bc..e3675c6718 100644 --- a/apps/web/src/utils/__tests__/formatNumber.test.ts +++ b/packages/utils/src/utils/__tests__/formatNumber.test.ts @@ -1,4 +1,10 @@ -import { formatAmountPrecise, formatAmount, formatCurrency, formatCurrencyPrecise } from '@/utils/formatNumber' +import { + formatAmountPrecise, + formatAmount, + formatCurrency, + formatCurrencyPrecise, + percentageOfTotal, +} from '@safe-global/utils/utils/formatNumber' describe('formatNumber', () => { describe('formatAmountPrecise', () => { @@ -90,4 +96,31 @@ describe('formatNumber', () => { expect(result).toBe('$NaN ') }) }) + + describe('percentageOfTotal', () => { + it('returns the correct fraction for typical inputs', () => { + expect(percentageOfTotal(30, 100)).toBeCloseTo(0.3) + expect(percentageOfTotal('75', '150')).toBeCloseTo(0.5) + }) + + it('handles a zero total by returning 0 (avoids division by 0)', () => { + expect(percentageOfTotal(10, 0)).toBe(0) + }) + + it('handles a negative total by returning 0', () => { + expect(percentageOfTotal(10, -50)).toBe(0) + }) + + it('handles non-numeric totals by returning 0', () => { + expect(percentageOfTotal(10, 'not-a-number')).toBe(0) + }) + + it('handles non-numeric balances by returning 0', () => { + expect(percentageOfTotal(NaN, 100)).toBe(0) + }) + + it('handles extremely large totals without throwing', () => { + expect(percentageOfTotal(1, Number.MAX_SAFE_INTEGER)).toBeCloseTo(1 / Number.MAX_SAFE_INTEGER) + }) + }) }) diff --git a/apps/web/src/utils/__tests__/formatters.test.ts b/packages/utils/src/utils/__tests__/formatters.test.ts similarity index 91% rename from apps/web/src/utils/__tests__/formatters.test.ts rename to packages/utils/src/utils/__tests__/formatters.test.ts index c77424456e..5df3fd5285 100644 --- a/apps/web/src/utils/__tests__/formatters.test.ts +++ b/packages/utils/src/utils/__tests__/formatters.test.ts @@ -1,4 +1,4 @@ -import * as formatters from '@/utils/formatters' +import * as formatters from '../formatters' import { parseEther } from 'ethers' describe('formatters', () => { @@ -116,4 +116,18 @@ describe('formatters', () => { expect(maybePlural(['1'])).toEqual('') }) }) + + describe('formatPercentage', () => { + it('should format percentage correctly', () => { + expect(formatters.formatPercentage(0.123456789)).toEqual('12.35%') + }) + + it('should not return signs', () => { + expect(formatters.formatPercentage(-0.123456789)).toEqual('12.35%') + }) + + it('should always show 2 decimals', () => { + expect(formatters.formatPercentage(0.69)).toEqual('69.00%') + }) + }) }) diff --git a/apps/web/src/utils/__tests__/gateway.test.ts b/packages/utils/src/utils/__tests__/gateway.test.ts similarity index 98% rename from apps/web/src/utils/__tests__/gateway.test.ts rename to packages/utils/src/utils/__tests__/gateway.test.ts index 59ec4fff59..93abf3fdfa 100644 --- a/apps/web/src/utils/__tests__/gateway.test.ts +++ b/packages/utils/src/utils/__tests__/gateway.test.ts @@ -1,4 +1,4 @@ -import { getExplorerLink, getHashedExplorerUrl, _replaceTemplate } from '../gateway' +import { getExplorerLink, getHashedExplorerUrl, _replaceTemplate } from '@safe-global/utils/utils/gateway' describe('gateway', () => { describe('replaceTemplate', () => { diff --git a/packages/utils/src/utils/__tests__/image.test.ts b/packages/utils/src/utils/__tests__/image.test.ts new file mode 100644 index 0000000000..07a9ccb0fb --- /dev/null +++ b/packages/utils/src/utils/__tests__/image.test.ts @@ -0,0 +1,54 @@ +import { upgradeCoinGeckoThumbToQuality } from '../image' + +describe('upgradeCoinGeckoThumbToQuality', () => { + it('should replace CoinGecko thumbnail URLs with large versions', () => { + const thumbnailUrl = 'https://coin-images.coingecko.com/coins/images/25244/thumb/Optimism.png' + const expectedUrl = 'https://coin-images.coingecko.com/coins/images/25244/large/Optimism.png' + + expect(upgradeCoinGeckoThumbToQuality(thumbnailUrl, 'large')).toBe(expectedUrl) + }) + + it('should replace CoinGecko thumbnail URLs with small versions', () => { + const thumbnailUrl = 'https://coin-images.coingecko.com/coins/images/25244/thumb/Optimism.png' + const expectedUrl = 'https://coin-images.coingecko.com/coins/images/25244/small/Optimism.png' + + expect(upgradeCoinGeckoThumbToQuality(thumbnailUrl, 'small')).toBe(expectedUrl) + }) + + it('should use small as default quality', () => { + const thumbnailUrl = 'https://coin-images.coingecko.com/coins/images/25244/thumb/Optimism.png' + const expectedUrl = 'https://coin-images.coingecko.com/coins/images/25244/small/Optimism.png' + + expect(upgradeCoinGeckoThumbToQuality(thumbnailUrl)).toBe(expectedUrl) + }) + + it('should return unchanged URL if it does not contain /thumb/', () => { + const coingeckoUrl = 'https://coin-images.coingecko.com/coins/images/25244/small/Optimism.png' + + expect(upgradeCoinGeckoThumbToQuality(coingeckoUrl, 'large')).toBe(coingeckoUrl) + }) + + it('should return unchanged URL if it is not from coingecko.com', () => { + const regularUrl = 'https://example.com/token-logo.png' + + expect(upgradeCoinGeckoThumbToQuality(regularUrl, 'large')).toBe(regularUrl) + }) + + it('should return unchanged URL for non-CoinGecko URLs with /thumb/', () => { + const nonCoingeckoUrl = 'https://example.com/assets/thumb/token.png' + + expect(upgradeCoinGeckoThumbToQuality(nonCoingeckoUrl, 'large')).toBe(nonCoingeckoUrl) + }) + + it('should handle null input', () => { + expect(upgradeCoinGeckoThumbToQuality(null, 'large')).toBeUndefined() + }) + + it('should handle undefined input', () => { + expect(upgradeCoinGeckoThumbToQuality(undefined, 'large')).toBeUndefined() + }) + + it('should handle empty string', () => { + expect(upgradeCoinGeckoThumbToQuality('', 'large')).toBe('') + }) +}) diff --git a/packages/utils/src/utils/__tests__/safe-setup-comparison.test.ts b/packages/utils/src/utils/__tests__/safe-setup-comparison.test.ts new file mode 100644 index 0000000000..97ad4d5c29 --- /dev/null +++ b/packages/utils/src/utils/__tests__/safe-setup-comparison.test.ts @@ -0,0 +1,123 @@ +import { areOwnersMatching, haveSameSetup, type SafeSetupData } from '../safe-setup-comparison' + +describe('safe-setup-comparison', () => { + describe('areOwnersMatching', () => { + it('should return true for identical owner arrays', () => { + const owners1 = ['0x1234', '0x5678', '0x9abc'] + const owners2 = ['0x1234', '0x5678', '0x9abc'] + + expect(areOwnersMatching(owners1, owners2)).toBe(true) + }) + + it('should return true for owners in different order', () => { + const owners1 = ['0x1234', '0x5678', '0x9abc'] + const owners2 = ['0x9abc', '0x1234', '0x5678'] + + expect(areOwnersMatching(owners1, owners2)).toBe(true) + }) + + it('should return false for different owner arrays', () => { + const owners1 = ['0x1234', '0x5678', '0x9abc'] + const owners2 = ['0x1234', '0x5678', '0xdiff'] + + expect(areOwnersMatching(owners1, owners2)).toBe(false) + }) + + it('should return false for arrays of different lengths', () => { + const owners1 = ['0x1234', '0x5678'] + const owners2 = ['0x1234', '0x5678', '0x9abc'] + + expect(areOwnersMatching(owners1, owners2)).toBe(false) + }) + + it('should return true for empty arrays', () => { + expect(areOwnersMatching([], [])).toBe(true) + }) + + it('should handle case-insensitive address comparison', () => { + const owners1 = ['0x1234ABCD', '0x5678EFGH'] + const owners2 = ['0x1234abcd', '0x5678efgh'] + + expect(areOwnersMatching(owners1, owners2)).toBe(true) + }) + }) + + describe('haveSameSetup', () => { + const createSafeSetup = (owners: string[], threshold: number): SafeSetupData => ({ + owners: owners.map((owner) => ({ value: owner })), + threshold, + }) + + const createSafeSetupStrings = (owners: string[], threshold: number): SafeSetupData => ({ + owners, + threshold, + }) + + it('should return true for identical safe setups with object owners', () => { + const safe1 = createSafeSetup(['0x1234', '0x5678'], 2) + const safe2 = createSafeSetup(['0x1234', '0x5678'], 2) + + expect(haveSameSetup(safe1, safe2)).toBe(true) + }) + + it('should return true for identical safe setups with string owners', () => { + const safe1 = createSafeSetupStrings(['0x1234', '0x5678'], 2) + const safe2 = createSafeSetupStrings(['0x1234', '0x5678'], 2) + + expect(haveSameSetup(safe1, safe2)).toBe(true) + }) + + it('should return true for safe setups with owners in different order', () => { + const safe1 = createSafeSetup(['0x1234', '0x5678'], 2) + const safe2 = createSafeSetup(['0x5678', '0x1234'], 2) + + expect(haveSameSetup(safe1, safe2)).toBe(true) + }) + + it('should return false for different thresholds', () => { + const safe1 = createSafeSetup(['0x1234', '0x5678'], 1) + const safe2 = createSafeSetup(['0x1234', '0x5678'], 2) + + expect(haveSameSetup(safe1, safe2)).toBe(false) + }) + + it('should return false for different owners', () => { + const safe1 = createSafeSetup(['0x1234', '0x5678'], 2) + const safe2 = createSafeSetup(['0x1234', '0x9abc'], 2) + + expect(haveSameSetup(safe1, safe2)).toBe(false) + }) + + it('should return false when one safe is null', () => { + const safe1 = createSafeSetup(['0x1234', '0x5678'], 2) + + expect(haveSameSetup(safe1, null)).toBe(false) + expect(haveSameSetup(null, safe1)).toBe(false) + }) + + it('should return false when one safe is undefined', () => { + const safe1 = createSafeSetup(['0x1234', '0x5678'], 2) + + expect(haveSameSetup(safe1, undefined)).toBe(false) + expect(haveSameSetup(undefined, safe1)).toBe(false) + }) + + it('should return false when both safes are null', () => { + expect(haveSameSetup(null, null)).toBe(false) + }) + + it('should handle mixed owner formats (objects vs strings)', () => { + const safe1 = createSafeSetup(['0x1234', '0x5678'], 2) + const safe2 = createSafeSetupStrings(['0x1234', '0x5678'], 2) + + expect(haveSameSetup(safe1, safe2)).toBe(true) + }) + + it('should handle case-insensitive addresses', () => { + const safe1 = createSafeSetup(['0x1234ABCD', '0x5678EFGH'], 2) + const safe2 = createSafeSetup(['0x1234abcd', '0x5678efgh'], 2) + + expect(haveSameSetup(safe1, safe2)).toBe(true) + }) + }) +}) diff --git a/apps/web/src/utils/__tests__/validation.test.ts b/packages/utils/src/utils/__tests__/validation.test.ts similarity index 95% rename from apps/web/src/utils/__tests__/validation.test.ts rename to packages/utils/src/utils/__tests__/validation.test.ts index ecd8b4e43a..ce1a233e55 100644 --- a/apps/web/src/utils/__tests__/validation.test.ts +++ b/packages/utils/src/utils/__tests__/validation.test.ts @@ -6,7 +6,7 @@ import { validateDecimalLength, isValidAddress, isValidURL, -} from '@/utils/validation' +} from '@safe-global/utils/utils/validation' describe('validation', () => { describe('Ethereum address validation', () => { @@ -89,6 +89,11 @@ describe('validation', () => { const result2 = validateLimitedAmount('101', 18, '100000000000000000000') expect(result2).toBe('Maximum value is 100') }) + + it('returns a custom error message if provided', () => { + const result = validateLimitedAmount('101', 18, '100', 'Custom error message') + expect(result).toBe('Custom error message') + }) }) describe('Decimal length validation', () => { diff --git a/apps/web/src/utils/__tests__/web3.test.ts b/packages/utils/src/utils/__tests__/web3.test.ts similarity index 93% rename from apps/web/src/utils/__tests__/web3.test.ts rename to packages/utils/src/utils/__tests__/web3.test.ts index 29a1cede66..5ec3fbadb7 100644 --- a/apps/web/src/utils/__tests__/web3.test.ts +++ b/packages/utils/src/utils/__tests__/web3.test.ts @@ -1,5 +1,5 @@ import type { JsonRpcSigner } from 'ethers' -import { signTypedData } from '../web3' +import { signTypedData } from '@safe-global/utils/utils/web3' describe('web3', () => { describe('signTypedData', () => { @@ -20,6 +20,7 @@ describe('web3', () => { EIP712Domain: [], }, message: {}, + primaryType: 'EIP712Domain', } const result = await signTypedData(signer as unknown as JsonRpcSigner, typedData) expect(result).toBe(mockSignature) @@ -39,6 +40,7 @@ describe('web3', () => { EIP712Domain: [], }, message: {}, + primaryType: 'EIP712Domain', } await expect(signTypedData(signer as unknown as JsonRpcSigner, typedData)).rejects.toThrow('error') }) @@ -71,6 +73,7 @@ describe('web3', () => { safeTxHash: '0x1234567890123456789012345678901234567890123456789012345678901234', totp: Math.floor(Date.now() / 3600e3), }, + primaryType: 'DeleteRequest', } const result = await signTypedData(signer as unknown as JsonRpcSigner, typedData) expect(result).toBe(mockSignature) diff --git a/apps/web/src/utils/addresses.ts b/packages/utils/src/utils/addresses.ts similarity index 100% rename from apps/web/src/utils/addresses.ts rename to packages/utils/src/utils/addresses.ts diff --git a/packages/utils/src/utils/chains.ts b/packages/utils/src/utils/chains.ts new file mode 100644 index 0000000000..5cf2d80965 --- /dev/null +++ b/packages/utils/src/utils/chains.ts @@ -0,0 +1,83 @@ +import { getExplorerLink } from '@safe-global/utils/utils/gateway' +import type { SafeVersion } from '@safe-global/types-kit' +import { getSafeSingletonDeployment } from '@safe-global/safe-deployments' +import semverSatisfies from 'semver/functions/satisfies' +import { LATEST_SAFE_VERSION } from '@safe-global/utils/config/constants' +import type { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains' + +export enum FEATURES { + ERC721 = 'ERC721', + SAFE_APPS = 'SAFE_APPS', + DOMAIN_LOOKUP = 'DOMAIN_LOOKUP', + SPENDING_LIMIT = 'SPENDING_LIMIT', + EIP1559 = 'EIP1559', + SAFE_TX_GAS_OPTIONAL = 'SAFE_TX_GAS_OPTIONAL', + TX_SIMULATION = 'TX_SIMULATION', + DEFAULT_TOKENLIST = 'DEFAULT_TOKENLIST', + RELAYING = 'RELAYING', + EIP1271 = 'EIP1271', + RISK_MITIGATION = 'RISK_MITIGATION', + PUSH_NOTIFICATIONS = 'PUSH_NOTIFICATIONS', + NATIVE_WALLETCONNECT = 'NATIVE_WALLETCONNECT', + RECOVERY = 'RECOVERY', + COUNTERFACTUAL = 'COUNTERFACTUAL', + DELETE_TX = 'DELETE_TX', + SPEED_UP_TX = 'SPEED_UP_TX', + SAP_BANNER = 'SAP_BANNER', + NATIVE_SWAPS = 'NATIVE_SWAPS', + NATIVE_SWAPS_USE_COW_STAGING_SERVER = 'NATIVE_SWAPS_USE_COW_STAGING_SERVER', + NATIVE_SWAPS_FEE_ENABLED = 'NATIVE_SWAPS_FEE_ENABLED', + NATIVE_SWAPS_COW = 'NATIVE_SWAPS_COW', + ZODIAC_ROLES = 'ZODIAC_ROLES', + STAKING = 'STAKING', + STAKING_BANNER = 'STAKING_BANNER', + MULTI_CHAIN_SAFE_CREATION = 'MULTI_CHAIN_SAFE_CREATION', + MULTI_CHAIN_SAFE_ADD_NETWORK = 'MULTI_CHAIN_SAFE_ADD_NETWORK', + PROPOSERS = 'PROPOSERS', + TARGETED_SURVEY = 'TARGETED_SURVEY', + BRIDGE = 'BRIDGE', + RENEW_NOTIFICATIONS_TOKEN = 'RENEW_NOTIFICATIONS_TOKEN', + TX_NOTES = 'TX_NOTES', + NESTED_SAFES = 'NESTED_SAFES', + MASS_PAYOUTS = 'MASS_PAYOUTS', + SPACES = 'SPACES', + EARN = 'EARN', + MIXPANEL = 'MIXPANEL', +} + +const MIN_SAFE_VERSION = '1.3.0' + +export const hasFeature = (chain: Pick, feature: FEATURES): boolean => { + return (chain.features as string[]).includes(feature) +} + +export const getBlockExplorerLink = ( + chain: Pick, + address: string, +): { href: string; title: string } | undefined => { + if (chain.blockExplorerUriTemplate) { + return getExplorerLink(address, chain.blockExplorerUriTemplate) + } +} +/** This version is used if a network does not have the LATEST_SAFE_VERSION deployed yet */ +const FALLBACK_SAFE_VERSION = '1.3.0' as const +export const getLatestSafeVersion = ( + chain: Pick | undefined, +): SafeVersion => { + const latestSafeVersion = chain?.recommendedMasterCopyVersion || LATEST_SAFE_VERSION + + // Without version filter it will always return the LATEST_SAFE_VERSION constant to avoid automatically updating to the newest version if the deployments change + const latestDeploymentVersion = (getSafeSingletonDeployment({ network: chain?.chainId, released: true })?.version ?? + FALLBACK_SAFE_VERSION) as SafeVersion + + // The version needs to be smaller or equal to the + if (semverSatisfies(latestDeploymentVersion, `<=${latestSafeVersion}`)) { + return latestDeploymentVersion + } else { + return latestSafeVersion as SafeVersion + } +} + +export const isNonCriticalUpdate = (version?: string | null) => { + return version && semverSatisfies(version, `>= ${MIN_SAFE_VERSION}`) +} diff --git a/apps/web/src/utils/date.ts b/packages/utils/src/utils/date.ts similarity index 100% rename from apps/web/src/utils/date.ts rename to packages/utils/src/utils/date.ts diff --git a/apps/web/src/utils/formatNumber.ts b/packages/utils/src/utils/formatNumber.ts similarity index 79% rename from apps/web/src/utils/formatNumber.ts rename to packages/utils/src/utils/formatNumber.ts index b05ca4b346..d6af401ad0 100644 --- a/apps/web/src/utils/formatNumber.ts +++ b/packages/utils/src/utils/formatNumber.ts @@ -81,3 +81,23 @@ export const formatCurrencyPrecise = (number: string | number, currency: string) const result = getCurrencyFormatter(currency, false, 2, 2).format(Number(number)) return result.replace(/^(\D+)/, '$1 ') } + +/** + * Safely compute the ratio `balance / total`. + * + * @param balance The asset’s fiat balance + * @param total The overall fiat total + * @returns A number between 0 and 1. Returns 0 when the inputs are non-numeric, Infinity, or when total ≤ 0. + */ +export function percentageOfTotal(balance: number | string, total: number | string): number { + const totalNum = Number(total) + const balanceNum = Number(balance) + + // invalid, zero or negative totals → return 0 to avoid division by 0/−n + if (!Number.isFinite(totalNum) || totalNum <= 0) return 0 + + // invalid balances → treat as 0 so the overall percentage still works + if (!Number.isFinite(balanceNum)) return 0 + + return balanceNum / totalNum +} diff --git a/apps/web/src/utils/formatters.ts b/packages/utils/src/utils/formatters.ts similarity index 79% rename from apps/web/src/utils/formatters.ts rename to packages/utils/src/utils/formatters.ts index 0c2a9db86a..17858fb6cc 100644 --- a/apps/web/src/utils/formatters.ts +++ b/packages/utils/src/utils/formatters.ts @@ -1,6 +1,6 @@ import type { BigNumberish } from 'ethers' import { formatUnits, parseUnits } from 'ethers' -import { formatAmount, formatAmountPrecise } from './formatNumber' +import { formatAmount, formatAmountPrecise } from '@safe-global/utils/utils/formatNumber' import { formatDuration, intervalToDuration } from 'date-fns' const GWEI = 'gwei' @@ -16,7 +16,8 @@ export const _removeTrailingZeros = (value: string): string => { * @param decimals decimals of the specified value or unit name * @returns value at specified decimals, i.e. 0.000000000000000001 */ -export const safeFormatUnits = (value: BigNumberish, decimals: number | string = GWEI): string => { +export const safeFormatUnits = (value: BigNumberish, decimals?: number | string | null): string => { + decimals = decimals ?? GWEI try { const formattedAmount = formatUnits(value, decimals) @@ -36,16 +37,16 @@ export const safeFormatUnits = (value: BigNumberish, decimals: number | string = */ export const formatVisualAmount = ( value: BigNumberish, - decimals: number | string = GWEI, + decimals?: number | string | null, precision?: number, ): string => { - const amount = safeFormatUnits(value, decimals) + const amount = safeFormatUnits(value, decimals ?? GWEI) return precision !== undefined ? formatAmountPrecise(amount, precision) : formatAmount(amount) } -export const safeParseUnits = (value: string, decimals: number | string = GWEI): bigint | undefined => { +export const safeParseUnits = (value: string, decimals?: number | string | null): bigint | undefined => { try { - return parseUnits(value, decimals) + return parseUnits(value, decimals ?? GWEI) } catch (err) { console.error('Error parsing units', err) return @@ -60,7 +61,7 @@ export const shortenAddress = (address: string, length = 4): string => { return `${address.slice(0, length + 2)}...${address.slice(-length)}` } -export const shortenText = (text: string, length = 10, separator = '...'): string => { +export const shortenText = (text: string, length = 10, separator = '…'): string => { return `${text.slice(0, length)}${separator}` } @@ -108,3 +109,14 @@ export const maybePlural = (quantity: number | unknown[]) => { quantity = Array.isArray(quantity) ? quantity.length : quantity return quantity === 1 ? '' : 's' } + +export const formatPercentage = (value: number, hideFractions?: boolean) => { + const fraction = hideFractions ? 0 : 2 + + return new Intl.NumberFormat(undefined, { + style: 'percent', + maximumFractionDigits: fraction, + signDisplay: 'never', + minimumFractionDigits: fraction, + }).format(value) +} diff --git a/apps/mobile/src/utils/gateway.ts b/packages/utils/src/utils/gateway.ts similarity index 99% rename from apps/mobile/src/utils/gateway.ts rename to packages/utils/src/utils/gateway.ts index 770301365f..826502bd79 100644 --- a/apps/mobile/src/utils/gateway.ts +++ b/packages/utils/src/utils/gateway.ts @@ -16,7 +16,6 @@ export const getHashedExplorerUrl = ( return _replaceTemplate(blockExplorerUriTemplate[param], { [param]: hash }) } - export const getExplorerLink = ( hash: string, blockExplorerUriTemplate: Chain['blockExplorerUriTemplate'], diff --git a/packages/utils/src/utils/helpers.ts b/packages/utils/src/utils/helpers.ts new file mode 100644 index 0000000000..d75982c9e2 --- /dev/null +++ b/packages/utils/src/utils/helpers.ts @@ -0,0 +1,7 @@ +export function invariant(condition: T, error: string): asserts condition { + if (condition) { + return + } + + throw new Error(error) +} diff --git a/packages/utils/src/utils/hex.ts b/packages/utils/src/utils/hex.ts new file mode 100644 index 0000000000..7bac3d8481 --- /dev/null +++ b/packages/utils/src/utils/hex.ts @@ -0,0 +1,3 @@ +export const isEmptyHexData = (encodedData: string): boolean => encodedData !== '' && isNaN(parseInt(encodedData, 16)) + +export const numberToHex = (value: number | bigint): `0x${string}` => `0x${value.toString(16)}` diff --git a/packages/utils/src/utils/image.ts b/packages/utils/src/utils/image.ts new file mode 100644 index 0000000000..69b4240d59 --- /dev/null +++ b/packages/utils/src/utils/image.ts @@ -0,0 +1,19 @@ +const COINGECKO_THUMB = '/thumb/' +const COINGECKO_DOMAIN = 'coingecko.com' + +type CoinGeckoImageQuality = 'small' | 'large' + +export const upgradeCoinGeckoThumbToQuality = ( + logoUri?: string | null, + quality: CoinGeckoImageQuality = 'small', +): string | undefined => { + if (logoUri === null || logoUri === undefined) { + return undefined + } + + if (logoUri === '' || !logoUri.includes(COINGECKO_DOMAIN)) { + return logoUri + } + + return logoUri.replace(COINGECKO_THUMB, `/${quality}/`) +} diff --git a/packages/utils/src/utils/multicall/deployments.ts b/packages/utils/src/utils/multicall/deployments.ts new file mode 100644 index 0000000000..c620e3d141 --- /dev/null +++ b/packages/utils/src/utils/multicall/deployments.ts @@ -0,0 +1,1454 @@ +type MulticallDeployment = { + name: string + chainId: number + url: string + address?: string +} + +export const CANONICAL_MULTICALL_ADDRESSS = '0xca11bde05977b3631167028862be2a173976ca11' + +export const MULTICALL_DEPLOYMENTS: MulticallDeployment[] = [ + { + name: 'Mainnet', + chainId: 1, + url: 'https://etherscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Kovan', + chainId: 42, + url: 'https://kovan.etherscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Rinkeby', + chainId: 4, + url: 'https://rinkeby.etherscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Görli', + chainId: 5, + url: 'https://goerli.etherscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Ropsten', + chainId: 3, + url: 'https://ropsten.etherscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Sepolia', + chainId: 11155111, + url: 'https://sepolia.etherscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Holesky', + chainId: 17000, + url: 'https://holesky.etherscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Story', + chainId: 1514, + url: 'https://www.storyscan.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Story Aeneid Testnet', + chainId: 1315, + url: 'https://aeneid.storyscan.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Xterio Chain', + chainId: 112358, + url: 'https://xterscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Xterio Testnet', + chainId: 1637450, + url: 'https://testnet.xterscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Optimism', + chainId: 10, + url: 'https://optimistic.etherscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Optimism Kovan', + chainId: 69, + url: 'https://kovan-optimistic.etherscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Optimism Görli', + chainId: 420, + url: 'https://blockscout.com/optimism/goerli/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Optimism Sepolia', + chainId: 11155420, + url: 'https://optimism-sepolia.blockscout.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Arbitrum', + chainId: 42161, + url: 'https://arbiscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Arbitrum Nova', + chainId: 42170, + url: 'https://nova.arbiscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Arbitrum Görli', + chainId: 421613, + url: 'https://goerli-rollup-explorer.arbitrum.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Arbitrum Sepolia', + chainId: 421614, + url: 'https://sepolia-explorer.arbitrum.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Arbitrum Rinkeby', + chainId: 421611, + url: 'https://testnet.arbiscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Stylus Testnet', + chainId: 23011913, + url: 'https://stylus-testnet-explorer.arbitrum.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Polygon', + chainId: 137, + url: 'https://polygonscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Mumbai', + chainId: 80001, + url: 'https://mumbai.polygonscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Amoy', + chainId: 80002, + url: 'https://amoy.polygonscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Polygon zkEVM', + chainId: 1101, + url: 'https://zkevm.polygonscan.com/address/0xca11bde05977b3631167028862be2a173976ca11#code', + }, + { + name: 'Polygon zkEVM Testnet', + chainId: 1442, + url: 'https://testnet-zkevm.polygonscan.com/address/0xca11bde05977b3631167028862be2a173976ca11#code', + }, + { + name: 'Cardona zkEVM Testnet', + chainId: 2442, + url: 'https://cardona-zkevm.polygonscan.com/address/0xca11bde05977b3631167028862be2a173976ca11#code', + }, + { + name: 'Gnosis Chain (xDai)', + chainId: 100, + url: 'https://blockscout.com/xdai/mainnet/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Chiado (Gnosis Chain Testnet)', + chainId: 10200, + url: 'https://blockscout.chiadochain.net/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Avalanche', + chainId: 43114, + url: 'https://snowtrace.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Avalanche Fuji', + chainId: 43113, + url: 'https://testnet.snowtrace.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Fantom Testnet', + chainId: 4002, + url: 'https://testnet.ftmscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Fantom Opera', + chainId: 250, + url: 'https://ftmscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Fantom Sonic', + chainId: 64240, + url: 'https://public-sonic.fantom.network/address/0xca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'Sonic Network', + chainId: 146, + url: 'https://sonicscan.org/address/0xca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'BNB Smart Chain', + chainId: 56, + url: 'https://bscscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'BNB Smart Chain Testnet', + chainId: 97, + url: 'https://testnet.bscscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'opBNB Testnet', + chainId: 5611, + url: 'https://opbnbscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?p=1&tab=Contract', + }, + { + name: 'opBNB Mainnet', + chainId: 204, + url: 'https://mainnet.opbnbscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?p=1&tab=Contract', + }, + { + name: 'Moonbeam', + chainId: 1284, + url: 'https://moonscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Moonriver', + chainId: 1285, + url: 'https://moonriver.moonscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Moonbase Alpha Testnet', + chainId: 1287, + url: 'https://moonbase.moonscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Palm', + chainId: 11297108109, + url: 'https://palm.chainlens.com/contracts/0xca11bde05977b3631167028862be2a173976ca11/sources', + }, + { + name: 'Palm Testnet', + chainId: 11297108099, + url: 'https://testnet.palm.chainlens.com/contracts/0xca11bde05977b3631167028862be2a173976ca11/sources', + }, + { + name: 'Harmony', + chainId: 1666600000, + url: 'https://explorer.harmony.one/address/0xcA11bde05977b3631167028862bE2a173976CA11?activeTab=7', + }, + { + name: 'Cronos', + chainId: 25, + url: 'https://cronoscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Cronos Testnet', + chainId: 338, + url: 'https://cronos.org/explorer/testnet3/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Reya Cronos', + chainId: 89346162, + url: 'https://reya-cronos.blockscout.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Reya Network', + chainId: 1729, + url: 'https://explorer.reya.network/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Fuse', + chainId: 122, + url: 'https://explorer.fuse.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Flare Mainnet', + chainId: 14, + url: 'https://flare-explorer.flare.network/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Songbird Canary Network', + chainId: 19, + url: 'https://songbird-explorer.flare.network/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Coston Testnet', + chainId: 16, + url: 'https://coston-explorer.flare.network/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Coston2 Testnet', + chainId: 114, + url: 'https://coston2-explorer.flare.network/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Boba', + chainId: 288, + url: 'https://blockexplorer.boba.network/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Aurora', + chainId: 1313161554, + url: 'https://explorer.mainnet.aurora.dev/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Astar', + chainId: 592, + url: 'https://blockscout.com/astar/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Astar zKyoto Testnet', + chainId: 6038361, + url: 'https://zkyoto.explorer.startale.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Astar zkEVM', + chainId: 3776, + url: 'https://astar-zkevm.explorer.startale.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'OKC', + chainId: 66, + url: 'https://www.oklink.com/en/okc/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Heco Chain', + chainId: 128, + url: 'https://hecoinfo.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Metis Andromeda', + chainId: 1088, + url: 'https://andromeda-explorer.metis.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Metis Goerli', + chainId: 599, + url: 'https://goerli.explorer.metisdevops.link/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Metis Sepolia', + chainId: 59902, + url: 'https://sepolia-explorer.metisdevops.link/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Over Protocol', + chainId: 54176, + url: 'https://scan.over.network/address/0x03657CDcDA1523C073b5e09c37dd199E6fBD1b99', + }, + { + name: 'Over Protocol Dolphin Testnet', + chainId: 541764, + url: 'https://dolphin-scan.over.network/address/0x03657CDcDA1523C073b5e09c37dd199E6fBD1b99', + }, + { + name: 'RSK', + chainId: 30, + url: 'https://explorer.rsk.co/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'RSK Testnet', + chainId: 31, + url: 'https://explorer.testnet.rsk.co/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Evmos', + chainId: 9001, + url: 'https://evm.evmos.org/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Evmos Testnet', + chainId: 9000, + url: 'https://evm.evmos.dev/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Thundercore', + chainId: 108, + url: 'https://viewblock.io/thundercore/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=code', + }, + { + name: 'Thundercore Testnet', + chainId: 18, + url: 'https://explorer-testnet.thundercore.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Oasis', + chainId: 42262, + url: 'https://explorer.emerald.oasis.dev/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Oasis Sapphire', + chainId: 23294, + url: 'https://explorer.sapphire.oasis.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Celo', + chainId: 42220, + url: 'https://explorer.celo.org/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Celo Alfajores Testnet', + chainId: 44787, + url: 'https://explorer.celo.org/alfajores/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Godwoken', + chainId: 71402, + url: 'https://v1.gwscan.com/account/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Godwoken Testnet', + chainId: 71401, + url: 'https://gw-explorer.nervosdao.community/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Klaytn', + chainId: 8217, + url: 'https://scope.klaytn.com/account/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Klaytn Testnet (Baobab)', + chainId: 1001, + url: 'https://baobab.klaytnscope.com/account/0xca11bde05977b3631167028862be2a173976ca11?tabId=contractCode', + }, + { + name: 'Milkomeda', + chainId: 2001, + url: 'https://explorer-mainnet-cardano-evm.c1.milkomeda.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'KCC', + chainId: 321, + url: 'https://explorer.kcc.io/en/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Velas', + chainId: 106, + url: 'https://evmexplorer.velas.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Telos', + chainId: 40, + url: 'https://www.teloscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#contract', + }, + { + name: 'Step Network', + chainId: 1234, + url: 'https://stepscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Canto', + chainId: 7700, + url: 'https://tuber.build/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Canto Testnet', + chainId: 7701, + url: 'https://testnet.tuber.build/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Iotex', + chainId: 4689, + url: 'https://iotexscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#transactions', + }, + { + name: 'Bitgert', + chainId: 32520, + url: 'https://brisescan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Kava', + chainId: 2222, + url: 'https://explorer.kava.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Mantle Sepolia Testnet', + chainId: 5003, + url: 'https://explorer.sepolia.mantle.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Mantle Testnet', + chainId: 5001, + url: 'https://explorer.testnet.mantle.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Mantle', + chainId: 5000, + url: 'https://explorer.mantle.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Shardeum Sphinx', + chainId: 8082, + url: 'https://explorer.testnet.mantle.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Base Testnet (Goerli)', + chainId: 84531, + url: 'https://goerli.basescan.org/address/0xca11bde05977b3631167028862be2a173976ca11#code', + }, + { + name: 'Base Testnet (Sepolia)', + chainId: 84532, + url: 'https://base-sepolia.blockscout.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Base', + chainId: 8453, + url: 'https://basescan.org/address/0xca11bde05977b3631167028862be2a173976ca11#code', + }, + { + name: 'Kroma Testnet (Sepolia)', + chainId: 2358, + url: 'https://sepolia.kromascan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Kroma', + chainId: 255, + url: 'https://kromascan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'DeFiChain EVM Mainnet', + chainId: 1130, + url: 'https://meta.defiscan.live/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'DeFiChain EVM Testnet', + chainId: 1131, + url: 'https://meta.defiscan.live/address/0xcA11bde05977b3631167028862bE2a173976CA11?network=TestNet', + }, + { + name: 'Defi Oracle Meta Mainnet', + chainId: 138, + url: 'https://blockscout.defi-oracle.io/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'DFK Chain Test', + chainId: 335, + url: 'https://subnets-test.avax.network/defi-kingdoms/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'DFK Chain', + chainId: 53935, + url: 'https://subnets.avax.network/defi-kingdoms/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Neon EVM DevNet', + chainId: 245022926, + url: 'https://devnet.neonscan.org/address/0xcA11bde05977b3631167028862bE2a173976CA11#contract', + }, + { + name: 'Linea Sepolia Testnet', + chainId: 59141, + url: 'https://sepolia.lineascan.build/address/0xca11bde05977b3631167028862be2a173976ca11#code', + }, + { + name: 'Linea Goerli Testnet', + chainId: 59140, + url: 'https://explorer.goerli.linea.build/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Linea Mainnet', + chainId: 59144, + url: 'https://lineascan.build/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Hashbit', + chainId: 11119, + url: 'https://explorer.hashbit.org/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Syscoin', + chainId: 57, + url: 'https://explorer.syscoin.org/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Syscoin Rollux Mainnet', + chainId: 570, + url: 'https://explorer.rollux.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Syscoin Tannebaum Testnet', + chainId: 5700, + url: 'https://tanenbaum.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Syscoin Tannebaum Rollux', + chainId: 57000, + url: 'https://rollux.tanenbaum.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Pulsechain v4 Testnet', + chainId: 943, + url: 'https://scan.v4.testnet.pulsechain.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Pulsechain Mainnet', + chainId: 369, + url: 'https://scan.pulsechain.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Zora Goerli Testnet', + chainId: 999, + url: 'https://testnet.explorer.zora.co/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Zora', + chainId: 7777777, + url: 'https://explorer.zora.co/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Zora Sepolia Testnet', + chainId: 999999999, + url: 'https://sepolia.explorer.zora.energy/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Darwinia Crab Network', + chainId: 44, + url: 'https://crab.subscan.io/account/0xca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'Darwinia Network', + chainId: 46, + url: 'https://darwinia.subscan.io/account/0xca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'Chain Verse Mainnet', + chainId: 5555, + url: 'https://explorer.chainverse.info/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Scroll Alpha Testnet', + chainId: 534353, + url: 'https://blockscout.scroll.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Scroll Sepolia Testnet', + chainId: 534351, + url: 'https://sepolia.scrollscan.dev/address/0xca11bde05977b3631167028862be2a173976ca11#code', + }, + { + name: 'Scroll', + chainId: 534352, + url: 'https://scrollscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Xodex', + chainId: 2415, + url: 'https://explorer.xo-dex.com/contracts/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'EOS EVM Testnet', + chainId: 15557, + url: 'https://explorer.testnet.evm.eosnetwork.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'EOS EVM', + chainId: 17777, + url: 'https://explorer.evm.eosnetwork.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Crossbell', + chainId: 3737, + url: 'https://scan.crossbell.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Dogechain', + chainId: 2000, + url: 'https://explorer.dogechain.dog/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'MEVerse Chain Testnet', + chainId: 4759, + url: 'https://testnet.meversescan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'MEVerse Chain Mainnet', + chainId: 7518, + url: 'https://meversescan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'SKALE Calypso Testnet', + chainId: 974399131, + url: 'https://giant-half-dual-testnet.explorer.testnet.skalenodes.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'SKALE Europa Testnet', + chainId: 1444673419, + url: 'https://juicy-low-small-testnet.explorer.testnet.skalenodes.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'SKALE Nebula Testnet', + chainId: 37084624, + url: 'https://lanky-ill-funny-testnet.explorer.testnet.skalenodes.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'SKALE Titan Testnet', + chainId: 1020352220, + url: 'https://aware-fake-trim-testnet.explorer.testnet.skalenodes.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'SKALE Calypso Hub', + chainId: 1564830818, + url: 'https://honorable-steel-rasalhague.explorer.mainnet.skalenodes.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'SKALE Europa Liquidity Hub', + chainId: 2046399126, + url: 'https://elated-tan-skat.explorer.mainnet.skalenodes.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'SKALE Nebula Gaming Hub', + chainId: 1482601649, + url: 'https://green-giddy-denebola.explorer.mainnet.skalenodes.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'SKALE Titan AI Hub', + chainId: 1350216234, + url: 'https://parallel-stormy-spica.explorer.mainnet.skalenodes.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Ronin Saigon Testnet', + chainId: 2021, + url: 'https://saigon-app.roninchain.com/address/ronin:ca11bde05977b3631167028862be2a173976ca11?t=contract', + }, + { + name: 'Ronin Mainnet', + chainId: 2020, + url: 'https://app.roninchain.com/address/ronin:ca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'Qitmeer Testnet', + chainId: 8131, + url: 'https://testnet-qng.qitmeer.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Qitmeer QNG Mainnet', + chainId: 813, + url: 'https://qng.meerscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Q Testnet', + chainId: 35443, + url: 'https://explorer.qtestnet.org/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Q Devnet', + chainId: 35442, + url: 'https://explorer.qdevnet.org/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Q Mainnet', + chainId: 35441, + url: 'https://explorer.q.org/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Neon Mainnet', + chainId: 245022934, + url: 'https://neonscan.org/address/0xca11bde05977b3631167028862be2a173976ca11#contract', + }, + { + name: 'LUKSO Testnet', + chainId: 4201, + url: 'https://explorer.execution.testnet.lukso.network/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'LUKSO Mainnet', + chainId: 42, + url: 'https://explorer.execution.mainnet.lukso.network/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Edgeware EdgeEVM', + chainId: 2021, + url: 'https://edgscan.live/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts', + }, + { + name: 'Meter Testnet', + chainId: 83, + url: 'https://scan-warringstakes.meter.io/address/0xca11bde05977b3631167028862be2a173976ca11?tab=0&p=1', + }, + { + name: 'Meter', + chainId: 82, + url: 'https://scan.meter.io/address/0xca11bde05977b3631167028862be2a173976ca11?tab=0&p=1', + }, + { + name: 'Sepolia PGN (Public Goods Network) Testnet', + chainId: 58008, + url: 'https://explorer.sepolia.publicgoods.network/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'PGN (Public Goods Network)', + chainId: 424, + url: 'https://explorer.publicgoods.network/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'ShimmerEVM', + chainId: 148, + url: 'https://explorer.evm.shimmer.network/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Highbury EVM', + chainId: 710, + url: 'https://explorer.furya.io/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Arthera Testnet', + chainId: 10243, + url: 'https://explorer-test.arthera.net/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Arthera Mainnet', + chainId: 10242, + url: 'https://explorer.arthera.net/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Manta Pacific Mainnet', + chainId: 169, + url: 'https://pacific-explorer.manta.network/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Jolnir (Taiko Testnet)', + chainId: 167007, + url: 'https://explorer.jolnir.taiko.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Katla (Taiko A6 Testnet)', + chainId: 167008, + url: 'https://explorer.katla.taiko.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Filecoin Mainnet', + chainId: 314, + url: 'https://filfox.info/en/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Filecoin Calibration Testnet', + chainId: 314159, + url: 'https://calibration.filscan.io/en/tx/0xdbfa261cd7d17bb40479a0493ad6c0fee435859e37aae73aa7e803f3122cc465/', + }, + { + name: 'Fusion', + chainId: 32659, + url: 'https://fsnscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#contract', + }, + { + name: 'Fusion Testnet', + chainId: 46688, + url: 'https://testnet.fsnscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#contract', + }, + { + name: 'Xai Testnet', + chainId: 47279324479, + url: 'https://testnet-explorer.xai-chain.net/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'JFIN Chain', + chainId: 3501, + url: 'https://jfinscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'JFIN Chain Testnet', + chainId: 3502, + url: 'https://testnet.jfinscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Chiliz Chain', + chainId: 88888, + url: 'https://scan.chiliz.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Chiliz Spicy Testnet', + chainId: 88882, + url: 'https://testnet.chiliscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11/contract/88882/code', + }, + { + name: 'CORE', + chainId: 1116, + url: 'https://scan.coredao.org/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Core Testnet', + chainId: 1115, + url: 'https://scan.test.btcs.network/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Core Testnet2', + chainId: 1114, + url: 'https://scan.test2.btcs.network/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Ethereum Classic', + chainId: 61, + url: 'https://etc.blockscout.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Frame Testnet', + chainId: 68840142, + url: 'https://explorer.testnet.frame.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Etherlink Mainnet', + chainId: 42793, + url: 'https://explorer.etherlink.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Etherlink Testnet', + chainId: 128123, + url: 'https://testnet-explorer.etherlink.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'ZetaChain Athens 3 Testnet', + chainId: 7001, + url: 'https://explorer.zetachain.com/address/0xca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'ZetaChain ', + chainId: 7000, + url: 'https://explorer.zetachain.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'X1 Testnet', + chainId: 195, + url: 'https://www.oklink.com/x1-test/address/0xca11bde05977b3631167028862be2a173976ca11/contract', + }, + { + name: 'Lumiterra Layer3', + chainId: 94168, + url: 'https://scan.layerlumi.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'BitTorrent Chain Mainnet', + chainId: 199, + url: 'https://bttcscan.com/address/0xca11bde05977b3631167028862be2a173976ca11#code', + }, + { + name: 'BTT Chain Testnet', + chainId: 1029, + url: 'https://testnet.bttcscan.com/address/0xca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'Callisto Mainnet', + chainId: 820, + url: 'https://explorer.callisto.network/address/0xcA11bde05977b3631167028862bE2a173976CA11/transactions', + }, + { + name: 'Areon Network Testnet', + chainId: 462, + url: 'https://areonscan.com/contracts/0xca11bde05977b3631167028862be2a173976ca11?page=0', + }, + { + name: 'Areon Network Mainnet', + chainId: 463, + url: 'https://areonscan.com/contracts/0xca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'zkFair Mainnet', + chainId: 42766, + url: 'https://scan.zkfair.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'Mode Mainnet', + chainId: 34443, + url: 'https://explorer.mode.network/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Blast Sepolia', + chainId: 168587773, + url: 'https://testnet.blastscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contract/168587773/code', + }, + { + name: 'Blast', + chainId: 81457, + url: 'https://blastscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Xai', + chainId: 660279, + url: 'https://explorer.xai-chain.net/address/0xcA11bde05977b3631167028862bE2a173976CA11/contracts#address-tabs', + }, + { + name: 'DOS Chain', + chainId: 7979, + url: 'https://doscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'DOS Chain Testnet', + chainId: 3939, + url: 'https://test.doscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Tron', + chainId: 728126428, + url: 'https://tronscan.org/#/contract/TEazPvZwDjDtFeJupyo7QunvnrnUjPH8ED/code', + address: 'TEazPvZwDjDtFeJupyo7QunvnrnUjPH8ED', + }, + { + name: 'zkSync Era', + chainId: 324, + url: 'https://explorer.zksync.io/address/0xF9cda624FBC7e059355ce98a31693d299FACd963#contract', + address: '0xF9cda624FBC7e059355ce98a31693d299FACd963', + }, + { + name: 'zkSync Era Goerli Testnet', + chainId: 280, + url: 'https://goerli.explorer.zksync.io/address/0xF9cda624FBC7e059355ce98a31693d299FACd963#contract', + address: '0xF9cda624FBC7e059355ce98a31693d299FACd963', + }, + { + name: 'zkSync Era Sepolia Testnet', + chainId: 300, + url: 'https://sepolia.explorer.zksync.io/address/0xF9cda624FBC7e059355ce98a31693d299FACd963#contract', + address: '0xF9cda624FBC7e059355ce98a31693d299FACd963', + }, + { + name: 'PlayFi Albireo Testnet', + chainId: 1612127, + url: 'https://albireo-explorer.playfi.ai/address/0xF9cda624FBC7e059355ce98a31693d299FACd963#contract', + address: '0xF9cda624FBC7e059355ce98a31693d299FACd963', + }, + { + name: 'Abstract Testnet', + chainId: 11124, + url: 'https://explorer.testnet.abs.xyz/address/0xF9cda624FBC7e059355ce98a31693d299FACd963#contract', + address: '0xF9cda624FBC7e059355ce98a31693d299FACd963', + }, + { + name: 'Abstract Mainnet', + chainId: 2741, + url: 'https://abscan.org/address/0xF9cda624FBC7e059355ce98a31693d299FACd963#code', + address: '0xF9cda624FBC7e059355ce98a31693d299FACd963', + }, + { + name: 'Fraxtal Mainnet', + chainId: 252, + url: 'https://fraxscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Fraxtal Holesky Testnet', + chainId: 2522, + url: 'https://holesky.fraxscan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'Omax Mainnet', + chainId: 311, + url: 'https://omaxray.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Syndicate Frame Chain', + chainId: 5101, + url: 'https://explorer-frame.syndicate.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Dela Sepolia', + chainId: 9393, + url: 'https://sepolia-delascan.deperp.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'NeoX Testnet', + chainId: 12227330, + url: 'https://xt2scan.ngd.network/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Sanko Mainnet', + chainId: 1996, + url: 'https://explorer.sanko.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Sanko Testnet', + chainId: 1992, + url: 'https://testnet.sankoscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Berachain Mainnet', + chainId: 80094, + url: 'https://berascan.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Berachain Bepolia Testnet', + chainId: 80069, + url: 'https://bepolia.beratrail.io/address/0xca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'Shibarium', + chainId: 109, + url: 'https://www.shibariumscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Immutable zkEVM Mainnet', + chainId: 13371, + url: 'https://explorer.immutable.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Immutable zkEVM Testnet', + chainId: 13473, + url: 'https://explorer.testnet.immutable.com/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'RSS3 VSL Mainnet', + chainId: 12553, + url: 'https://scan.rss3.io/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'RSS3 VSL Sepolia Testnet', + chainId: 2331, + url: 'https://scan.testnet.rss3.io/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Morph Sepolia Testnet', + chainId: 2710, + url: 'https://explorer-testnet.morphl2.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Morph Holesky Testnet', + chainId: 2810, + url: 'https://explorer-holesky.morphl2.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Morph', + chainId: 2818, + url: 'https://explorer.morphl2.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'JIBCHAIN L1', + chainId: 8899, + url: 'https://exp-l1.jibchain.net/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Haqq Mainnet', + chainId: 11235, + url: 'https://explorer.haqq.network/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Zircuit Sepolia Testnet', + chainId: 48899, + url: 'https://explorer.zircuit.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?activeTab=3', + }, + { + name: 're.al', + chainId: 111188, + url: 'https://explorer.re.al/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Merlin Testnet', + chainId: 686868, + url: 'https://testnet-scan.merlinchain.io/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'IOTA EVM', + chainId: 8822, + url: 'https://explorer.evm.iota.org/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Planq', + chainId: 7070, + url: 'https://evm.planq.network/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Cyber Testnet', + chainId: 111557560, + url: 'https://testnet.cyberscan.co/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Unit Zero Mainnet', + chainId: 88811, + url: 'https://explorer.unit0.dev/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Unit Zero Stagenet', + chainId: 88819, + url: 'https://explorer-stagenet.unit0.dev/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Unit Zero Testnet', + chainId: 88817, + url: 'https://explorer-testnet.unit0.dev/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Sei EVM Devnet', + chainId: 713715, + url: 'https://seitrace.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Sei EVM Mainnet', + chainId: 1329, + url: 'https://seitrace.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?chain=pacific-1&tab=contract', + }, + { + name: 'Hekla (Taiko A7 Testnet)', + chainId: 167009, + url: 'https://explorer.hekla.taiko.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Taiko Mainnet', + chainId: 167000, + url: 'https://taikoscan.io/address/0xca11bde05977b3631167028862be2a173976ca11#code', + }, + { + name: 'Cyber Mainnet', + chainId: 7560, + url: 'https://cyberscan.co/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'DreyerX Mainnet', + chainId: 23451, + url: 'https://scan.dreyerx.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Sahara Testnet', + chainId: 313313, + url: 'https://explorer.saharaa.info/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'BOX Chain', + chainId: 42299, + url: 'https://explorerl2new-boxchain-t4zoh9y5dr.t.conduit.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'OX Chain', + chainId: 6699, + url: 'https://explorer-ox-chain-2s86s7wp21.t.conduit.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Conflux Espace', + chainId: 1030, + url: 'https://evm.confluxscan.net/address/0xca11bde05977b3631167028862be2a173976ca11?tab=contract-viewer', + }, + { + name: 'BEVM Testnet', + chainId: 11503, + url: 'https://scan-testnet.bevm.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Aura Mainnet', + chainId: 6322, + url: 'https://aurascan.io/evm-contracts/0xca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'Superposition Testnet', + chainId: 98985, + url: 'https://testnet-explorer.superposition.so/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'X Layer Mainnet', + chainId: 196, + url: 'https://www.okx.com/web3/explorer/xlayer/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Nahmii 3 Mainnet', + chainId: 4061, + url: 'https://explorer.nahmii.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Nahmii 3 Testnet', + chainId: 4062, + url: 'https://explorer.testnet.nahmii.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Plume Testnet', + chainId: 98867, + url: 'https://testnet-explorer.plumenetwork.xyz/address/0xca11bde05977b3631167028862be2a173976ca11?tab=contract', + }, + { + name: 'Plume Mainnet', + chainId: 98866, + url: 'https://phoenix-explorer.plumenetwork.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Algen L1', + chainId: 8911, + url: 'https://scan.algen.network/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Bitlayer Mainnet', + chainId: 200901, + url: 'https://www.btrscan.com/address/0xca11bde05977b3631167028862be2a173976ca11?tab=Contract', + }, + { + name: 'Lisk Mainnet', + chainId: 1135, + url: 'https://blockscout.lisk.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Gravity Alpha Mainnet', + chainId: 1625, + url: 'https://explorer.gravity.xyz/address/0xca11bde05977b3631167028862be2a173976ca11?tab=contract', + }, + { + name: 'Yominet', + chainId: 5264468217, + url: 'https://yominet.explorer.caldera.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Bob', + chainId: 60808, + url: 'https://explorer.gobob.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Superseed', + chainId: 53302, + url: 'https://sepolia-explorer.superseed.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Rupaya', + chainId: 499, + url: 'https://scan.rupaya.io/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Fluence Testnet', + chainId: 52164803, + url: 'https://blockscout.testnet.fluence.dev/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Fluence Stage', + chainId: 123420000220, + url: 'https://blockscout.stage.fluence.dev/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Fluence', + chainId: 9999999, + url: 'https://blockscout.mainnet.fluence.dev/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Camp Testnet V2', + chainId: 325000, + url: 'https://camp-network-testnet.blockscout.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Ontology Testnet', + chainId: 5851, + url: 'https://explorer.ont.io/testnet/contract/other/0xca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'Ontology Mainnet', + chainId: 58, + url: 'https://explorer.ont.io/contract/all/0xca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'Viction Testnet', + chainId: 89, + url: 'https://testnet.vicscan.xyz/address/0xca11bde05977b3631167028862be2a173976ca11#code', + }, + { + name: 'Viction Mainnet', + chainId: 88, + url: 'https://www.vicscan.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'World Chain', + chainId: 480, + url: 'https://worldchain-mainnet.explorer.alchemy.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Flow Mainnet', + chainId: 747, + url: 'https://evm.flowscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Flow Testnet', + chainId: 545, + url: 'https://evm-testnet.flowscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Conflux Core Space Mainnet', + chainId: 1029, + url: 'https://confluxscan.io/address/cfx:acevn2d3dr6vh4jca28c6cmvkktsg7r8n25vp9hnmw?tab=contract-viewer', + address: 'cfx:acevn2d3dr6vh4jca28c6cmvkktsg7r8n25vp9hnmw', + }, + { + name: 'Superposition', + chainId: 55244, + url: 'https://explorer.superposition.so/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Starchain Testnet', + chainId: 1570, + url: 'https://devnet.starchainscan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Starchain Mainnet', + chainId: 1578, + url: 'https://starchainscan.io/address/0xca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'ApeChain Mainnet', + chainId: 33139, + url: 'https://apescan.io/address/0xcA11bde05977b3631167028862bE2a173976CA11#code', + }, + { + name: 'WEMIX 3.0 Mainnet', + chainId: 1111, + url: 'https://wemixscan.com/address/0xca11bde05977b3631167028862be2a173976ca11', + }, + { + name: 'Aleph Zero EVM Mainnet', + chainId: 41455, + url: 'https://evm-explorer.alephzero.org/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'EDUChain Testnet', + chainId: 656476, + url: 'https://edu-chain-testnet.blockscout.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Form Testnet', + chainId: 132902, + url: 'https://explorer.form.network/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'peaq', + chainId: 3338, + url: 'https://peaq.subscan.io/account/0xca11bde05977b3631167028862be2a173976ca11?tab=contract&evm_contract_tab=code', + }, + { + name: 'HyperEVM', + chainId: 999, + url: 'https://hyperliquid.cloud.blockscout.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Monad Testnet', + chainId: 10143, + url: 'https://testnet.monadexplorer.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=Contract', + }, + { + name: 'Powerloom Mainnet', + chainId: 7869, + url: 'https://explorer-v2.powerloom.network/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Hoodi', + chainId: 560048, + url: 'https://hoodi.etherscan.io/address/0xca11bde05977b3631167028862be2a173976ca11#code', + }, + { + name: 'MegaETH Testnet', + chainId: 6342, + url: 'https://www.megaexplorer.xyz/address/0xcA11bde05977b3631167028862bE2a173976CA11', + }, + { + name: 'Ink Sepolia', + chainId: 763373, + url: 'https://explorer-sepolia.inkonchain.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Ink', + chainId: 57073, + url: 'https://explorer.inkonchain.com/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Bittensor', + chainId: 964, + url: 'https://evm.taostats.io/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, + { + name: 'Whitechain', + chainId: 1875, + url: 'https://explorer.whitechain.io/address/0xcA11bde05977b3631167028862bE2a173976CA11/contract', + }, + { + name: 'Tangle Testnet', + chainId: 3799, + url: 'https://testnet-explorer.tangle.tools/address/0xca11bde05977b3631167028862be2a173976ca11?tab=contract', + }, + { + name: 'Tangle Mainnet', + chainId: 8545, + url: 'https://explorer.tangle.tools/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract', + }, +] diff --git a/packages/utils/src/utils/multicall/index.ts b/packages/utils/src/utils/multicall/index.ts new file mode 100644 index 0000000000..b737eac850 --- /dev/null +++ b/packages/utils/src/utils/multicall/index.ts @@ -0,0 +1,73 @@ +import { Contract, AbstractProvider } from 'ethers' +import { CANONICAL_MULTICALL_ADDRESSS, MULTICALL_DEPLOYMENTS } from './deployments' + +// Multicall contract ABI +export const MULTICALL_ABI = [ + 'function aggregate3(tuple(address target, bool allowFailure, bytes callData)[] calls) payable returns (tuple(bool success, bytes returnData)[] returnData)', +] + +/** + * Get the multicall contract address for a given chain ID + * @param chainId The chain ID to get the multicall address for + * @returns The multicall contract address for the given chain ID + */ +export const getMultiCallAddress = (chainId: string): string | null => { + const deployment = MULTICALL_DEPLOYMENTS.find((deployment) => deployment.chainId.toString() === chainId) + if (!deployment) { + return null + } + return deployment.address ?? CANONICAL_MULTICALL_ADDRESSS +} + +export type Aggregate3Response = { success: boolean; returnData: string } + +const fallbackMulticall = async (provider: AbstractProvider, calls: { to: string; data: string }[]) => { + const results: Aggregate3Response[] = [] + for (const call of calls) { + try { + const result = await provider.call(call) + results.push({ success: true, returnData: result }) + } catch (error) { + results.push({ success: false, returnData: '0x' }) + } + } + return results +} + +/** + * Execute multiple calls in a single RPC request using the multicall contract + * @param provider The ethers provider to use + * @param calls Array of calls to execute, each containing target address and call data + * @param chainId The chain ID to execute the calls on + * @returns Array of return data from each call + */ +export const multicall = async ( + provider: AbstractProvider, + calls: { to: string; data: string }[], +): Promise<{ success: boolean; returnData: string }[]> => { + if (calls.length === 0) { + return [] + } + const chainId = (await provider.getNetwork()).chainId.toString() + + const multicallAddress = getMultiCallAddress(chainId) + if (!multicallAddress || calls.length === 1) { + // Fallback to consecutive calls if multicall is not supported or if there is only one call + return fallbackMulticall(provider, calls) + } + + const multicallContract = new Contract(multicallAddress, MULTICALL_ABI, provider) + + try { + const calls3 = calls.map((call) => ({ + target: call.to, + allowFailure: true, + callData: call.data, + })) + + const resolverResults: Aggregate3Response[] = await multicallContract.aggregate3.staticCall(calls3) + return resolverResults + } catch (error) { + throw new Error(`Multicall failed: ${error instanceof Error ? error.message : String(error)}`) + } +} diff --git a/packages/utils/src/utils/multicall/multicall.test.ts b/packages/utils/src/utils/multicall/multicall.test.ts new file mode 100644 index 0000000000..585a6fca67 --- /dev/null +++ b/packages/utils/src/utils/multicall/multicall.test.ts @@ -0,0 +1,119 @@ +import { ethers } from 'ethers' +import { faker } from '@faker-js/faker' +import { multicall, getMultiCallAddress, MULTICALL_ABI } from '.' +import { createMockWeb3Provider } from '@safe-global/utils/tests/web3Provider' + +const MULTICALL_INTERFACE = new ethers.Interface(MULTICALL_ABI) +describe('multicall', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return empty array for empty calls', async () => { + const mockProvider = createMockWeb3Provider([], undefined, '1') + const result = await multicall(mockProvider, []) + expect(result).toEqual([]) + }) + + it('should use fallback for chains without multicall support', async () => { + const target1 = faker.finance.ethereumAddress() + const target2 = faker.finance.ethereumAddress() + const calls = [ + { to: target1, data: faker.string.hexadecimal({ length: 64 }) }, + { to: target2, data: faker.string.hexadecimal({ length: 64 }) }, + ] + const mockProvider = createMockWeb3Provider( + [ + { + signature: calls[0].data.slice(0, 10), + returnType: 'uint256', + returnValue: 100, + }, + { + signature: calls[1].data.slice(0, 10), + returnType: 'uint256', + returnValue: 200, + }, + ], + undefined, + '232', + ) + + const result = await multicall(mockProvider, calls) + + expect(result).toEqual([ + { success: true, returnData: ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [100]) }, + { success: true, returnData: ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [200]) }, + ]) + expect(mockProvider.call).toHaveBeenCalledTimes(2) + expect(mockProvider.call).toHaveBeenCalledWith(calls[0]) + expect(mockProvider.call).toHaveBeenCalledWith(calls[1]) + }) + + it('should use fallback for single call', async () => { + const target = faker.finance.ethereumAddress() + const calls = [{ to: target, data: faker.string.hexadecimal({ length: 64 }) }] + const mockProvider = createMockWeb3Provider( + [ + { + signature: calls[0].data.slice(0, 10), + returnType: 'uint256', + returnValue: '100', + }, + ], + undefined, + '1', + ) + const result = await multicall(mockProvider, calls) + + expect(result).toEqual([ + { success: true, returnData: ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [100]) }, + ]) + expect(mockProvider.call).toHaveBeenCalledTimes(1) + expect(mockProvider.call).toHaveBeenCalledWith(calls[0]) + }) + + it('should use multicall contract for multiple calls on supported chain', async () => { + const target1 = faker.finance.ethereumAddress() + const target2 = faker.finance.ethereumAddress() + const calls = [ + { to: target1, data: faker.string.hexadecimal({ length: 64 }) }, + { to: target2, data: faker.string.hexadecimal({ length: 64 }) }, + ] + + const expectedResults = [ + { success: true, returnData: ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [100]) }, + { success: true, returnData: ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [200]) }, + ] + + const mockProvider = createMockWeb3Provider( + [ + { + signature: MULTICALL_INTERFACE.getFunction('aggregate3')?.selector!, + returnType: 'raw', + returnValue: MULTICALL_INTERFACE.encodeFunctionResult('aggregate3', [expectedResults]), + }, + ], + undefined, + '1', + ) + + const result = await multicall(mockProvider, calls) + expect(result.every((r) => r.success)).toBe(true) + expect(result[0].returnData).toEqual(expectedResults[0].returnData) + expect(result[1].returnData).toEqual(expectedResults[1].returnData) + + expect(mockProvider.call).toHaveBeenCalledTimes(1) + }) +}) + +describe('getMultiCallAddress', () => { + it('should return null for unsupported chains', () => { + expect(getMultiCallAddress('232')).toBeNull() // Lens + }) + + it('should return canonical address for supported chains', () => { + expect(getMultiCallAddress('1')).toBe('0xca11bde05977b3631167028862be2a173976ca11') // Ethereum Mainnet + expect(getMultiCallAddress('137')).toBe('0xca11bde05977b3631167028862be2a173976ca11') // Polygon + }) +}) diff --git a/apps/web/src/utils/safe-messages.ts b/packages/utils/src/utils/safe-messages.ts similarity index 72% rename from apps/web/src/utils/safe-messages.ts rename to packages/utils/src/utils/safe-messages.ts index 21b35c1301..7230432b31 100644 --- a/apps/web/src/utils/safe-messages.ts +++ b/packages/utils/src/utils/safe-messages.ts @@ -1,18 +1,15 @@ +import type { MessageItem, TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import { hashMessage, type TypedDataDomain, type JsonRpcSigner } from 'ethers' import { gte } from 'semver' import { adjustVInSignature } from '@safe-global/protocol-kit/dist/src/utils/signatures' -import { hashTypedData } from '@/utils/web3' -import { isValidAddress } from './validation' -import { - type SafeInfo, - type SafeMessage, - type EIP712TypedData, - type ChainInfo, -} from '@safe-global/safe-gateway-typescript-sdk' -import { FEATURES } from '@/utils/chains' - -import { hasFeature } from './chains' +import { hashTypedData } from '@safe-global/utils/utils/web3' +import { isValidAddress } from '@safe-global/utils/utils/validation' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' +import { FEATURES } from '@safe-global/utils/utils/chains' + +import { hasFeature } from '@safe-global/utils/utils/chains' import { SigningMethod } from '@safe-global/protocol-kit' /* @@ -31,13 +28,13 @@ const isHash = (payload: string) => /^0x[a-f0-9]+$/i.test(payload) * Typeguard for EIP712TypedData * */ -export const isEIP712TypedData = (obj: any): obj is EIP712TypedData => { +export const isEIP712TypedData = (obj: any): obj is TypedData => { return typeof obj === 'object' && obj != null && 'domain' in obj && 'types' in obj && 'message' in obj } -export const isBlindSigningPayload = (obj: EIP712TypedData | string): boolean => !isEIP712TypedData(obj) && isHash(obj) +export const isBlindSigningPayload = (obj: TypedData | string): boolean => !isEIP712TypedData(obj) && isHash(obj) -export const generateSafeMessageMessage = (message: SafeMessage['message']): string => { +export const generateSafeMessageMessage = (message: MessageItem['message']): string => { return typeof message === 'string' ? hashMessage(message) : hashTypedData(message) } @@ -49,9 +46,9 @@ export const generateSafeMessageMessage = (message: SafeMessage['message']): str * @returns `SafeMessage` types for signing */ export const generateSafeMessageTypedData = ( - { version, chainId, address }: SafeInfo, - message: SafeMessage['message'], -): EIP712TypedData => { + { version, chainId, address }: SafeState, + message: MessageItem['message'], +): TypedData => { if (!version) { throw Error('Cannot create SafeMessage without version information') } @@ -60,7 +57,7 @@ export const generateSafeMessageTypedData = ( return { domain: isHandledByFallbackHandler ? { - chainId, + chainId: Number(chainId), verifyingContract: address.value, } : { verifyingContract: address.value }, @@ -70,16 +67,17 @@ export const generateSafeMessageTypedData = ( message: { message: generateSafeMessageMessage(message), }, + primaryType: 'SafeMessage', } } -export const generateSafeMessageHash = (safe: SafeInfo, message: SafeMessage['message']): string => { +export const generateSafeMessageHash = (safe: SafeState, message: MessageItem['message']): string => { const typedData = generateSafeMessageTypedData(safe, message) return hashTypedData(typedData) } export const isOffchainEIP1271Supported = ( - { version, fallbackHandler }: SafeInfo, + { version, fallbackHandler }: SafeState, chain: ChainInfo | undefined, sdkVersion?: string, ): boolean => { @@ -101,7 +99,7 @@ export const isOffchainEIP1271Supported = ( const isHandledByFallbackHandler = gte(version, EIP1271_FALLBACK_HANDLER_SUPPORTED_SAFE_VERSION) if (isHandledByFallbackHandler) { // We only check if any fallback Handler is set as we expect / assume that users who overwrite the fallback handler by a custom one know what they are doing - return fallbackHandler !== null && isValidAddress(fallbackHandler.value) + return fallbackHandler !== null && typeof fallbackHandler !== 'undefined' && isValidAddress(fallbackHandler.value) } // check if Safe version supports EIP-1271 @@ -110,8 +108,8 @@ export const isOffchainEIP1271Supported = ( export const tryOffChainMsgSigning = async ( signer: JsonRpcSigner, - safe: SafeInfo, - message: SafeMessage['message'], + safe: SafeState, + message: MessageItem['message'], ): Promise => { const typedData = generateSafeMessageTypedData(safe, message) const signature = await signer.signTypedData(typedData.domain as TypedDataDomain, typedData.types, typedData.message) diff --git a/packages/utils/src/utils/safe-setup-comparison.ts b/packages/utils/src/utils/safe-setup-comparison.ts new file mode 100644 index 0000000000..6215164c39 --- /dev/null +++ b/packages/utils/src/utils/safe-setup-comparison.ts @@ -0,0 +1,50 @@ +import { sameAddress } from './addresses' + +/** + * Compares two arrays of owner addresses to check if they match + * @param owners1 - First array of owner addresses + * @param owners2 - Second array of owner addresses + * @returns true if both arrays contain the same addresses (order doesn't matter) + */ +export const areOwnersMatching = (owners1: string[], owners2: string[]): boolean => + owners1.length === owners2.length && owners1.every((owner) => owners2.some((owner2) => sameAddress(owner, owner2))) + +/** + * Interface for safe configuration data needed for comparison + */ +export interface SafeSetupData { + owners: Array<{ value: string }> | Array + threshold: number +} + +/** + * Normalizes owner data to string array + * @param owners - Owner data that can be either string array or object array with value property + * @returns Array of owner address strings + */ +const normalizeOwners = (owners: Array<{ value: string }> | Array): string[] => { + return owners.map((owner) => (typeof owner === 'string' ? owner : owner.value)) +} + +/** + * Compares two safe configurations to determine if they have the same setup + * @param safe1 - First safe configuration + * @param safe2 - Second safe configuration + * @returns true if both safes have matching owners and threshold + */ +export const haveSameSetup = ( + safe1: SafeSetupData | null | undefined, + safe2: SafeSetupData | null | undefined, +): boolean => { + if (!safe1 || !safe2) { + return false + } + + const owners1 = normalizeOwners(safe1.owners) + const owners2 = normalizeOwners(safe2.owners) + + const hasMatchingOwners = areOwnersMatching(owners1, owners2) + const hasMatchingThreshold = safe1.threshold === safe2.threshold + + return hasMatchingOwners && hasMatchingThreshold +} diff --git a/packages/utils/src/utils/tokens.ts b/packages/utils/src/utils/tokens.ts new file mode 100644 index 0000000000..e82c9aee29 --- /dev/null +++ b/packages/utils/src/utils/tokens.ts @@ -0,0 +1,4 @@ +export const UNLIMITED_APPROVAL_AMOUNT = 2n ** 256n - 1n +export const UNLIMITED_PERMIT2_AMOUNT = 2n ** 160n - 1n +// As per https://eips.ethereum.org/EIPS/eip-721#specification +export const ERC721_IDENTIFIER = '0x80ac58cd' diff --git a/apps/web/src/utils/validation.ts b/packages/utils/src/utils/validation.ts similarity index 88% rename from apps/web/src/utils/validation.ts rename to packages/utils/src/utils/validation.ts index f437838b2a..b7aa6a0e4b 100644 --- a/apps/web/src/utils/validation.ts +++ b/packages/utils/src/utils/validation.ts @@ -1,5 +1,5 @@ -import { parsePrefixedAddress, sameAddress, isChecksummedAddress } from './addresses' -import { safeFormatUnits, safeParseUnits } from './formatters' +import { parsePrefixedAddress, sameAddress, isChecksummedAddress } from '@safe-global/utils/utils/addresses' +import { safeFormatUnits, safeParseUnits } from '@safe-global/utils/utils/formatters' export const validateAddress = (address: string) => { const ADDRESS_RE = /^0x[0-9a-f]{40}$/i @@ -63,8 +63,8 @@ export const validateAmount = (amount?: string, includingZero: boolean = false) } } -export const validateLimitedAmount = (amount: string, decimals?: number, max?: string) => { - if (typeof decimals === 'undefined' || !max) return +export const validateLimitedAmount = (amount: string, decimals?: number | null, max?: string, errorMsg?: string) => { + if (decimals == null || !max) return const numberError = validateAmount(amount) if (numberError) { @@ -74,12 +74,12 @@ export const validateLimitedAmount = (amount: string, decimals?: number, max?: s const value = safeParseUnits(amount, decimals) if (value !== undefined && value > BigInt(max)) { - return `Maximum value is ${safeFormatUnits(max, decimals)}` + return errorMsg || `Maximum value is ${safeFormatUnits(max, decimals)}` } } -export const validateDecimalLength = (value: string, maxLen?: number, minLen = 1) => { - if (typeof maxLen === 'undefined' || !value.includes('.')) { +export const validateDecimalLength = (value: string, maxLen?: number | null, minLen = 1) => { + if (maxLen == null || !value.includes('.')) { return } diff --git a/apps/web/src/utils/web3.ts b/packages/utils/src/utils/web3.ts similarity index 83% rename from apps/web/src/utils/web3.ts rename to packages/utils/src/utils/web3.ts index d9def7f188..8862f82308 100644 --- a/apps/web/src/utils/web3.ts +++ b/packages/utils/src/utils/web3.ts @@ -1,19 +1,17 @@ +import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages' import type { JsonRpcSigner } from 'ethers' import { TypedDataEncoder } from 'ethers' -import type { EIP712TypedData } from '@safe-global/safe-gateway-typescript-sdk' import type { TypedDataDomain } from 'ethers' import { adjustVInSignature } from '@safe-global/protocol-kit/dist/src/utils/signatures' import { SigningMethod } from '@safe-global/protocol-kit' -export type EIP712Normalized = EIP712TypedData & { primaryType: string } - -export const hashTypedData = (typedData: EIP712TypedData): string => { +export const hashTypedData = (typedData: TypedData): string => { // `ethers` doesn't require `EIP712Domain` and otherwise throws const { EIP712Domain: _, ...types } = typedData.types return TypedDataEncoder.hash(typedData.domain as TypedDataDomain, types, typedData.message) } -export const normalizeTypedData = (typedData: EIP712TypedData): EIP712Normalized => { +export const normalizeTypedData = (typedData: TypedData): TypedData => { const { EIP712Domain: _, ...types } = typedData.types const payload = TypedDataEncoder.getPayload(typedData.domain as TypedDataDomain, types, typedData.message) @@ -30,14 +28,14 @@ export const normalizeTypedData = (typedData: EIP712TypedData): EIP712Normalized } // Fall back to `eth_signTypedData` for Ledger that doesn't support `eth_signTypedData_v4` -const signTypedDataFallback = async (signer: JsonRpcSigner, typedData: EIP712TypedData): Promise => { +const signTypedDataFallback = async (signer: JsonRpcSigner, typedData: TypedData): Promise => { return await signer.provider.send('eth_signTypedData', [ signer.address.toLowerCase(), TypedDataEncoder.getPayload(typedData.domain as TypedDataDomain, typedData.types, typedData.message), ]) } -export const signTypedData = async (signer: JsonRpcSigner, typedData: EIP712TypedData): Promise => { +export const signTypedData = async (signer: JsonRpcSigner, typedData: TypedData): Promise => { const UNSUPPORTED_OPERATION = 'UNSUPPORTED_OPERATION' let signature = '' try { diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 0000000000..1cfac6677a --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../config/tsconfig/confs/base.json", + "compilerOptions": { + "baseUrl": "./", + // "rootDir": "./" + "paths": { + "@safe-global/store/*": ["../../packages/store/src/*"], + "@safe-global/utils/*": ["../../packages/utils/src/*"] + } + }, + "include": ["**/*.ts"] +} diff --git a/yarn.config.cjs b/yarn.config.cjs new file mode 100644 index 0000000000..6eb86a14bd --- /dev/null +++ b/yarn.config.cjs @@ -0,0 +1,89 @@ +/** @type {import('@yarnpkg/types')} */ +const { defineConfig } = require('@yarnpkg/types') + +const DEPS_TO_CHECK = [ + 'typescript', + 'react', + 'redux', + 'react-redux', + '@reduxjs/toolkit', + 'eslint', + 'prettier', + 'jest', + '@types/jest', + '@safe-global/protocol-kit', + '@safe-global/safe-apps-sdk', + '@safe-global/safe-client-gateway-sdk', + '@safe-global/safe-deployments', + '@safe-global/safe-gateway-typescript-sdk', + '@safe-global/safe-modules-deployments', + '@cowprotocol/app-data', +] + +/** + * Detect and report different versions of specified dependencies across workspaces + * + * @param {Context} context + * @param {string[]} depsToCheck - Array of dependency names to check + */ +function detectInconsistentVersions({ Yarn }, depsToCheck = DEPS_TO_CHECK) { + const inconsistentDeps = new Map() + + for (const depName of depsToCheck) { + const depVersions = new Map() + + // Collect all dependencies of this type across workspaces + for (const dependency of Yarn.dependencies({ ident: depName })) { + if (dependency.type === `peerDependencies`) continue + + // Try different ways to get the workspace name + let workspaceName = 'unknown' + + if (dependency.workspace) { + workspaceName = + dependency.workspace.manifest?.name || + dependency.workspace.locator?.name || + dependency.workspace.cwd?.split('/').pop() || + dependency.workspace.anchoredDescriptor?.name || + 'root' + } else { + workspaceName = 'root' + } + + const version = dependency.range + + if (!depVersions.has(version)) { + depVersions.set(version, []) + } + depVersions.get(version).push(workspaceName) + } + + // Only report if there are inconsistencies + if (depVersions.size > 1) { + inconsistentDeps.set(depName, depVersions) + } else if (depVersions.size === 1) { + const [version, workspaces] = depVersions.entries().next().value + console.log(`✅ ${depName} version ${version} is consistent across ${workspaces.length} workspace(s)`) + } + } + + // Report inconsistencies + if (inconsistentDeps.size > 0) { + console.log('\n🔍 Version inconsistencies detected:') + for (const [depName, versions] of inconsistentDeps.entries()) { + console.log(`\n📦 ${depName}:`) + for (const [version, workspaces] of versions.entries()) { + console.log(` - ${version}: ${workspaces.join(', ')}`) + } + } + console.log('\n⚠️ Consider standardizing these dependency versions across all workspaces.\n') + } else { + console.log('\n✅ All specified dependencies have consistent versions across workspaces.\n') + } +} + +module.exports = defineConfig({ + constraints: async (ctx) => { + detectInconsistentVersions(ctx) + }, +}) diff --git a/yarn.lock b/yarn.lock index 5995aca195..7fa6bf4f2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17,13 +17,20 @@ __metadata: languageName: node linkType: hard -"@adobe/css-tools@npm:^4.0.1, @adobe/css-tools@npm:^4.4.0": +"@adobe/css-tools@npm:^4.4.0": version: 4.4.1 resolution: "@adobe/css-tools@npm:4.4.1" checksum: 10/a0ea05517308593a52728936a833b1075c4cf1a6b68baaea817063f34e75faa1dba1209dd285003c4f8072804227dfa563e7e903f72ae2d39cb520aaee3f4bcc languageName: node linkType: hard +"@adobe/css-tools@npm:~4.3.3": + version: 4.3.3 + resolution: "@adobe/css-tools@npm:4.3.3" + checksum: 10/0e77057efb4e18182560855503066b75edca98671be327d3f8a7ae89ec3da6821e693114b55225909fca00d7e7ed8422f3d79d71fe95dd4d5df1f2026a9fda02 + languageName: node + linkType: hard + "@adraffy/ens-normalize@npm:1.10.0": version: 1.10.0 resolution: "@adraffy/ens-normalize@npm:1.10.0" @@ -117,6 +124,33 @@ __metadata: languageName: node linkType: hard +"@babel/cli@npm:^7.23.4": + version: 7.27.2 + resolution: "@babel/cli@npm:7.27.2" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.25" + "@nicolo-ribaudo/chokidar-2": "npm:2.1.8-no-fsevents.3" + chokidar: "npm:^3.6.0" + commander: "npm:^6.2.0" + convert-source-map: "npm:^2.0.0" + fs-readdir-recursive: "npm:^1.1.0" + glob: "npm:^7.2.0" + make-dir: "npm:^2.1.0" + slash: "npm:^2.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + dependenciesMeta: + "@nicolo-ribaudo/chokidar-2": + optional: true + chokidar: + optional: true + bin: + babel: ./bin/babel.js + babel-external-helpers: ./bin/babel-external-helpers.js + checksum: 10/00fcb58170173d2e501cc9f43c8a1fd2266085cdbb005287164068e6fe566be1a11e885efe3abdeff6f928cb1c84d5c31aa3c50e7b6b3647bc47ce3a231e9d1b + languageName: node + linkType: hard + "@babel/code-frame@npm:7.10.4, @babel/code-frame@npm:~7.10.4": version: 7.10.4 resolution: "@babel/code-frame@npm:7.10.4" @@ -137,6 +171,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/code-frame@npm:7.27.1" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.27.1" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10/721b8a6e360a1fa0f1c9fe7351ae6c874828e119183688b533c477aa378f1010f37cc9afbfc4722c686d1f5cdd00da02eab4ba7278a0c504fa0d7a321dcd4fdf + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.22.6, @babel/compat-data@npm:^7.25.9, @babel/compat-data@npm:^7.26.0": version: 7.26.3 resolution: "@babel/compat-data@npm:7.26.3" @@ -144,6 +189,13 @@ __metadata: languageName: node linkType: hard +"@babel/compat-data@npm:^7.27.2": + version: 7.27.3 + resolution: "@babel/compat-data@npm:7.27.3" + checksum: 10/3bc4f53f2c076468c1df405e3fb3aac60a8118f46ff4ea8d093e00dcf919e915adc68d9c0a46fffe9cdc5b0d41fefe3b44370d43da09bbd7c9e5474d2cd4c656 + languageName: node + linkType: hard + "@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.13.16, @babel/core@npm:^7.18.9, @babel/core@npm:^7.20.0, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.7, @babel/core@npm:^7.23.9, @babel/core@npm:^7.24.4, @babel/core@npm:^7.25.2": version: 7.26.0 resolution: "@babel/core@npm:7.26.0" @@ -180,6 +232,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.27.3": + version: 7.27.3 + resolution: "@babel/generator@npm:7.27.3" + dependencies: + "@babel/parser": "npm:^7.27.3" + "@babel/types": "npm:^7.27.3" + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.25" + jsesc: "npm:^3.0.2" + checksum: 10/3b8477ae0c305639f86aeb553115535b103626008945462d32171fa4ebd77f2a0345600dc5baee7ced98d54cc7da9c806808a04b555c75136f42e0e9d7794bdf + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-annotate-as-pure@npm:7.25.9" @@ -189,6 +254,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-annotate-as-pure@npm:^7.27.1": + version: 7.27.3 + resolution: "@babel/helper-annotate-as-pure@npm:7.27.3" + dependencies: + "@babel/types": "npm:^7.27.3" + checksum: 10/63863a5c936ef82b546ca289c9d1b18fabfc24da5c4ee382830b124e2e79b68d626207febc8d4bffc720f50b2ee65691d7d12cc0308679dee2cd6bdc926b7190 + languageName: node + linkType: hard + "@babel/helper-compilation-targets@npm:^7.22.6, @babel/helper-compilation-targets@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-compilation-targets@npm:7.25.9" @@ -202,6 +276,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-compilation-targets@npm:^7.27.1, @babel/helper-compilation-targets@npm:^7.27.2": + version: 7.27.2 + resolution: "@babel/helper-compilation-targets@npm:7.27.2" + dependencies: + "@babel/compat-data": "npm:^7.27.2" + "@babel/helper-validator-option": "npm:^7.27.1" + browserslist: "npm:^4.24.0" + lru-cache: "npm:^5.1.1" + semver: "npm:^6.3.1" + checksum: 10/bd53c30a7477049db04b655d11f4c3500aea3bcbc2497cf02161de2ecf994fec7c098aabbcebe210ffabc2ecbdb1e3ffad23fb4d3f18723b814f423ea1749fe8 + languageName: node + linkType: hard + "@babel/helper-create-class-features-plugin@npm:^7.18.6, @babel/helper-create-class-features-plugin@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-create-class-features-plugin@npm:7.25.9" @@ -219,6 +306,23 @@ __metadata: languageName: node linkType: hard +"@babel/helper-create-class-features-plugin@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-create-class-features-plugin@npm:7.27.1" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.27.1" + "@babel/helper-member-expression-to-functions": "npm:^7.27.1" + "@babel/helper-optimise-call-expression": "npm:^7.27.1" + "@babel/helper-replace-supers": "npm:^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" + "@babel/traverse": "npm:^7.27.1" + semver: "npm:^6.3.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/701579b49046cd42f6a6b1e693e6827df8623185adf0911c4d68a219a082d8fd4501672880d92b6b96263d1c92a3beb891b3464a662a55e69e7539d8db9277da + languageName: node + linkType: hard + "@babel/helper-create-regexp-features-plugin@npm:^7.18.6, @babel/helper-create-regexp-features-plugin@npm:^7.25.9": version: 7.26.3 resolution: "@babel/helper-create-regexp-features-plugin@npm:7.26.3" @@ -232,6 +336,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-create-regexp-features-plugin@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-create-regexp-features-plugin@npm:7.27.1" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.27.1" + regexpu-core: "npm:^6.2.0" + semver: "npm:^6.3.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/dea272628cd8874f127ab7b2ee468620aabc1383d38bb40c49a9c7667db2258cdfe6620a1d1412f5f0706583f6301b4b7ad3d5932f24df7fe72e66bf9bc0be45 + languageName: node + linkType: hard + "@babel/helper-define-polyfill-provider@npm:^0.6.2, @babel/helper-define-polyfill-provider@npm:^0.6.3": version: 0.6.3 resolution: "@babel/helper-define-polyfill-provider@npm:0.6.3" @@ -257,6 +374,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-member-expression-to-functions@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-member-expression-to-functions@npm:7.27.1" + dependencies: + "@babel/traverse": "npm:^7.27.1" + "@babel/types": "npm:^7.27.1" + checksum: 10/533a5a2cf1c9a8770d241b86d5f124c88e953c831a359faf1ac7ba1e632749c1748281b83295d227fe6035b202d81f3d3a1ea13891f150c6538e040668d6126a + languageName: node + linkType: hard + "@babel/helper-module-imports@npm:^7.10.4, @babel/helper-module-imports@npm:^7.16.7, @babel/helper-module-imports@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-module-imports@npm:7.25.9" @@ -267,6 +394,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-imports@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-module-imports@npm:7.27.1" + dependencies: + "@babel/traverse": "npm:^7.27.1" + "@babel/types": "npm:^7.27.1" + checksum: 10/58e792ea5d4ae71676e0d03d9fef33e886a09602addc3bd01388a98d87df9fcfd192968feb40ac4aedb7e287ec3d0c17b33e3ecefe002592041a91d8a1998a8d + languageName: node + linkType: hard + "@babel/helper-module-transforms@npm:^7.25.9, @babel/helper-module-transforms@npm:^7.26.0": version: 7.26.0 resolution: "@babel/helper-module-transforms@npm:7.26.0" @@ -280,6 +417,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-transforms@npm:^7.27.1": + version: 7.27.3 + resolution: "@babel/helper-module-transforms@npm:7.27.3" + dependencies: + "@babel/helper-module-imports": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.27.1" + "@babel/traverse": "npm:^7.27.3" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/47abc90ceb181b4bdea9bf1717adf536d1b5e5acb6f6d8a7a4524080318b5ca8a99e6d58677268c596bad71077d1d98834d2c3815f2443e6d3f287962300f15d + languageName: node + linkType: hard + "@babel/helper-optimise-call-expression@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-optimise-call-expression@npm:7.25.9" @@ -289,6 +439,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-optimise-call-expression@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-optimise-call-expression@npm:7.27.1" + dependencies: + "@babel/types": "npm:^7.27.1" + checksum: 10/0fb7ee824a384529d6b74f8a58279f9b56bfe3cce332168067dddeab2552d8eeb56dc8eaf86c04a3a09166a316cb92dfc79c4c623cd034ad4c563952c98b464f + languageName: node + linkType: hard + "@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.20.2, @babel/helper-plugin-utils@npm:^7.22.5, @babel/helper-plugin-utils@npm:^7.24.8, @babel/helper-plugin-utils@npm:^7.25.9, @babel/helper-plugin-utils@npm:^7.8.0": version: 7.25.9 resolution: "@babel/helper-plugin-utils@npm:7.25.9" @@ -296,6 +455,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-plugin-utils@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-plugin-utils@npm:7.27.1" + checksum: 10/96136c2428888e620e2ec493c25888f9ceb4a21099dcf3dd4508ea64b58cdedbd5a9fb6c7b352546de84d6c24edafe482318646932a22c449ebd16d16c22d864 + languageName: node + linkType: hard + "@babel/helper-remap-async-to-generator@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-remap-async-to-generator@npm:7.25.9" @@ -309,6 +475,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-remap-async-to-generator@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-remap-async-to-generator@npm:7.27.1" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.27.1" + "@babel/helper-wrap-function": "npm:^7.27.1" + "@babel/traverse": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/0747397ba013f87dbf575454a76c18210d61c7c9af0f697546b4bcac670b54ddc156330234407b397f0c948738c304c228e0223039bc45eab4fbf46966a5e8cc + languageName: node + linkType: hard + "@babel/helper-replace-supers@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-replace-supers@npm:7.25.9" @@ -322,6 +501,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-replace-supers@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-replace-supers@npm:7.27.1" + dependencies: + "@babel/helper-member-expression-to-functions": "npm:^7.27.1" + "@babel/helper-optimise-call-expression": "npm:^7.27.1" + "@babel/traverse": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/72e3f8bef744c06874206bf0d80a0abbedbda269586966511c2491df4f6bf6d47a94700810c7a6737345a545dfb8295222e1e72f506bcd0b40edb3f594f739ea + languageName: node + linkType: hard + "@babel/helper-skip-transparent-expression-wrappers@npm:^7.20.0, @babel/helper-skip-transparent-expression-wrappers@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.25.9" @@ -332,6 +524,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-skip-transparent-expression-wrappers@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.27.1" + dependencies: + "@babel/traverse": "npm:^7.27.1" + "@babel/types": "npm:^7.27.1" + checksum: 10/4f380c5d0e0769fa6942a468b0c2d7c8f0c438f941aaa88f785f8752c103631d0904c7b4e76207a3b0e6588b2dec376595370d92ca8f8f1b422c14a69aa146d4 + languageName: node + linkType: hard + "@babel/helper-string-parser@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-string-parser@npm:7.25.9" @@ -339,6 +541,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-string-parser@npm:7.27.1" + checksum: 10/0ae29cc2005084abdae2966afdb86ed14d41c9c37db02c3693d5022fba9f5d59b011d039380b8e537c34daf117c549f52b452398f576e908fb9db3c7abbb3a00 + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-validator-identifier@npm:7.25.9" @@ -346,6 +555,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-validator-identifier@npm:7.27.1" + checksum: 10/75041904d21bdc0cd3b07a8ac90b11d64cd3c881e89cb936fa80edd734bf23c35e6bd1312611e8574c4eab1f3af0f63e8a5894f4699e9cfdf70c06fcf4252320 + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-validator-option@npm:7.25.9" @@ -353,6 +569,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-option@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-validator-option@npm:7.27.1" + checksum: 10/db73e6a308092531c629ee5de7f0d04390835b21a263be2644276cb27da2384b64676cab9f22cd8d8dbd854c92b1d7d56fc8517cf0070c35d1c14a8c828b0903 + languageName: node + linkType: hard + "@babel/helper-wrap-function@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-wrap-function@npm:7.25.9" @@ -364,13 +587,24 @@ __metadata: languageName: node linkType: hard +"@babel/helper-wrap-function@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-wrap-function@npm:7.27.1" + dependencies: + "@babel/template": "npm:^7.27.1" + "@babel/traverse": "npm:^7.27.1" + "@babel/types": "npm:^7.27.1" + checksum: 10/effa5ba1732764982db52295a0003d0d6b527edf70d8c649f5a521808decbc47fc8f3c21cd31f7b6331192289f3bf5617141bce778fec45dcaedf5708d9c3140 + languageName: node + linkType: hard + "@babel/helpers@npm:^7.26.0": - version: 7.26.0 - resolution: "@babel/helpers@npm:7.26.0" + version: 7.27.1 + resolution: "@babel/helpers@npm:7.27.1" dependencies: - "@babel/template": "npm:^7.25.9" - "@babel/types": "npm:^7.26.0" - checksum: 10/fd4757f65d10b64cfdbf4b3adb7ea6ffff9497c53e0786452f495d1f7794da7e0898261b4db65e1c62bbb9a360d7d78a1085635c23dfc3af2ab6dcba06585f86 + "@babel/template": "npm:^7.27.1" + "@babel/types": "npm:^7.27.1" + checksum: 10/b86ee2c87d52640c63ec1fdf139d4560efc173ae6379659e0df49a3c0cf1d5f24436132ebb4459a4ee72418b43b39ee001f4e01465b48c8d31911a745ec4fd74 languageName: node linkType: hard @@ -397,6 +631,28 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.27.2": + version: 7.27.2 + resolution: "@babel/parser@npm:7.27.2" + dependencies: + "@babel/types": "npm:^7.27.1" + bin: + parser: ./bin/babel-parser.js + checksum: 10/133b4ccfbc01d4f36b0945937aabff87026c29fda6dcd3c842053a672e50f2487a101a3acd150bbaa2eecd33f3bd35650f95b806567c926f93b2af35c2b615c9 + languageName: node + linkType: hard + +"@babel/parser@npm:^7.27.3, @babel/parser@npm:^7.27.4": + version: 7.27.4 + resolution: "@babel/parser@npm:7.27.4" + dependencies: + "@babel/types": "npm:^7.27.3" + bin: + parser: ./bin/babel-parser.js + checksum: 10/5ff6db87fd17de99792bf9a54480feeb069fc90ffa64ce96524c7437222549c86dde10fc1c945d4e9a94f3f2fc6ee4b3e1cfaf372f844bd054791e30089e92cf + languageName: node + linkType: hard + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.25.9" @@ -409,6 +665,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/traverse": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/fe65257d5b82558bc6bc0f3a5a7a35b4166f71bed3747714dafb6360fadb15f036d568bc1fbeedae819165008c8feb646633ab91c0e3a95284963972f4fa9751 + languageName: node + linkType: hard + "@babel/plugin-bugfix-safari-class-field-initializer-scope@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-bugfix-safari-class-field-initializer-scope@npm:7.25.9" @@ -420,6 +688,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-bugfix-safari-class-field-initializer-scope@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-bugfix-safari-class-field-initializer-scope@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/eb7f4146dc01f1198ce559a90b077e58b951a07521ec414e3c7d4593bf6c4ab5c2af22242a7e9fec085e20299e0ba6ea97f44a45e84ab148141bf9eb959ad25e + languageName: node + linkType: hard + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:7.25.9" @@ -431,6 +710,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/621cfddfcc99a81e74f8b6f9101fd260b27500cb1a568e3ceae9cc8afe9aee45ac3bca3900a2b66c612b1a2366d29ef67d4df5a1c975be727eaad6906f98c2c6 + languageName: node + linkType: hard + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:7.25.9" @@ -444,6 +734,19 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" + "@babel/plugin-transform-optional-chaining": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.13.0 + checksum: 10/f07aa80272bd7a46b7ba11a4644da6c9b6a5a64e848dfaffdad6f02663adefd512e1aaebe664c4dd95f7ed4f80c872c7f8db8d8e34b47aae0930b412a28711a0 + languageName: node + linkType: hard + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:7.25.9" @@ -456,6 +759,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/traverse": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/dfa68da5f68c0fa9deff1739ac270a5643ea07540b26a2a05403bc536c96595f0fe98a5eac9f9b3501b79ce57caa3045a94c75d5ccbfed946a62469a370ecdc2 + languageName: node + linkType: hard + "@babel/plugin-proposal-class-properties@npm:^7.13.0": version: 7.18.6 resolution: "@babel/plugin-proposal-class-properties@npm:7.18.6" @@ -625,6 +940,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-import-assertions@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-syntax-import-assertions@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/fb661d630808d67ecb85eabad25aac4e9696a20464bad4c4a6a0d3d40e4dc22557d47e9be3d591ec06429cf048cfe169b8891c373606344d51c4f3ac0f91d6d0 + languageName: node + linkType: hard + "@babel/plugin-syntax-import-attributes@npm:^7.24.7, @babel/plugin-syntax-import-attributes@npm:^7.26.0": version: 7.26.0 resolution: "@babel/plugin-syntax-import-attributes@npm:7.26.0" @@ -636,6 +962,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-import-attributes@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-syntax-import-attributes@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/97973982fff1bbf86b3d1df13380567042887c50e2ae13a400d02a8ff2c9742a60a75e279bfb73019e1cd9710f04be5e6ab81f896e6678dcfcec8b135e8896cf + languageName: node + linkType: hard + "@babel/plugin-syntax-import-meta@npm:^7.10.4": version: 7.10.4 resolution: "@babel/plugin-syntax-import-meta@npm:7.10.4" @@ -669,6 +1006,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-jsx@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-syntax-jsx@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/c6d1324cff286a369aa95d99b8abd21dd07821b5d3affd5fe7d6058c84cff9190743287826463ee57a7beecd10fa1e4bc99061df532ee14e188c1c8937b13e3a + languageName: node + linkType: hard + "@babel/plugin-syntax-logical-assignment-operators@npm:^7.10.4": version: 7.10.4 resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4" @@ -768,6 +1116,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-typescript@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-syntax-typescript@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/87836f7e32af624c2914c73cd6b9803cf324e07d43f61dbb973c6a86f75df725e12540d91fac7141c14b697aa9268fd064220998daced156e96ac3062d7afb41 + languageName: node + linkType: hard + "@babel/plugin-syntax-unicode-sets-regex@npm:^7.18.6": version: 7.18.6 resolution: "@babel/plugin-syntax-unicode-sets-regex@npm:7.18.6" @@ -791,6 +1150,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-arrow-functions@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-arrow-functions@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/62c2cc0ae2093336b1aa1376741c5ed245c0987d9e4b4c5313da4a38155509a7098b5acce582b6781cc0699381420010da2e3086353344abe0a6a0ec38961eb7 + languageName: node + linkType: hard + "@babel/plugin-transform-async-generator-functions@npm:^7.25.4, @babel/plugin-transform-async-generator-functions@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-async-generator-functions@npm:7.25.9" @@ -804,6 +1174,19 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-async-generator-functions@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-async-generator-functions@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-remap-async-to-generator": "npm:^7.27.1" + "@babel/traverse": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/92e8ba589e8b128255846375e13fee30a3b77c889578f1f30da57ee26133f397dbbc81b27e1f19c12080b096930e62bce1dcbaa7a1453d296f51eb8bda3b8d39 + languageName: node + linkType: hard + "@babel/plugin-transform-async-to-generator@npm:^7.24.7, @babel/plugin-transform-async-to-generator@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-async-to-generator@npm:7.25.9" @@ -817,6 +1200,19 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-async-to-generator@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-async-to-generator@npm:7.27.1" + dependencies: + "@babel/helper-module-imports": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-remap-async-to-generator": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/d79d7a7ae7d416f6a48200017d027a6ba94c09c7617eea8b4e9c803630f00094c1a4fc32bf20ce3282567824ce3fcbda51653aac4003c71ea4e681b331338979 + languageName: node + linkType: hard + "@babel/plugin-transform-block-scoped-functions@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-block-scoped-functions@npm:7.25.9" @@ -828,6 +1224,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-block-scoped-functions@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-block-scoped-functions@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/7fb4988ca80cf1fc8345310d5edfe38e86b3a72a302675cdd09404d5064fe1d1fe1283ebe658ad2b71445ecef857bfb29a748064306b5f6c628e0084759c2201 + languageName: node + linkType: hard + "@babel/plugin-transform-block-scoping@npm:^7.25.0, @babel/plugin-transform-block-scoping@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-block-scoping@npm:7.25.9" @@ -839,6 +1246,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-block-scoping@npm:^7.27.1": + version: 7.27.3 + resolution: "@babel/plugin-transform-block-scoping@npm:7.27.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/0c174b0820edf6efc203de06e86897090cc91cb2beec6cfadb53baa1fe4619d11115578e3d56aa37ab5dee4fa79ecf8a736fdb689456a92666e55d0f86ae40ed + languageName: node + linkType: hard + "@babel/plugin-transform-class-properties@npm:^7.0.0-0, @babel/plugin-transform-class-properties@npm:^7.24.1, @babel/plugin-transform-class-properties@npm:^7.25.4, @babel/plugin-transform-class-properties@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-class-properties@npm:7.25.9" @@ -851,6 +1269,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-class-properties@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-class-properties@npm:7.27.1" + dependencies: + "@babel/helper-create-class-features-plugin": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/475a6e5a9454912fe1bdc171941976ca10ea4e707675d671cdb5ce6b6761d84d1791ac61b6bca81a2e5f6430cb7b9d8e4b2392404110e69c28207a754e196294 + languageName: node + linkType: hard + "@babel/plugin-transform-class-static-block@npm:^7.26.0": version: 7.26.0 resolution: "@babel/plugin-transform-class-static-block@npm:7.26.0" @@ -863,6 +1293,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-class-static-block@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-class-static-block@npm:7.27.1" + dependencies: + "@babel/helper-create-class-features-plugin": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.12.0 + checksum: 10/2d49de0f5ffc66ae873be1d8c3bf4d22e51889cc779d534e4dbda0f91e36907479e5c650b209fcfc80f922a6c3c2d76c905fc2f5dc78cc9a836f8c31b10686c4 + languageName: node + linkType: hard + "@babel/plugin-transform-classes@npm:^7.0.0-0, @babel/plugin-transform-classes@npm:^7.25.4, @babel/plugin-transform-classes@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-classes@npm:7.25.9" @@ -879,6 +1321,22 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-classes@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-classes@npm:7.27.1" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.27.1" + "@babel/helper-compilation-targets": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-replace-supers": "npm:^7.27.1" + "@babel/traverse": "npm:^7.27.1" + globals: "npm:^11.1.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/4ac2224fa68b933c80b4755300d795e055f6fb18c51432e9a4c048edcd6c64cae097eb9063d25f6c7e706ecd85a4c0b89b6f89b320b5798e3139c9cc4ff99f61 + languageName: node + linkType: hard + "@babel/plugin-transform-computed-properties@npm:^7.24.7, @babel/plugin-transform-computed-properties@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-computed-properties@npm:7.25.9" @@ -891,6 +1349,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-computed-properties@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-computed-properties@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/template": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/101f6d4575447070943d5a9efaa5bea8c552ea3083d73a9612f1a16d38b0a0a7b79a5feb65c6cc4e4fcabf28e85a570b97ccd3294da966e8fbbb6dfb97220eda + languageName: node + linkType: hard + "@babel/plugin-transform-destructuring@npm:^7.24.8, @babel/plugin-transform-destructuring@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-destructuring@npm:7.25.9" @@ -902,6 +1372,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-destructuring@npm:^7.27.1, @babel/plugin-transform-destructuring@npm:^7.27.3": + version: 7.27.3 + resolution: "@babel/plugin-transform-destructuring@npm:7.27.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/d5b1868d079551c0a2e923419613efe18a987548219bb378c61ab7e005d4f3ea590067f93996df6d896177c1cae1396b4aae9163c8a4ee77e9ffbc11a78fb88d + languageName: node + linkType: hard + "@babel/plugin-transform-dotall-regex@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-dotall-regex@npm:7.25.9" @@ -914,6 +1395,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-dotall-regex@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-dotall-regex@npm:7.27.1" + dependencies: + "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/2173e5b13f403538ffc6bd57b190cedf4caf320abc13a99e5b2721864e7148dbd3bd7c82d92377136af80432818f665fdd9a1fd33bc5549a4c91e24e5ce2413c + languageName: node + linkType: hard + "@babel/plugin-transform-duplicate-keys@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-duplicate-keys@npm:7.25.9" @@ -925,6 +1418,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-duplicate-keys@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-duplicate-keys@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/987b718d2fab7626f61b72325c8121ead42341d6f46ad3a9b5e5f67f3ec558c903f1b8336277ffc43caac504ce00dd23a5456b5d1da23913333e1da77751f08d + languageName: node + linkType: hard + "@babel/plugin-transform-duplicate-named-capturing-groups-regex@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-duplicate-named-capturing-groups-regex@npm:7.25.9" @@ -937,17 +1441,40 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-dynamic-import@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/plugin-transform-dynamic-import@npm:7.25.9" +"@babel/plugin-transform-duplicate-named-capturing-groups-regex@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-duplicate-named-capturing-groups-regex@npm:7.27.1" dependencies: - "@babel/helper-plugin-utils": "npm:^7.25.9" + "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/2a109613535e6ac79240dced71429e988affd6a5b3d0cd0f563c8d6c208c51ce7bf2c300bc1150502376b26a51f279119b3358f1c0f2d2f8abca3bcd62e1ae46 + languageName: node + linkType: hard + +"@babel/plugin-transform-dynamic-import@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/plugin-transform-dynamic-import@npm:7.25.9" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.25.9" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 10/aaca1ccda819be9b2b85af47ba08ddd2210ff2dbea222f26e4cd33f97ab020884bf81a66197e50872721e9daf36ceb5659502c82199884ea74d5d75ecda5c58b languageName: node linkType: hard +"@babel/plugin-transform-dynamic-import@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-dynamic-import@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/7a9fbc8d17148b7f11a1d1ca3990d2c2cd44bd08a45dcaf14f20a017721235b9044b20e6168b6940282bb1b48fb78e6afbdfb9dd9d82fde614e15baa7d579932 + languageName: node + linkType: hard + "@babel/plugin-transform-exponentiation-operator@npm:^7.25.9": version: 7.26.3 resolution: "@babel/plugin-transform-exponentiation-operator@npm:7.26.3" @@ -959,7 +1486,29 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-export-namespace-from@npm:^7.22.11, @babel/plugin-transform-export-namespace-from@npm:^7.24.1, @babel/plugin-transform-export-namespace-from@npm:^7.25.9": +"@babel/plugin-transform-exponentiation-operator@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-exponentiation-operator@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/dbbedd24724c2d590ef59d32cb1fef34e99daba41c5b621f9f4c4da23e15c2bb4b1e3d954c314645016391404cf00f1e4ddec8f1f7891438bcde9aaf16e16ee0 + languageName: node + linkType: hard + +"@babel/plugin-transform-export-namespace-from@npm:^7.23.4, @babel/plugin-transform-export-namespace-from@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-export-namespace-from@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/85082923eca317094f08f4953d8ea2a6558b3117826c0b740676983902b7236df1f4213ad844cb38c2dae104753dbe8f1cc51f01567835d476d32f5f544a4385 + languageName: node + linkType: hard + +"@babel/plugin-transform-export-namespace-from@npm:^7.24.1, @babel/plugin-transform-export-namespace-from@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-export-namespace-from@npm:7.25.9" dependencies: @@ -994,6 +1543,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-for-of@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-for-of@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/705c591d17ef263c309bba8c38e20655e8e74ff7fd21883a9cdaf5bf1df42d724383ad3d88ac01f42926e15b1e1e66f2f7f8c4e87de955afffa290d52314b019 + languageName: node + linkType: hard + "@babel/plugin-transform-function-name@npm:^7.25.1, @babel/plugin-transform-function-name@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-function-name@npm:7.25.9" @@ -1007,6 +1568,19 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-function-name@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-function-name@npm:7.27.1" + dependencies: + "@babel/helper-compilation-targets": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/traverse": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/26a2a183c3c52a96495967420a64afc5a09f743a230272a131668abf23001e393afa6371e6f8e6c60f4182bea210ed31d1caf866452d91009c1daac345a52f23 + languageName: node + linkType: hard + "@babel/plugin-transform-json-strings@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-json-strings@npm:7.25.9" @@ -1018,6 +1592,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-json-strings@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-json-strings@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/2c05a02f63b49f47069271b3405a66c3c8038de5b995b0700b1bd9a5e2bb3e67abd01e4604629302a521f4d8122a4233944aefa16559fd4373d256cc5d3da57f + languageName: node + linkType: hard + "@babel/plugin-transform-literals@npm:^7.25.2, @babel/plugin-transform-literals@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-literals@npm:7.25.9" @@ -1029,6 +1614,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-literals@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-literals@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/0a76d12ab19f32dd139964aea7da48cecdb7de0b75e207e576f0f700121fe92367d788f328bf4fb44b8261a0f605c97b44e62ae61cddbb67b14e94c88b411f95 + languageName: node + linkType: hard + "@babel/plugin-transform-logical-assignment-operators@npm:^7.24.7, @babel/plugin-transform-logical-assignment-operators@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-logical-assignment-operators@npm:7.25.9" @@ -1040,6 +1636,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-logical-assignment-operators@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-logical-assignment-operators@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/2757955d81d65cc4701c17b83720745f6858f7a1d1d58117e379c204f47adbeb066b778596b6168bdbf4a22c229aab595d79a9abc261d0c6bfd62d4419466e73 + languageName: node + linkType: hard + "@babel/plugin-transform-member-expression-literals@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-member-expression-literals@npm:7.25.9" @@ -1051,6 +1658,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-member-expression-literals@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-member-expression-literals@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/804121430a6dcd431e6ffe99c6d1fbbc44b43478113b79c677629e7f877b4f78a06b69c6bfb2747fd84ee91879fe2eb32e4620b53124603086cf5b727593ebe8 + languageName: node + linkType: hard + "@babel/plugin-transform-modules-amd@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-modules-amd@npm:7.25.9" @@ -1063,6 +1681,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-modules-amd@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-modules-amd@npm:7.27.1" + dependencies: + "@babel/helper-module-transforms": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/5ca9257981f2bbddd9dccf9126f1368de1cb335e7a5ff5cca9282266825af5b18b5f06c144320dcf5d2a200d2b53b6d22d9b801a55dc0509ab5a5838af7e61b7 + languageName: node + linkType: hard + "@babel/plugin-transform-modules-commonjs@npm:^7.13.8, @babel/plugin-transform-modules-commonjs@npm:^7.24.8, @babel/plugin-transform-modules-commonjs@npm:^7.25.9": version: 7.26.3 resolution: "@babel/plugin-transform-modules-commonjs@npm:7.26.3" @@ -1075,6 +1705,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-modules-commonjs@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-modules-commonjs@npm:7.27.1" + dependencies: + "@babel/helper-module-transforms": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/9059243a977bc1f13e3dccfc6feb6508890e7c7bb191f7eb56626b20672b4b12338051ca835ab55426875a473181502c8f35b4df58ba251bef63b25866d995fe + languageName: node + linkType: hard + "@babel/plugin-transform-modules-systemjs@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-modules-systemjs@npm:7.25.9" @@ -1089,6 +1731,20 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-modules-systemjs@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-modules-systemjs@npm:7.27.1" + dependencies: + "@babel/helper-module-transforms": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.27.1" + "@babel/traverse": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/06d7bf76ac4688a36ae8e8d2dde1c3b8bab4594362132b74a00d5a32e6716944d68911b9bc53df60e59f4f9c7f1796525503ce3e3eed42f842d7775ccdfd836e + languageName: node + linkType: hard + "@babel/plugin-transform-modules-umd@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-modules-umd@npm:7.25.9" @@ -1101,6 +1757,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-modules-umd@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-modules-umd@npm:7.27.1" + dependencies: + "@babel/helper-module-transforms": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/7388932863b4ee01f177eb6c2e2df9e2312005e43ada99897624d5565db4b9cef1e30aa7ad2c79bbe5373f284cfcddea98d8fe212714a24c6aba223272163058 + languageName: node + linkType: hard + "@babel/plugin-transform-named-capturing-groups-regex@npm:^7.24.7, @babel/plugin-transform-named-capturing-groups-regex@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-named-capturing-groups-regex@npm:7.25.9" @@ -1113,6 +1781,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-named-capturing-groups-regex@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-named-capturing-groups-regex@npm:7.27.1" + dependencies: + "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/a711c92d9753df26cefc1792481e5cbff4fe4f32b383d76b25e36fa865d8023b1b9aa6338cf18f5c0e864c71a7fbe8115e840872ccd61a914d9953849c68de7d + languageName: node + linkType: hard + "@babel/plugin-transform-new-target@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-new-target@npm:7.25.9" @@ -1124,6 +1804,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-new-target@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-new-target@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/620d78ee476ae70960989e477dc86031ffa3d554b1b1999e6ec95261629f7a13e5a7b98579c63a009f9fdf14def027db57de1f0ae1f06fb6eaed8908ff65cf68 + languageName: node + linkType: hard + "@babel/plugin-transform-nullish-coalescing-operator@npm:^7.0.0-0, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.24.7, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.25.9" @@ -1135,6 +1826,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/15333f4888ffedc449a2a21a0b1ca7983e089f43faa00cfb71d2466e20221a5fd979cdb1a3f57bc20fc62c67bd3ff3dde054133fb6324a58be8f64d20aefacd2 + languageName: node + linkType: hard + "@babel/plugin-transform-numeric-separator@npm:^7.24.1, @babel/plugin-transform-numeric-separator@npm:^7.24.7, @babel/plugin-transform-numeric-separator@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-numeric-separator@npm:7.25.9" @@ -1146,7 +1848,18 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-object-rest-spread@npm:^7.12.13, @babel/plugin-transform-object-rest-spread@npm:^7.24.1, @babel/plugin-transform-object-rest-spread@npm:^7.24.7, @babel/plugin-transform-object-rest-spread@npm:^7.25.9": +"@babel/plugin-transform-numeric-separator@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-numeric-separator@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/049b958911de86d32408cd78017940a207e49c054ae9534ab53a32a57122cc592c0aae3c166d6f29bd1a7d75cc779d71883582dd76cb28b2fbb493e842d8ffca + languageName: node + linkType: hard + +"@babel/plugin-transform-object-rest-spread@npm:^7.24.1, @babel/plugin-transform-object-rest-spread@npm:^7.24.7, @babel/plugin-transform-object-rest-spread@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-object-rest-spread@npm:7.25.9" dependencies: @@ -1159,6 +1872,20 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-object-rest-spread@npm:^7.27.2": + version: 7.27.3 + resolution: "@babel/plugin-transform-object-rest-spread@npm:7.27.3" + dependencies: + "@babel/helper-compilation-targets": "npm:^7.27.2" + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/plugin-transform-destructuring": "npm:^7.27.3" + "@babel/plugin-transform-parameters": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/7cc7be29a99010aac04fd78383f06d550b26460ea5367489e58ae484f0ed2f176966f0196bea0c2114a9872dd854a482bca38a9fad661c9d10d102c7195d53fd + languageName: node + linkType: hard + "@babel/plugin-transform-object-super@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-object-super@npm:7.25.9" @@ -1171,6 +1898,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-object-super@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-object-super@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-replace-supers": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/46b819cb9a6cd3cfefe42d07875fee414f18d5e66040366ae856116db560ad4e16f3899a0a7fddd6773e0d1458444f94b208b67c0e3b6977a27ea17a5c13dbf6 + languageName: node + linkType: hard + "@babel/plugin-transform-optional-catch-binding@npm:^7.24.7, @babel/plugin-transform-optional-catch-binding@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-optional-catch-binding@npm:7.25.9" @@ -1182,6 +1921,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-optional-catch-binding@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-optional-catch-binding@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/f4356b04cf21a98480f9788ea50f1f13ee88e89bb6393ba4b84d1f39a4a84c7928c9a4328e8f4c5b6deb218da68a8fd17bf4f46faec7653ddc20ffaaa5ba49f4 + languageName: node + linkType: hard + "@babel/plugin-transform-optional-chaining@npm:^7.0.0-0, @babel/plugin-transform-optional-chaining@npm:^7.24.8, @babel/plugin-transform-optional-chaining@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-optional-chaining@npm:7.25.9" @@ -1194,7 +1944,19 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-parameters@npm:^7.22.15, @babel/plugin-transform-parameters@npm:^7.24.7, @babel/plugin-transform-parameters@npm:^7.25.9": +"@babel/plugin-transform-optional-chaining@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-optional-chaining@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/34b0f96400c259a2722740d17a001fe45f78d8ff052c40e29db2e79173be72c1cfe8d9681067e3f5da3989e4a557402df5c982c024c18257587a41e022f95640 + languageName: node + linkType: hard + +"@babel/plugin-transform-parameters@npm:^7.24.7, @babel/plugin-transform-parameters@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-parameters@npm:7.25.9" dependencies: @@ -1205,6 +1967,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-parameters@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-parameters@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/47db574f8f3adf7a5d85933c9a2a2dee956ceda9e00fb4e03e9a9d600b559f06cba2da7c5e78a12b05dcf993cf147634edf0391f3f20a6b451830f41be47fe68 + languageName: node + linkType: hard + "@babel/plugin-transform-private-methods@npm:^7.24.7, @babel/plugin-transform-private-methods@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-private-methods@npm:7.25.9" @@ -1217,6 +1990,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-private-methods@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-private-methods@npm:7.27.1" + dependencies: + "@babel/helper-create-class-features-plugin": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/c76f8f6056946466116e67eb9d8014a2d748ade2062636ab82045c1dac9c233aff10e597777bc5af6f26428beb845ceb41b95007abef7d0484da95789da56662 + languageName: node + linkType: hard + "@babel/plugin-transform-private-property-in-object@npm:^7.24.7, @babel/plugin-transform-private-property-in-object@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-private-property-in-object@npm:7.25.9" @@ -1230,6 +2015,19 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-private-property-in-object@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-private-property-in-object@npm:7.27.1" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.27.1" + "@babel/helper-create-class-features-plugin": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/d4466d42a02c5a318d9d7b8102969fd032b17ff044918dfd462d5cc49bd11f5773ee0794781702afdf4727ba11e9be6cbea1e396bc0a7307761bb9a56399012a + languageName: node + linkType: hard + "@babel/plugin-transform-property-literals@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-property-literals@npm:7.25.9" @@ -1241,6 +2039,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-property-literals@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-property-literals@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/7caec27d5ed8870895c9faf4f71def72745d69da0d8e77903146a4e135fd7bed5778f5f9cebb36c5fba86338e6194dd67a08c033fc84b4299b7eceab6d9630cb + languageName: node + linkType: hard + "@babel/plugin-transform-react-constant-elements@npm:^7.21.3": version: 7.25.9 resolution: "@babel/plugin-transform-react-constant-elements@npm:7.25.9" @@ -1335,6 +2144,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-regenerator@npm:^7.27.1": + version: 7.27.4 + resolution: "@babel/plugin-transform-regenerator@npm:7.27.4" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/7048ed44b0dbb74f6527689e1e797e8bc20a1702f47e38b9f55e8e09ecf56ea520e7a67dfce5724f1dd24b33df98365e14c8d2c2b3f9145fa75a4f8404a64c4a + languageName: node + linkType: hard + "@babel/plugin-transform-regexp-modifiers@npm:^7.26.0": version: 7.26.0 resolution: "@babel/plugin-transform-regexp-modifiers@npm:7.26.0" @@ -1347,6 +2167,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-regexp-modifiers@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-regexp-modifiers@npm:7.27.1" + dependencies: + "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/f6cb385fe0e798bff7e9b20cf5912bf40e180895ff3610b1ccdce260f3c20daaebb3a99dc087c8168a99151cd3e16b94f4689fd5a4b01cf1834b45c133e620b2 + languageName: node + linkType: hard + "@babel/plugin-transform-reserved-words@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-reserved-words@npm:7.25.9" @@ -1358,6 +2190,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-reserved-words@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-reserved-words@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/dea0b66742d2863b369c06c053e11e15ba785892ea19cccf7aef3c1bdaa38b6ab082e19984c5ea7810d275d9445c5400fcc385ad71ce707ed9256fadb102af3b + languageName: node + linkType: hard + "@babel/plugin-transform-runtime@npm:^7.24.3, @babel/plugin-transform-runtime@npm:^7.24.7": version: 7.25.9 resolution: "@babel/plugin-transform-runtime@npm:7.25.9" @@ -1385,6 +2228,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-shorthand-properties@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-shorthand-properties@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/fbba6e2aef0b69681acb68202aa249c0598e470cc0853d7ff5bd0171fd6a7ec31d77cfabcce9df6360fc8349eded7e4a65218c32551bd3fc0caaa1ac899ac6d4 + languageName: node + linkType: hard + "@babel/plugin-transform-spread@npm:^7.24.7, @babel/plugin-transform-spread@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-spread@npm:7.25.9" @@ -1397,6 +2251,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-spread@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-spread@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/3edd28b07e1951f32aa2d380d9a0e0ed408c64a5cea2921d02308541042aca18f146b3a61e82e534d4d61cb3225dbc847f4f063aedfff6230b1a41282e95e8a2 + languageName: node + linkType: hard + "@babel/plugin-transform-sticky-regex@npm:^7.24.7, @babel/plugin-transform-sticky-regex@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-sticky-regex@npm:7.25.9" @@ -1408,6 +2274,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-sticky-regex@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-sticky-regex@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/e1414a502efba92c7974681767e365a8cda6c5e9e5f33472a9eaa0ce2e75cea0a9bef881ff8dda37c7810ad902f98d3c00ead92a3ac3b73a79d011df85b5a189 + languageName: node + linkType: hard + "@babel/plugin-transform-template-literals@npm:^7.0.0-0, @babel/plugin-transform-template-literals@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-template-literals@npm:7.25.9" @@ -1419,6 +2296,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-template-literals@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-template-literals@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/93aad782503b691faef7c0893372d5243df3219b07f1f22cfc32c104af6a2e7acd6102c128439eab15336d048f1b214ca134b87b0630d8cd568bf447f78b25ce + languageName: node + linkType: hard + "@babel/plugin-transform-typeof-symbol@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-typeof-symbol@npm:7.25.9" @@ -1430,6 +2318,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-typeof-symbol@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-typeof-symbol@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/812d736402a6f9313b86b8adf36740394400be7a09c48e51ee45ab4a383a3f46fc618d656dd12e44934665e42ae71cf143e25b95491b699ef7c737950dbdb862 + languageName: node + linkType: hard + "@babel/plugin-transform-typescript@npm:^7.25.2, @babel/plugin-transform-typescript@npm:^7.25.9": version: 7.26.3 resolution: "@babel/plugin-transform-typescript@npm:7.26.3" @@ -1445,6 +2344,21 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-typescript@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-typescript@npm:7.27.1" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.27.1" + "@babel/helper-create-class-features-plugin": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" + "@babel/plugin-syntax-typescript": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/069b37c5beeb613635e65a3024d6f5f3da09c82137e055a7d413bfd2778d623879bd7b2985466fb66f8a32e805a9bf6aa7e336e6bfcf0304c869bb850e8400c9 + languageName: node + linkType: hard + "@babel/plugin-transform-unicode-escapes@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-unicode-escapes@npm:7.25.9" @@ -1456,6 +2370,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-unicode-escapes@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-unicode-escapes@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/87b9e49dee4ab6e78f4cdcdbdd837d7784f02868a96bfc206c8dbb17dd85db161b5a0ecbe95b19a42e8aea0ce57e80249e1facbf9221d7f4114d52c3b9136c9e + languageName: node + linkType: hard + "@babel/plugin-transform-unicode-property-regex@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-unicode-property-regex@npm:7.25.9" @@ -1468,6 +2393,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-unicode-property-regex@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-unicode-property-regex@npm:7.27.1" + dependencies: + "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/5d99c89537d1ebaac3f526c04b162cf95a47d363d4829f78c6701a2c06ab78a48da66a94f853f85f44a3d72153410ba923e072bed4b7166fa097f503eb14131d + languageName: node + linkType: hard + "@babel/plugin-transform-unicode-regex@npm:^7.0.0-0, @babel/plugin-transform-unicode-regex@npm:^7.24.7, @babel/plugin-transform-unicode-regex@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-unicode-regex@npm:7.25.9" @@ -1480,6 +2417,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-unicode-regex@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-unicode-regex@npm:7.27.1" + dependencies: + "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/a34d89a2b75fb78e66d97c3dc90d4877f7e31f43316b52176f95a5dee20e9bb56ecf158eafc42a001676ddf7b393d9e67650bad6b32f5405780f25fb83cd68e3 + languageName: node + linkType: hard + "@babel/plugin-transform-unicode-sets-regex@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-unicode-sets-regex@npm:7.25.9" @@ -1492,6 +2441,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-unicode-sets-regex@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-unicode-sets-regex@npm:7.27.1" + dependencies: + "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/295126074c7388ab05c82ef3ed0907a1ee4666bbdd763477ead9aba6eb2c74bdf65669416861ac93d337a4a27640963bb214acadc2697275ce95aab14868d57f + languageName: node + linkType: hard + "@babel/preset-env@npm:^7.11.0, @babel/preset-env@npm:^7.20.2, @babel/preset-env@npm:^7.24.4, @babel/preset-env@npm:^7.26.0": version: 7.26.0 resolution: "@babel/preset-env@npm:7.26.0" @@ -1571,6 +2532,85 @@ __metadata: languageName: node linkType: hard +"@babel/preset-env@npm:^7.23.8": + version: 7.27.2 + resolution: "@babel/preset-env@npm:7.27.2" + dependencies: + "@babel/compat-data": "npm:^7.27.2" + "@babel/helper-compilation-targets": "npm:^7.27.2" + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-validator-option": "npm:^7.27.1" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "npm:^7.27.1" + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "npm:^7.27.1" + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "npm:^7.27.1" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "npm:^7.27.1" + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "npm:^7.27.1" + "@babel/plugin-proposal-private-property-in-object": "npm:7.21.0-placeholder-for-preset-env.2" + "@babel/plugin-syntax-import-assertions": "npm:^7.27.1" + "@babel/plugin-syntax-import-attributes": "npm:^7.27.1" + "@babel/plugin-syntax-unicode-sets-regex": "npm:^7.18.6" + "@babel/plugin-transform-arrow-functions": "npm:^7.27.1" + "@babel/plugin-transform-async-generator-functions": "npm:^7.27.1" + "@babel/plugin-transform-async-to-generator": "npm:^7.27.1" + "@babel/plugin-transform-block-scoped-functions": "npm:^7.27.1" + "@babel/plugin-transform-block-scoping": "npm:^7.27.1" + "@babel/plugin-transform-class-properties": "npm:^7.27.1" + "@babel/plugin-transform-class-static-block": "npm:^7.27.1" + "@babel/plugin-transform-classes": "npm:^7.27.1" + "@babel/plugin-transform-computed-properties": "npm:^7.27.1" + "@babel/plugin-transform-destructuring": "npm:^7.27.1" + "@babel/plugin-transform-dotall-regex": "npm:^7.27.1" + "@babel/plugin-transform-duplicate-keys": "npm:^7.27.1" + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "npm:^7.27.1" + "@babel/plugin-transform-dynamic-import": "npm:^7.27.1" + "@babel/plugin-transform-exponentiation-operator": "npm:^7.27.1" + "@babel/plugin-transform-export-namespace-from": "npm:^7.27.1" + "@babel/plugin-transform-for-of": "npm:^7.27.1" + "@babel/plugin-transform-function-name": "npm:^7.27.1" + "@babel/plugin-transform-json-strings": "npm:^7.27.1" + "@babel/plugin-transform-literals": "npm:^7.27.1" + "@babel/plugin-transform-logical-assignment-operators": "npm:^7.27.1" + "@babel/plugin-transform-member-expression-literals": "npm:^7.27.1" + "@babel/plugin-transform-modules-amd": "npm:^7.27.1" + "@babel/plugin-transform-modules-commonjs": "npm:^7.27.1" + "@babel/plugin-transform-modules-systemjs": "npm:^7.27.1" + "@babel/plugin-transform-modules-umd": "npm:^7.27.1" + "@babel/plugin-transform-named-capturing-groups-regex": "npm:^7.27.1" + "@babel/plugin-transform-new-target": "npm:^7.27.1" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.27.1" + "@babel/plugin-transform-numeric-separator": "npm:^7.27.1" + "@babel/plugin-transform-object-rest-spread": "npm:^7.27.2" + "@babel/plugin-transform-object-super": "npm:^7.27.1" + "@babel/plugin-transform-optional-catch-binding": "npm:^7.27.1" + "@babel/plugin-transform-optional-chaining": "npm:^7.27.1" + "@babel/plugin-transform-parameters": "npm:^7.27.1" + "@babel/plugin-transform-private-methods": "npm:^7.27.1" + "@babel/plugin-transform-private-property-in-object": "npm:^7.27.1" + "@babel/plugin-transform-property-literals": "npm:^7.27.1" + "@babel/plugin-transform-regenerator": "npm:^7.27.1" + "@babel/plugin-transform-regexp-modifiers": "npm:^7.27.1" + "@babel/plugin-transform-reserved-words": "npm:^7.27.1" + "@babel/plugin-transform-shorthand-properties": "npm:^7.27.1" + "@babel/plugin-transform-spread": "npm:^7.27.1" + "@babel/plugin-transform-sticky-regex": "npm:^7.27.1" + "@babel/plugin-transform-template-literals": "npm:^7.27.1" + "@babel/plugin-transform-typeof-symbol": "npm:^7.27.1" + "@babel/plugin-transform-unicode-escapes": "npm:^7.27.1" + "@babel/plugin-transform-unicode-property-regex": "npm:^7.27.1" + "@babel/plugin-transform-unicode-regex": "npm:^7.27.1" + "@babel/plugin-transform-unicode-sets-regex": "npm:^7.27.1" + "@babel/preset-modules": "npm:0.1.6-no-external-plugins" + babel-plugin-polyfill-corejs2: "npm:^0.4.10" + babel-plugin-polyfill-corejs3: "npm:^0.11.0" + babel-plugin-polyfill-regenerator: "npm:^0.6.1" + core-js-compat: "npm:^3.40.0" + semver: "npm:^6.3.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/3748b5e5582bee12f2b21ee4af9552a0ea8851fdfa8e54cdab142ac9191b7e9b1673d23056c0d2c3c6fd554eb85873664acfc9829c4f14a8ae7676548184eff6 + languageName: node + linkType: hard + "@babel/preset-flow@npm:^7.13.13": version: 7.25.9 resolution: "@babel/preset-flow@npm:7.25.9" @@ -1628,6 +2668,21 @@ __metadata: languageName: node linkType: hard +"@babel/preset-typescript@npm:^7.23.3": + version: 7.27.1 + resolution: "@babel/preset-typescript@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-validator-option": "npm:^7.27.1" + "@babel/plugin-syntax-jsx": "npm:^7.27.1" + "@babel/plugin-transform-modules-commonjs": "npm:^7.27.1" + "@babel/plugin-transform-typescript": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/9d8e75326b3c93fa016ba7aada652800fc77bc05fcc181888700a049935e8cf1284b549de18a5d62ef3591d02f097ea6de1111f7d71a991aaf36ba74657bd145 + languageName: node + linkType: hard + "@babel/register@npm:^7.13.16": version: 7.25.9 resolution: "@babel/register@npm:7.25.9" @@ -1643,12 +2698,10 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.5, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.4, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": - version: 7.26.0 - resolution: "@babel/runtime@npm:7.26.0" - dependencies: - regenerator-runtime: "npm:^0.14.0" - checksum: 10/9f4ea1c1d566c497c052d505587554e782e021e6ccd302c2ad7ae8291c8e16e3f19d4a7726fb64469e057779ea2081c28b7dbefec6d813a22f08a35712c0f699 +"@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.4, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.26.9, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": + version: 7.27.1 + resolution: "@babel/runtime@npm:7.27.1" + checksum: 10/34cefcbf781ea5a4f1b93f8563327b9ac82694bebdae91e8bd9d7f58d084cbe5b9a6e7f94d77076e15b0bcdaa0040a36cb30737584028df6c4673b4c67b2a31d languageName: node linkType: hard @@ -1663,6 +2716,17 @@ __metadata: languageName: node linkType: hard +"@babel/template@npm:^7.27.1, @babel/template@npm:^7.27.2": + version: 7.27.2 + resolution: "@babel/template@npm:7.27.2" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@babel/parser": "npm:^7.27.2" + "@babel/types": "npm:^7.27.1" + checksum: 10/fed15a84beb0b9340e5f81566600dbee5eccd92e4b9cc42a944359b1aa1082373391d9d5fc3656981dff27233ec935d0bc96453cf507f60a4b079463999244d8 + languageName: node + linkType: hard + "@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3, @babel/traverse@npm:^7.1.6, @babel/traverse@npm:^7.18.9, @babel/traverse@npm:^7.25.3, @babel/traverse@npm:^7.25.4, @babel/traverse@npm:^7.25.9": version: 7.26.4 resolution: "@babel/traverse@npm:7.26.4" @@ -1678,6 +2742,21 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.27.3": + version: 7.27.4 + resolution: "@babel/traverse@npm:7.27.4" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@babel/generator": "npm:^7.27.3" + "@babel/parser": "npm:^7.27.4" + "@babel/template": "npm:^7.27.2" + "@babel/types": "npm:^7.27.3" + debug: "npm:^4.3.1" + globals: "npm:^11.1.0" + checksum: 10/4debb80b9068a46e188e478272f3b6820e16d17e2651e82d0a0457176b0c3b2489994f0a0d6e8941ee90218b0a8a69fe52ba350c1aa66eb4c72570d6b2405f91 + languageName: node + linkType: hard + "@babel/types@npm:^7.0.0, @babel/types@npm:^7.1.6, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.25.2, @babel/types@npm:^7.25.4, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.0, @babel/types@npm:^7.26.3, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": version: 7.26.3 resolution: "@babel/types@npm:7.26.3" @@ -1688,6 +2767,26 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/types@npm:7.27.1" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.27.1" + checksum: 10/81f8ada28c4b29695d7d4c4cbfaa5ec3138ccebbeb26628c7c3cc570fdc84f28967c9e68caf4977d51ff4f4d3159c88857ef278317f84f3515dd65e5b8a74995 + languageName: node + linkType: hard + +"@babel/types@npm:^7.27.3": + version: 7.27.3 + resolution: "@babel/types@npm:7.27.3" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.27.1" + checksum: 10/a24e6accd85c4747b974b3d68a3210d0aa1180c1a77b287ffcb7401cd2edad7bdecadaeb40fe5191be3990c3a5252943f7de7c09da13ed269adbb054b97056ee + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -1755,9 +2854,9 @@ __metadata: languageName: node linkType: hard -"@cowprotocol/app-data@npm:^2.3.0, @cowprotocol/app-data@npm:^2.4.0": - version: 2.4.0 - resolution: "@cowprotocol/app-data@npm:2.4.0" +"@cowprotocol/app-data@npm:^3.1.0": + version: 3.1.0 + resolution: "@cowprotocol/app-data@npm:3.1.0" dependencies: ajv: "npm:^8.11.0" cross-fetch: "npm:^3.1.5" @@ -1769,7 +2868,7 @@ __metadata: ethers: ^5.0.0 ipfs-only-hash: ^4.x multiformats: ^9.x - checksum: 10/3b585f32011d68f2dce1be2003c849f797ffc6ac5eb199f39dc2aeb7ab2d77f8491f52d56857809a3b41dc62ac7c22151d1789bb5ab7048a4321b317f3dbd4ce + checksum: 10/cd540b7f7578917be115454d307df333f39101614c59a839ff31278a98c70d9a8f3aaf5fcf08f34075f537710a7db1de265249dae6b7ddda08bcba890b423856 languageName: node linkType: hard @@ -1861,6 +2960,27 @@ __metadata: languageName: node linkType: hard +"@datadog/browser-core@npm:6.6.3": + version: 6.6.3 + resolution: "@datadog/browser-core@npm:6.6.3" + checksum: 10/2b0ccf614bd0c1e99dbd916ae7308fccddc4d1330f4e9dc28c66a11481db5222f7ffc8ca7eff985dc7359810d295adacb535ccf4c095ba02ec1e03472855d907 + languageName: node + linkType: hard + +"@datadog/browser-logs@npm:^6.6.3": + version: 6.6.3 + resolution: "@datadog/browser-logs@npm:6.6.3" + dependencies: + "@datadog/browser-core": "npm:6.6.3" + peerDependencies: + "@datadog/browser-rum": 6.6.3 + peerDependenciesMeta: + "@datadog/browser-rum": + optional: true + checksum: 10/2a347917afd3739d672daac3cd5113cae4e8b94d87542775a6e81ff86661837e39bda3457b8d0b32784ef76f4ad2191b0912ea184620dbe0a0eadf76528691bf + languageName: node + linkType: hard + "@discoveryjs/json-ext@npm:0.5.7": version: 0.5.7 resolution: "@discoveryjs/json-ext@npm:0.5.7" @@ -2089,9 +3209,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/aix-ppc64@npm:0.24.2" +"@esbuild/aix-ppc64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/aix-ppc64@npm:0.24.0" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/aix-ppc64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/aix-ppc64@npm:0.25.3" conditions: os=aix & cpu=ppc64 languageName: node linkType: hard @@ -2103,9 +3230,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/android-arm64@npm:0.24.2" +"@esbuild/android-arm64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/android-arm64@npm:0.24.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/android-arm64@npm:0.25.3" conditions: os=android & cpu=arm64 languageName: node linkType: hard @@ -2117,9 +3251,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/android-arm@npm:0.24.2" +"@esbuild/android-arm@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/android-arm@npm:0.24.0" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/android-arm@npm:0.25.3" conditions: os=android & cpu=arm languageName: node linkType: hard @@ -2131,9 +3272,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-x64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/android-x64@npm:0.24.2" +"@esbuild/android-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/android-x64@npm:0.24.0" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/android-x64@npm:0.25.3" conditions: os=android & cpu=x64 languageName: node linkType: hard @@ -2145,9 +3293,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/darwin-arm64@npm:0.24.2" +"@esbuild/darwin-arm64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/darwin-arm64@npm:0.24.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/darwin-arm64@npm:0.25.3" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -2159,9 +3314,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/darwin-x64@npm:0.24.2" +"@esbuild/darwin-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/darwin-x64@npm:0.24.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/darwin-x64@npm:0.25.3" conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -2173,9 +3335,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/freebsd-arm64@npm:0.24.2" +"@esbuild/freebsd-arm64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/freebsd-arm64@npm:0.24.0" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/freebsd-arm64@npm:0.25.3" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard @@ -2187,9 +3356,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/freebsd-x64@npm:0.24.2" +"@esbuild/freebsd-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/freebsd-x64@npm:0.24.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/freebsd-x64@npm:0.25.3" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard @@ -2201,9 +3377,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/linux-arm64@npm:0.24.2" +"@esbuild/linux-arm64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-arm64@npm:0.24.0" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-arm64@npm:0.25.3" conditions: os=linux & cpu=arm64 languageName: node linkType: hard @@ -2215,9 +3398,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/linux-arm@npm:0.24.2" +"@esbuild/linux-arm@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-arm@npm:0.24.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-arm@npm:0.25.3" conditions: os=linux & cpu=arm languageName: node linkType: hard @@ -2229,9 +3419,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/linux-ia32@npm:0.24.2" +"@esbuild/linux-ia32@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-ia32@npm:0.24.0" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-ia32@npm:0.25.3" conditions: os=linux & cpu=ia32 languageName: node linkType: hard @@ -2243,9 +3440,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/linux-loong64@npm:0.24.2" +"@esbuild/linux-loong64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-loong64@npm:0.24.0" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-loong64@npm:0.25.3" conditions: os=linux & cpu=loong64 languageName: node linkType: hard @@ -2257,9 +3461,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/linux-mips64el@npm:0.24.2" +"@esbuild/linux-mips64el@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-mips64el@npm:0.24.0" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-mips64el@npm:0.25.3" conditions: os=linux & cpu=mips64el languageName: node linkType: hard @@ -2271,9 +3482,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/linux-ppc64@npm:0.24.2" +"@esbuild/linux-ppc64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-ppc64@npm:0.24.0" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-ppc64@npm:0.25.3" conditions: os=linux & cpu=ppc64 languageName: node linkType: hard @@ -2285,9 +3503,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/linux-riscv64@npm:0.24.2" +"@esbuild/linux-riscv64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-riscv64@npm:0.24.0" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-riscv64@npm:0.25.3" conditions: os=linux & cpu=riscv64 languageName: node linkType: hard @@ -2299,9 +3524,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/linux-s390x@npm:0.24.2" +"@esbuild/linux-s390x@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-s390x@npm:0.24.0" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-s390x@npm:0.25.3" conditions: os=linux & cpu=s390x languageName: node linkType: hard @@ -2313,16 +3545,23 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/linux-x64@npm:0.24.2" +"@esbuild/linux-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/linux-x64@npm:0.24.0" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-x64@npm:0.25.3" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@esbuild/netbsd-arm64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/netbsd-arm64@npm:0.24.2" +"@esbuild/netbsd-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/netbsd-arm64@npm:0.25.3" conditions: os=netbsd & cpu=arm64 languageName: node linkType: hard @@ -2334,16 +3573,30 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/netbsd-x64@npm:0.24.2" +"@esbuild/netbsd-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/netbsd-x64@npm:0.24.0" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/netbsd-x64@npm:0.25.3" conditions: os=netbsd & cpu=x64 languageName: node linkType: hard -"@esbuild/openbsd-arm64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/openbsd-arm64@npm:0.24.2" +"@esbuild/openbsd-arm64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/openbsd-arm64@npm:0.24.0" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/openbsd-arm64@npm:0.25.3" conditions: os=openbsd & cpu=arm64 languageName: node linkType: hard @@ -2355,9 +3608,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/openbsd-x64@npm:0.24.2" +"@esbuild/openbsd-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/openbsd-x64@npm:0.24.0" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/openbsd-x64@npm:0.25.3" conditions: os=openbsd & cpu=x64 languageName: node linkType: hard @@ -2369,9 +3629,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/sunos-x64@npm:0.24.2" +"@esbuild/sunos-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/sunos-x64@npm:0.24.0" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/sunos-x64@npm:0.25.3" conditions: os=sunos & cpu=x64 languageName: node linkType: hard @@ -2383,9 +3650,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/win32-arm64@npm:0.24.2" +"@esbuild/win32-arm64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/win32-arm64@npm:0.24.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/win32-arm64@npm:0.25.3" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -2397,9 +3671,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/win32-ia32@npm:0.24.2" +"@esbuild/win32-ia32@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/win32-ia32@npm:0.24.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/win32-ia32@npm:0.25.3" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard @@ -2411,13 +3692,31 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.24.2": - version: 0.24.2 - resolution: "@esbuild/win32-x64@npm:0.24.2" +"@esbuild/win32-x64@npm:0.24.0": + version: 0.24.0 + resolution: "@esbuild/win32-x64@npm:0.24.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/win32-x64@npm:0.25.3" conditions: os=win32 & cpu=x64 languageName: node linkType: hard +"@eslint-community/eslint-utils@npm:^4.1.2, @eslint-community/eslint-utils@npm:^4.5.0, @eslint-community/eslint-utils@npm:^4.7.0": + version: 4.7.0 + resolution: "@eslint-community/eslint-utils@npm:4.7.0" + dependencies: + eslint-visitor-keys: "npm:^3.4.3" + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + checksum: 10/43ed5d391526d9f5bbe452aef336389a473026fca92057cf97c576db11401ce9bcf8ef0bf72625bbaf6207ed8ba6bf0dcf4d7e809c24f08faa68a28533c491a7 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0": version: 4.4.1 resolution: "@eslint-community/eslint-utils@npm:4.4.1" @@ -2429,36 +3728,52 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.12.1": +"@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.11.0, @eslint-community/regexpp@npm:^4.12.1": version: 4.12.1 resolution: "@eslint-community/regexpp@npm:4.12.1" checksum: 10/c08f1dd7dd18fbb60bdd0d85820656d1374dd898af9be7f82cb00451313402a22d5e30569c150315b4385907cdbca78c22389b2a72ab78883b3173be317620cc languageName: node linkType: hard -"@eslint/config-array@npm:^0.19.0": - version: 0.19.1 - resolution: "@eslint/config-array@npm:0.19.1" +"@eslint/config-array@npm:^0.20.1": + version: 0.20.1 + resolution: "@eslint/config-array@npm:0.20.1" dependencies: - "@eslint/object-schema": "npm:^2.1.5" + "@eslint/object-schema": "npm:^2.1.6" debug: "npm:^4.3.1" minimatch: "npm:^3.1.2" - checksum: 10/1243b01f463de85c970c18f0994f9d1850dafe8cc8c910edb64105d845edd3cacaa0bbf028bf35a6daaf5a179021140b6a8b1dc7a2f915b42c2d35f022a9c201 + checksum: 10/d72cc90f516c5730da5f37fa04aa8ba26ea0d92c7457ee77980902158f844f3483518272ccfe16f273c3313c3bfec8da713d4e51d3da49bdeccd34e919a2b903 languageName: node linkType: hard -"@eslint/core@npm:^0.10.0": - version: 0.10.0 - resolution: "@eslint/core@npm:0.10.0" +"@eslint/config-helpers@npm:^0.2.1": + version: 0.2.3 + resolution: "@eslint/config-helpers@npm:0.2.3" + checksum: 10/1f5082248f65555cc666942f7c991a2cfd6821758fb45338f43b28ea0f6b77d0c48b35097400d9b8fe1b4b10150085452e0b8f2d6d9ba17a84e16a6c7e4b341d + languageName: node + linkType: hard + +"@eslint/core@npm:^0.14.0": + version: 0.14.0 + resolution: "@eslint/core@npm:0.14.0" dependencies: "@types/json-schema": "npm:^7.0.15" - checksum: 10/de41d7fa5dc468b70fb15c72829096939fc0217c41b8519af4620bc1089cb42539a15325c4c3ee3832facac1836c8c944c4a0c4d0cc8b33ffd8e95962278ae14 + checksum: 10/d9b060cf97468150675ddf4fb3db55edaa32467e0adf9f80919a5bfd15d0835ad7765456f4397ec2d16b0a1bb702af63f6d4712f94194d34fea118231ae1e2db languageName: node linkType: hard -"@eslint/eslintrc@npm:^3.1.0, @eslint/eslintrc@npm:^3.2.0": - version: 3.2.0 - resolution: "@eslint/eslintrc@npm:3.2.0" +"@eslint/core@npm:^0.15.0": + version: 0.15.0 + resolution: "@eslint/core@npm:0.15.0" + dependencies: + "@types/json-schema": "npm:^7.0.15" + checksum: 10/27c9cb5bdc5c9dead5b06f2b2a6a66d8bbe5e2e19397e2c5ff9ea582c9d4e4478bf1bc1bdd4eaec7bb3a0d6fa53f152e595acf637354776c14bb58c321ea5aa3 + languageName: node + linkType: hard + +"@eslint/eslintrc@npm:^3.3.1": + version: 3.3.1 + resolution: "@eslint/eslintrc@npm:3.3.1" dependencies: ajv: "npm:^6.12.4" debug: "npm:^4.3.2" @@ -2469,31 +3784,48 @@ __metadata: js-yaml: "npm:^4.1.0" minimatch: "npm:^3.1.2" strip-json-comments: "npm:^3.1.1" - checksum: 10/b32dd90ce7da68e89b88cd729db46b27aac79a2e6cb1fa75d25a6b766d586b443bfbf59622489efbd3c6f696f147b51111e81ec7cd23d70f215c5d474cad0261 + checksum: 10/cc240addbab3c5fceaa65b2c8d5d4fd77ddbbf472c2f74f0270b9d33263dc9116840b6099c46b64c9680301146250439b044ed79278a1bcc557da412a4e3c1bb languageName: node linkType: hard -"@eslint/js@npm:9.19.0, @eslint/js@npm:^9.18.0": - version: 9.19.0 - resolution: "@eslint/js@npm:9.19.0" - checksum: 10/d8133a83330676d5f0827713af2e9bbf35530631a93520fb59ead6b827a325c54fdd7ad99f2158f895fb393c47bbc55dfdaa945998a647f3b9230f1d5324a626 +"@eslint/js@npm:9.29.0": + version: 9.29.0 + resolution: "@eslint/js@npm:9.29.0" + checksum: 10/7f7fd586b35bd08537dd65a9bda764f474350c36b4ccbdd342462d1a26be28f7ee0ebd0611dd4762b69829674336ba04c281b9658aeccb3e6ab1d0fec7e6d08c languageName: node linkType: hard -"@eslint/object-schema@npm:^2.1.5": - version: 2.1.5 - resolution: "@eslint/object-schema@npm:2.1.5" - checksum: 10/bb07ec53357047f20de923bcd61f0306d9eee83ef41daa32e633e154a44796b5bd94670169eccb8fd8cb4ff42228a43b86953a6321f789f98194baba8207b640 +"@eslint/js@npm:^9.18.0": + version: 9.18.0 + resolution: "@eslint/js@npm:9.18.0" + checksum: 10/364a7d030dad9dbda1458d8dbcea0199fe7d48bcfefe4b49389df6c45cdc5a2449f70e5d8a794e46ed9fb34af3fe5a3f53e30020d306b6ee791e2a1b2b9fa25f + languageName: node + linkType: hard + +"@eslint/object-schema@npm:^2.1.6": + version: 2.1.6 + resolution: "@eslint/object-schema@npm:2.1.6" + checksum: 10/266085c8d3fa6cd99457fb6350dffb8ee39db9c6baf28dc2b86576657373c92a568aec4bae7d142978e798b74c271696672e103202d47a0c148da39154351ed6 languageName: node linkType: hard -"@eslint/plugin-kit@npm:^0.2.5": - version: 0.2.5 - resolution: "@eslint/plugin-kit@npm:0.2.5" +"@eslint/plugin-kit@npm:^0.3.1": + version: 0.3.2 + resolution: "@eslint/plugin-kit@npm:0.3.2" dependencies: - "@eslint/core": "npm:^0.10.0" + "@eslint/core": "npm:^0.15.0" levn: "npm:^0.4.1" - checksum: 10/82d0142bc7054587bde4f75c2c517f477df7c320e4bdb47a4d5f766899a313ce65e9ce5d59428178d0be473a95292065053f69637042546b811ad89079781cbc + checksum: 10/26ba99936f72ca124036fbc5ca93168713fab5984117109b1447642a93725fbb75aa457622683dc8797509e40294497d74b584caa26f285373bdde17ceba8eac + languageName: node + linkType: hard + +"@ethereumjs/common@npm:2.6.2": + version: 2.6.2 + resolution: "@ethereumjs/common@npm:2.6.2" + dependencies: + crc-32: "npm:^1.2.0" + ethereumjs-util: "npm:^7.1.4" + checksum: 10/9bbdb9b7919480a59f3e6e0d6f0d6329912cf6fd64121666526d06de42730c5b0049871cc2c1085a5f8a8bd2c2d1219f500e616bbe65f6c3556180e533e530be languageName: node linkType: hard @@ -2515,19 +3847,19 @@ __metadata: linkType: hard "@ethersproject/abi@npm:^5.5.0": - version: 5.7.0 - resolution: "@ethersproject/abi@npm:5.7.0" + version: 5.8.0 + resolution: "@ethersproject/abi@npm:5.8.0" dependencies: - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/constants": "npm:^5.7.0" - "@ethersproject/hash": "npm:^5.7.0" - "@ethersproject/keccak256": "npm:^5.7.0" - "@ethersproject/logger": "npm:^5.7.0" - "@ethersproject/properties": "npm:^5.7.0" - "@ethersproject/strings": "npm:^5.7.0" - checksum: 10/6ed002cbc61a7e21bc0182702345659c1984f6f8e6bad166e43aee76ea8f74766dd0f6236574a868e1b4600af27972bf25b973fae7877ae8da3afa90d3965cac + "@ethersproject/address": "npm:^5.8.0" + "@ethersproject/bignumber": "npm:^5.8.0" + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/constants": "npm:^5.8.0" + "@ethersproject/hash": "npm:^5.8.0" + "@ethersproject/keccak256": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + "@ethersproject/properties": "npm:^5.8.0" + "@ethersproject/strings": "npm:^5.8.0" + checksum: 10/a63ebc2c8ea795ceca5289abaf817bb402c83c330cffd0ae2d355be70c54050a21ddd408abd4fd0dce4c3fd5c5f091707be2095011c233022a52f2110e7012d6 languageName: node linkType: hard @@ -2546,7 +3878,22 @@ __metadata: languageName: node linkType: hard -"@ethersproject/abstract-provider@npm:^5.5.0, @ethersproject/abstract-provider@npm:^5.7.0": +"@ethersproject/abstract-provider@npm:^5.5.0, @ethersproject/abstract-provider@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/abstract-provider@npm:5.8.0" + dependencies: + "@ethersproject/bignumber": "npm:^5.8.0" + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + "@ethersproject/networks": "npm:^5.8.0" + "@ethersproject/properties": "npm:^5.8.0" + "@ethersproject/transactions": "npm:^5.8.0" + "@ethersproject/web": "npm:^5.8.0" + checksum: 10/2066aa717c7ecf0b6defe47f4f0af21943ee76e47f6fdc461d89b15d8af76c37d25355b4f5d635ed30e7378eafb0599b283df8ef9133cef389d938946874200d + languageName: node + linkType: hard + +"@ethersproject/abstract-provider@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/abstract-provider@npm:5.7.0" dependencies: @@ -2574,7 +3921,20 @@ __metadata: languageName: node linkType: hard -"@ethersproject/abstract-signer@npm:^5.5.0, @ethersproject/abstract-signer@npm:^5.7.0": +"@ethersproject/abstract-signer@npm:^5.5.0, @ethersproject/abstract-signer@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/abstract-signer@npm:5.8.0" + dependencies: + "@ethersproject/abstract-provider": "npm:^5.8.0" + "@ethersproject/bignumber": "npm:^5.8.0" + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + "@ethersproject/properties": "npm:^5.8.0" + checksum: 10/10986eb1520dd94efb34bc19de4f53a49bea023493a0df686711872eb2cb446f3cca3c98c1ecec7831497004822e16ead756d6c7d6977971eaa780f4d41db327 + languageName: node + linkType: hard + +"@ethersproject/abstract-signer@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/abstract-signer@npm:5.7.0" dependencies: @@ -2600,7 +3960,20 @@ __metadata: languageName: node linkType: hard -"@ethersproject/address@npm:^5.5.0, @ethersproject/address@npm:^5.7.0": +"@ethersproject/address@npm:^5.5.0, @ethersproject/address@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/address@npm:5.8.0" + dependencies: + "@ethersproject/bignumber": "npm:^5.8.0" + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/keccak256": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + "@ethersproject/rlp": "npm:^5.8.0" + checksum: 10/4b8ef5b3001f065fae571d86f113395d0dd081a2f411c99e354da912d4138e14a1fbe206265725daeb55c4e735ddb761891b58779208c5e2acec03f3219ce6ef + languageName: node + linkType: hard + +"@ethersproject/address@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/address@npm:5.7.0" dependencies: @@ -2622,7 +3995,16 @@ __metadata: languageName: node linkType: hard -"@ethersproject/base64@npm:^5.5.0, @ethersproject/base64@npm:^5.7.0": +"@ethersproject/base64@npm:^5.5.0, @ethersproject/base64@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/base64@npm:5.8.0" + dependencies: + "@ethersproject/bytes": "npm:^5.8.0" + checksum: 10/c83e4ee01a1e69d874277d05c0e3fbc2afcdb9c80507be6963d31c77e505e355191cbba2d8fecf1c922b68c1ff072ede7914981fd965f1d8771c5b0706beb911 + languageName: node + linkType: hard + +"@ethersproject/base64@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/base64@npm:5.7.0" dependencies: @@ -2641,13 +4023,13 @@ __metadata: languageName: node linkType: hard -"@ethersproject/basex@npm:^5.5.0, @ethersproject/basex@npm:^5.7.0": - version: 5.7.0 - resolution: "@ethersproject/basex@npm:5.7.0" +"@ethersproject/basex@npm:^5.5.0, @ethersproject/basex@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/basex@npm:5.8.0" dependencies: - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/properties": "npm:^5.7.0" - checksum: 10/840e333e109bff2fcf8d91dcfd45fa951835844ef0e1ba710037e87291c7b5f3c189ba86f6cee2ca7de2ede5b7d59fbb930346607695855bee20d2f9f63371ef + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/properties": "npm:^5.8.0" + checksum: 10/1a8d48a9397461ea42ec43b69a15a0d13ba0b9192695713750d9d391503c55b258cca435fa78a4014d23a813053f1a471593b89c7c0d89351639a78d50a12ef2 languageName: node linkType: hard @@ -2662,7 +4044,18 @@ __metadata: languageName: node linkType: hard -"@ethersproject/bignumber@npm:^5.5.0, @ethersproject/bignumber@npm:^5.7.0": +"@ethersproject/bignumber@npm:^5.5.0, @ethersproject/bignumber@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/bignumber@npm:5.8.0" + dependencies: + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + bn.js: "npm:^5.2.1" + checksum: 10/15538ba9eef8475bc14a2a2bb5f0d7ae8775cf690283cb4c7edc836761a4310f83d67afe33f6d0b8befd896b10f878d8ca79b89de6e6ebd41a9e68375ec77123 + languageName: node + linkType: hard + +"@ethersproject/bignumber@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/bignumber@npm:5.7.0" dependencies: @@ -2682,7 +4075,16 @@ __metadata: languageName: node linkType: hard -"@ethersproject/bytes@npm:^5.5.0, @ethersproject/bytes@npm:^5.7.0": +"@ethersproject/bytes@npm:^5.5.0, @ethersproject/bytes@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/bytes@npm:5.8.0" + dependencies: + "@ethersproject/logger": "npm:^5.8.0" + checksum: 10/b8956aa4f607d326107cec522a881effed62585d5b5c5ad66ada4f7f83b42fd6c6acb76f355ec7a57e4cadea62a0194e923f4b5142d50129fe03d2fe7fc664f8 + languageName: node + linkType: hard + +"@ethersproject/bytes@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/bytes@npm:5.7.0" dependencies: @@ -2700,7 +4102,16 @@ __metadata: languageName: node linkType: hard -"@ethersproject/constants@npm:^5.5.0, @ethersproject/constants@npm:^5.7.0": +"@ethersproject/constants@npm:^5.5.0, @ethersproject/constants@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/constants@npm:5.8.0" + dependencies: + "@ethersproject/bignumber": "npm:^5.8.0" + checksum: 10/74830c44f4315a1058b905c73be7a9bb92850e45213cb28a957447b8a100f22a514f4500b0ea5ac7a995427cecef9918af39ae4e0e0ecf77aa4835b1ea5c3432 + languageName: node + linkType: hard + +"@ethersproject/constants@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/constants@npm:5.7.0" dependencies: @@ -2743,7 +4154,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/hash@npm:5.7.0, @ethersproject/hash@npm:^5.5.0, @ethersproject/hash@npm:^5.7.0": +"@ethersproject/hash@npm:5.7.0": version: 5.7.0 resolution: "@ethersproject/hash@npm:5.7.0" dependencies: @@ -2760,6 +4171,23 @@ __metadata: languageName: node linkType: hard +"@ethersproject/hash@npm:^5.5.0, @ethersproject/hash@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/hash@npm:5.8.0" + dependencies: + "@ethersproject/abstract-signer": "npm:^5.8.0" + "@ethersproject/address": "npm:^5.8.0" + "@ethersproject/base64": "npm:^5.8.0" + "@ethersproject/bignumber": "npm:^5.8.0" + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/keccak256": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + "@ethersproject/properties": "npm:^5.8.0" + "@ethersproject/strings": "npm:^5.8.0" + checksum: 10/a355cc1120b51c5912d960c66e2d1e2fb9cceca7d02e48c3812abd32ac2480035d8345885f129d2ed1cde9fb044adad1f98e4ea39652fa96c5de9c2720e83d28 + languageName: node + linkType: hard + "@ethersproject/hdnode@npm:5.5.0": version: 5.5.0 resolution: "@ethersproject/hdnode@npm:5.5.0" @@ -2780,23 +4208,23 @@ __metadata: languageName: node linkType: hard -"@ethersproject/hdnode@npm:^5.5.0, @ethersproject/hdnode@npm:^5.7.0": - version: 5.7.0 - resolution: "@ethersproject/hdnode@npm:5.7.0" +"@ethersproject/hdnode@npm:^5.5.0, @ethersproject/hdnode@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/hdnode@npm:5.8.0" dependencies: - "@ethersproject/abstract-signer": "npm:^5.7.0" - "@ethersproject/basex": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/logger": "npm:^5.7.0" - "@ethersproject/pbkdf2": "npm:^5.7.0" - "@ethersproject/properties": "npm:^5.7.0" - "@ethersproject/sha2": "npm:^5.7.0" - "@ethersproject/signing-key": "npm:^5.7.0" - "@ethersproject/strings": "npm:^5.7.0" - "@ethersproject/transactions": "npm:^5.7.0" - "@ethersproject/wordlists": "npm:^5.7.0" - checksum: 10/2fbe6278c324235afaa88baa5dea24d8674c72b14ad037fe2096134d41025977f410b04fd146e333a1b6cac9482e9de62d6375d1705fd42667543f2d0eb66655 + "@ethersproject/abstract-signer": "npm:^5.8.0" + "@ethersproject/basex": "npm:^5.8.0" + "@ethersproject/bignumber": "npm:^5.8.0" + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + "@ethersproject/pbkdf2": "npm:^5.8.0" + "@ethersproject/properties": "npm:^5.8.0" + "@ethersproject/sha2": "npm:^5.8.0" + "@ethersproject/signing-key": "npm:^5.8.0" + "@ethersproject/strings": "npm:^5.8.0" + "@ethersproject/transactions": "npm:^5.8.0" + "@ethersproject/wordlists": "npm:^5.8.0" + checksum: 10/55b35cf30f0dd40e2d5ecd4b2f005ebea82a85a440717a61d4a483074f652d2c7063e9c704272b894bfdd500f7883aa36692931c6808591f702c1da7107ebb61 languageName: node linkType: hard @@ -2822,23 +4250,23 @@ __metadata: linkType: hard "@ethersproject/json-wallets@npm:^5.5.0": - version: 5.7.0 - resolution: "@ethersproject/json-wallets@npm:5.7.0" - dependencies: - "@ethersproject/abstract-signer": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/hdnode": "npm:^5.7.0" - "@ethersproject/keccak256": "npm:^5.7.0" - "@ethersproject/logger": "npm:^5.7.0" - "@ethersproject/pbkdf2": "npm:^5.7.0" - "@ethersproject/properties": "npm:^5.7.0" - "@ethersproject/random": "npm:^5.7.0" - "@ethersproject/strings": "npm:^5.7.0" - "@ethersproject/transactions": "npm:^5.7.0" + version: 5.8.0 + resolution: "@ethersproject/json-wallets@npm:5.8.0" + dependencies: + "@ethersproject/abstract-signer": "npm:^5.8.0" + "@ethersproject/address": "npm:^5.8.0" + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/hdnode": "npm:^5.8.0" + "@ethersproject/keccak256": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + "@ethersproject/pbkdf2": "npm:^5.8.0" + "@ethersproject/properties": "npm:^5.8.0" + "@ethersproject/random": "npm:^5.8.0" + "@ethersproject/strings": "npm:^5.8.0" + "@ethersproject/transactions": "npm:^5.8.0" aes-js: "npm:3.0.0" scrypt-js: "npm:3.0.1" - checksum: 10/4a1ef0912ffc8d18c392ae4e292948d86bffd715fe3dd3e66d1cd21f6c9267aeadad4da84261db853327f97cdfd765a377f9a87e39d4c6749223a69226faf0a1 + checksum: 10/5cbf7e698ee7f26f54fceb672d9824b01816cd785182e638cb5cd1eaed5d80d8a4576e3cad92af46ac6d23404a806a47a72d5dee908af42322d091553a0d8da6 languageName: node linkType: hard @@ -2852,7 +4280,17 @@ __metadata: languageName: node linkType: hard -"@ethersproject/keccak256@npm:^5.5.0, @ethersproject/keccak256@npm:^5.7.0": +"@ethersproject/keccak256@npm:^5.5.0, @ethersproject/keccak256@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/keccak256@npm:5.8.0" + dependencies: + "@ethersproject/bytes": "npm:^5.8.0" + js-sha3: "npm:0.8.0" + checksum: 10/af3621d2b18af6c8f5181dacad91e1f6da4e8a6065668b20e4c24684bdb130b31e45e0d4dbaed86d4f1314d01358aa119f05be541b696e455424c47849d81913 + languageName: node + linkType: hard + +"@ethersproject/keccak256@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/keccak256@npm:5.7.0" dependencies: @@ -2869,8 +4307,15 @@ __metadata: languageName: node linkType: hard -"@ethersproject/logger@npm:^5.5.0, @ethersproject/logger@npm:^5.7.0": - version: 5.7.0 +"@ethersproject/logger@npm:^5.5.0, @ethersproject/logger@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/logger@npm:5.8.0" + checksum: 10/dab862d6cc3a4312f4c49d62b4a603f4b60707da8b8ff0fee6bdfee3cbed48b34ec8f23fedfef04dd3d24f2fa2d7ad2be753c775aa00fe24dcd400631d65004a + languageName: node + linkType: hard + +"@ethersproject/logger@npm:^5.7.0": + version: 5.7.0 resolution: "@ethersproject/logger@npm:5.7.0" checksum: 10/683a939f467ae7510deedc23d7611d0932c3046137f5ffb92ba1e3c8cd9cf2fbbaa676b660c248441a0fa9143783137c46d6e6d17d676188dd5a6ef0b72dd091 languageName: node @@ -2885,7 +4330,16 @@ __metadata: languageName: node linkType: hard -"@ethersproject/networks@npm:^5.5.0, @ethersproject/networks@npm:^5.7.0": +"@ethersproject/networks@npm:^5.5.0, @ethersproject/networks@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/networks@npm:5.8.0" + dependencies: + "@ethersproject/logger": "npm:^5.8.0" + checksum: 10/8e2f4c3fd3a701ebd3d767a5f3217f8ced45a9f8ebf830c73b2dd87107dd50777f4869c3c9cc946698e2c597d3fe53eadeec55d19af7769c7d6bdb4a1493fb6f + languageName: node + linkType: hard + +"@ethersproject/networks@npm:^5.7.0": version: 5.7.1 resolution: "@ethersproject/networks@npm:5.7.1" dependencies: @@ -2904,13 +4358,13 @@ __metadata: languageName: node linkType: hard -"@ethersproject/pbkdf2@npm:^5.5.0, @ethersproject/pbkdf2@npm:^5.7.0": - version: 5.7.0 - resolution: "@ethersproject/pbkdf2@npm:5.7.0" +"@ethersproject/pbkdf2@npm:^5.5.0, @ethersproject/pbkdf2@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/pbkdf2@npm:5.8.0" dependencies: - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/sha2": "npm:^5.7.0" - checksum: 10/dea7ba747805e24b81dfb99e695eb329509bf5cad1a42e48475ade28e060e567458a3d5bf930f302691bded733fd3fa364f0c7adce920f9f05a5ef8c13267aaa + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/sha2": "npm:^5.8.0" + checksum: 10/203bb992eec3042256702f4c8259a37202af7b341cc6e370614cdc52541042fc3b795fb040592bd6be8b67376a798c45312ca1e6d5d179c3e8eb7431882f1fd1 languageName: node linkType: hard @@ -2923,7 +4377,16 @@ __metadata: languageName: node linkType: hard -"@ethersproject/properties@npm:^5.5.0, @ethersproject/properties@npm:^5.7.0": +"@ethersproject/properties@npm:^5.5.0, @ethersproject/properties@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/properties@npm:5.8.0" + dependencies: + "@ethersproject/logger": "npm:^5.8.0" + checksum: 10/3bc1af678c1cf7c87f39aec24b1d86cfaa5da1f9f54e426558701fff1c088c1dcc9ec3e1f395e138bdfcda94a0161e7192f0596e11c8ff25d31735e6b33edc59 + languageName: node + linkType: hard + +"@ethersproject/properties@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/properties@npm:5.7.0" dependencies: @@ -2932,9 +4395,9 @@ __metadata: languageName: node linkType: hard -"@ethersproject/providers@npm:5.5.2": - version: 5.5.2 - resolution: "@ethersproject/providers@npm:5.5.2" +"@ethersproject/providers@npm:5.5.3": + version: 5.5.3 + resolution: "@ethersproject/providers@npm:5.5.3" dependencies: "@ethersproject/abstract-provider": "npm:^5.5.0" "@ethersproject/abstract-signer": "npm:^5.5.0" @@ -2955,7 +4418,7 @@ __metadata: "@ethersproject/web": "npm:^5.5.0" bech32: "npm:1.1.4" ws: "npm:7.4.6" - checksum: 10/25d7f15b0a9eb72410f90ce06cffd887c11c8abfa9699895defdb52424836af52b689c31ff13bd413c932f1ddf4bb21143739fdbf753360fe60c3c018ba3a83b + checksum: 10/d8f1b83369b1fa98bc22891cfe2ae1122b270b40c1991ecf03c1af86c7a4b0d5812c39a72e9795ab6961639e796c1dfb3c3edf5bee96654c9e0a36c8cbbc5955 languageName: node linkType: hard @@ -2969,13 +4432,13 @@ __metadata: languageName: node linkType: hard -"@ethersproject/random@npm:^5.5.0, @ethersproject/random@npm:^5.7.0": - version: 5.7.0 - resolution: "@ethersproject/random@npm:5.7.0" +"@ethersproject/random@npm:^5.5.0, @ethersproject/random@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/random@npm:5.8.0" dependencies: - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/logger": "npm:^5.7.0" - checksum: 10/c23ec447998ce1147651bd58816db4d12dbeb404f66a03d14a13e1edb439879bab18528e1fc46b931502903ac7b1c08ea61d6a86e621a6e060fa63d41aeed3ac + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + checksum: 10/47c34a72c81183ac13a1b4635bb9d5cf1456e6329276f50c9e12711f404a9eb4536db824537ed05ef8839a0a358883dc3342d3ea83147b8bafeb767dc8f57e23 languageName: node linkType: hard @@ -2989,7 +4452,17 @@ __metadata: languageName: node linkType: hard -"@ethersproject/rlp@npm:^5.5.0, @ethersproject/rlp@npm:^5.7.0": +"@ethersproject/rlp@npm:^5.5.0, @ethersproject/rlp@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/rlp@npm:5.8.0" + dependencies: + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + checksum: 10/353f04618f44c822d20da607b055286b3374fc6ab9fc50b416140f21e410f6d6e89ff9d951bef667b8baf1314e2d5f0b47c5615c3f994a2c8b2d6c01c6329bb4 + languageName: node + linkType: hard + +"@ethersproject/rlp@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/rlp@npm:5.7.0" dependencies: @@ -3010,14 +4483,14 @@ __metadata: languageName: node linkType: hard -"@ethersproject/sha2@npm:^5.5.0, @ethersproject/sha2@npm:^5.7.0": - version: 5.7.0 - resolution: "@ethersproject/sha2@npm:5.7.0" +"@ethersproject/sha2@npm:^5.5.0, @ethersproject/sha2@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/sha2@npm:5.8.0" dependencies: - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" hash.js: "npm:1.1.7" - checksum: 10/09321057c022effbff4cc2d9b9558228690b5dd916329d75c4b1ffe32ba3d24b480a367a7cc92d0f0c0b1c896814d03351ae4630e2f1f7160be2bcfbde435dbc + checksum: 10/ef8916e3033502476fba9358ba1993722ac3bb99e756d5681e4effa3dfa0f0bf0c29d3fa338662830660b45dd359cccb06ba40bc7b62cfd44f4a177b25829404 languageName: node linkType: hard @@ -3042,7 +4515,21 @@ __metadata: languageName: node linkType: hard -"@ethersproject/signing-key@npm:^5.5.0, @ethersproject/signing-key@npm:^5.7.0": +"@ethersproject/signing-key@npm:^5.5.0, @ethersproject/signing-key@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/signing-key@npm:5.8.0" + dependencies: + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + "@ethersproject/properties": "npm:^5.8.0" + bn.js: "npm:^5.2.1" + elliptic: "npm:6.6.1" + hash.js: "npm:1.1.7" + checksum: 10/07e5893bf9841e1d608c52b58aa240ed10c7aa01613ff45b15c312c1403887baa8ed543871721052d7b7dd75d80b1fa90945377b231d18ccb6986c6677c8315d + languageName: node + linkType: hard + +"@ethersproject/signing-key@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/signing-key@npm:5.7.0" dependencies: @@ -3081,7 +4568,18 @@ __metadata: languageName: node linkType: hard -"@ethersproject/strings@npm:^5.5.0, @ethersproject/strings@npm:^5.7.0": +"@ethersproject/strings@npm:^5.5.0, @ethersproject/strings@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/strings@npm:5.8.0" + dependencies: + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/constants": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + checksum: 10/536264dad4b9ad42d8287be7b7a9f3e243d0172fafa459e22af2d416eb6fe6a46ff623ca5456457f841dec4b080939da03ed02ab9774dcd1f2391df9ef5a96bb + languageName: node + linkType: hard + +"@ethersproject/strings@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/strings@npm:5.7.0" dependencies: @@ -3109,7 +4607,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/transactions@npm:5.7.0, @ethersproject/transactions@npm:^5.5.0, @ethersproject/transactions@npm:^5.7.0": +"@ethersproject/transactions@npm:5.7.0, @ethersproject/transactions@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/transactions@npm:5.7.0" dependencies: @@ -3126,6 +4624,23 @@ __metadata: languageName: node linkType: hard +"@ethersproject/transactions@npm:^5.5.0, @ethersproject/transactions@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/transactions@npm:5.8.0" + dependencies: + "@ethersproject/address": "npm:^5.8.0" + "@ethersproject/bignumber": "npm:^5.8.0" + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/constants": "npm:^5.8.0" + "@ethersproject/keccak256": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + "@ethersproject/properties": "npm:^5.8.0" + "@ethersproject/rlp": "npm:^5.8.0" + "@ethersproject/signing-key": "npm:^5.8.0" + checksum: 10/b43fd97ee359154c9162037c7aedc23abafae3cedf78d8fd2e641e820a0443120d22c473ec9bb79e8301f179f61a6120d61b0b757560e3aad8ae2110127018ba + languageName: node + linkType: hard + "@ethersproject/units@npm:5.5.0": version: 5.5.0 resolution: "@ethersproject/units@npm:5.5.0" @@ -3173,7 +4688,20 @@ __metadata: languageName: node linkType: hard -"@ethersproject/web@npm:^5.5.0, @ethersproject/web@npm:^5.7.0": +"@ethersproject/web@npm:^5.5.0, @ethersproject/web@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/web@npm:5.8.0" + dependencies: + "@ethersproject/base64": "npm:^5.8.0" + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + "@ethersproject/properties": "npm:^5.8.0" + "@ethersproject/strings": "npm:^5.8.0" + checksum: 10/93aad7041ffae7a4f881cc8df3356a297d736b50e6e48952b3b76e547b83e4d9189bbf2f417543031e91e74568c54395d1bb43c3252c3adf4f7e1c0187012912 + languageName: node + linkType: hard + +"@ethersproject/web@npm:^5.7.0": version: 5.7.1 resolution: "@ethersproject/web@npm:5.7.1" dependencies: @@ -3199,16 +4727,16 @@ __metadata: languageName: node linkType: hard -"@ethersproject/wordlists@npm:^5.5.0, @ethersproject/wordlists@npm:^5.7.0": - version: 5.7.0 - resolution: "@ethersproject/wordlists@npm:5.7.0" +"@ethersproject/wordlists@npm:^5.5.0, @ethersproject/wordlists@npm:^5.8.0": + version: 5.8.0 + resolution: "@ethersproject/wordlists@npm:5.8.0" dependencies: - "@ethersproject/bytes": "npm:^5.7.0" - "@ethersproject/hash": "npm:^5.7.0" - "@ethersproject/logger": "npm:^5.7.0" - "@ethersproject/properties": "npm:^5.7.0" - "@ethersproject/strings": "npm:^5.7.0" - checksum: 10/737fca67ad743a32020f50f5b9e147e5683cfba2692367c1124a5a5538be78515865257b426ec9141daac91a70295e5e21bef7a193b79fe745f1be378562ccaa + "@ethersproject/bytes": "npm:^5.8.0" + "@ethersproject/hash": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + "@ethersproject/properties": "npm:^5.8.0" + "@ethersproject/strings": "npm:^5.8.0" + checksum: 10/b8e6aa7d2195bb568847f360f6525ddc3d145404fbd4553e2e05daf4a95f58167591feb69e16e3398a28114ea85e1895fc8f5bd1c0cbf8b578123d7c1d21c32d languageName: node linkType: hard @@ -3219,62 +4747,46 @@ __metadata: languageName: node linkType: hard -"@expo/bunyan@npm:^4.0.0": - version: 4.0.1 - resolution: "@expo/bunyan@npm:4.0.1" - dependencies: - uuid: "npm:^8.0.0" - checksum: 10/22d656b07967e9112c13d3d7432c73f19b777ea31f7bccbc558d59b9f7d9c81a8d94036f9b6e8665abfeb57409107fd61f05e9072d57b12c1087b77b05accbb7 - languageName: node - linkType: hard - -"@expo/cli@npm:0.22.3": - version: 0.22.3 - resolution: "@expo/cli@npm:0.22.3" +"@expo/cli@npm:0.24.11": + version: 0.24.11 + resolution: "@expo/cli@npm:0.24.11" dependencies: "@0no-co/graphql.web": "npm:^1.0.8" "@babel/runtime": "npm:^7.20.0" "@expo/code-signing-certificates": "npm:^0.0.5" - "@expo/config": "npm:~10.0.4" - "@expo/config-plugins": "npm:~9.0.10" + "@expo/config": "npm:~11.0.8" + "@expo/config-plugins": "npm:~10.0.2" "@expo/devcert": "npm:^1.1.2" - "@expo/env": "npm:~0.4.0" - "@expo/image-utils": "npm:^0.6.0" - "@expo/json-file": "npm:^9.0.0" - "@expo/metro-config": "npm:~0.19.0" - "@expo/osascript": "npm:^2.0.31" - "@expo/package-manager": "npm:^1.5.0" - "@expo/plist": "npm:^0.2.0" - "@expo/prebuild-config": "npm:^8.0.22" - "@expo/rudder-sdk-node": "npm:^1.1.1" + "@expo/env": "npm:~1.0.5" + "@expo/image-utils": "npm:^0.7.4" + "@expo/json-file": "npm:^9.1.4" + "@expo/metro-config": "npm:~0.20.13" + "@expo/osascript": "npm:^2.2.4" + "@expo/package-manager": "npm:^1.8.4" + "@expo/plist": "npm:^0.3.4" + "@expo/prebuild-config": "npm:^9.0.5" "@expo/spawn-async": "npm:^1.7.2" + "@expo/ws-tunnel": "npm:^1.0.1" "@expo/xcpretty": "npm:^4.3.0" - "@react-native/dev-middleware": "npm:0.76.3" + "@react-native/dev-middleware": "npm:0.79.2" "@urql/core": "npm:^5.0.6" "@urql/exchange-retry": "npm:^1.3.0" accepts: "npm:^1.3.8" arg: "npm:^5.0.2" better-opn: "npm:~3.0.2" - bplist-creator: "npm:0.0.7" + bplist-creator: "npm:0.1.0" bplist-parser: "npm:^0.3.1" - cacache: "npm:^18.0.2" chalk: "npm:^4.0.0" ci-info: "npm:^3.3.0" compression: "npm:^1.7.4" connect: "npm:^3.7.0" debug: "npm:^4.3.4" env-editor: "npm:^0.4.1" - fast-glob: "npm:^3.3.2" - form-data: "npm:^3.0.1" freeport-async: "npm:^2.0.0" - fs-extra: "npm:~8.1.0" getenv: "npm:^1.0.0" glob: "npm:^10.4.2" - internal-ip: "npm:^4.3.0" - is-docker: "npm:^2.0.0" - is-wsl: "npm:^2.1.1" - lodash.debounce: "npm:^4.0.8" - minimatch: "npm:^3.0.4" + lan-network: "npm:^0.1.4" + minimatch: "npm:^9.0.0" node-forge: "npm:^1.3.1" npm-package-arg: "npm:^11.0.0" ora: "npm:^3.4.0" @@ -3288,24 +4800,21 @@ __metadata: requireg: "npm:^0.2.2" resolve: "npm:^1.22.2" resolve-from: "npm:^5.0.0" - resolve.exports: "npm:^2.0.2" + resolve.exports: "npm:^2.0.3" semver: "npm:^7.6.0" send: "npm:^0.19.0" slugify: "npm:^1.3.4" source-map-support: "npm:~0.5.21" stacktrace-parser: "npm:^0.1.10" structured-headers: "npm:^0.4.1" - tar: "npm:^6.2.1" - temp-dir: "npm:^2.0.0" - tempy: "npm:^0.7.1" + tar: "npm:^7.4.3" terminal-link: "npm:^2.1.1" undici: "npm:^6.18.2" - unique-string: "npm:~2.0.0" wrap-ansi: "npm:^7.0.0" ws: "npm:^8.12.1" bin: expo-internal: build/bin/cli - checksum: 10/67ad931ebdb185a9686a53a3389dfdc93765c8124b0e9cdbd1b00e5b2dbc0e3005be487ae02552040f462754f1c59840d127fa08086d3a75785ff368efb1abaa + checksum: 10/2bba576daf356858f5ef8bbb4c325677549e48739f81f058fb28636a7da0d213cc5efaa77061534371d31dd4e9ec401cf3596de88a6801a395ec29f255476931 languageName: node linkType: hard @@ -3319,13 +4828,13 @@ __metadata: languageName: node linkType: hard -"@expo/config-plugins@npm:^9.0.10, @expo/config-plugins@npm:~9.0.10, @expo/config-plugins@npm:~9.0.12, @expo/config-plugins@npm:~9.0.14": - version: 9.0.14 - resolution: "@expo/config-plugins@npm:9.0.14" +"@expo/config-plugins@npm:~10.0.0, @expo/config-plugins@npm:~10.0.2": + version: 10.0.2 + resolution: "@expo/config-plugins@npm:10.0.2" dependencies: - "@expo/config-types": "npm:^52.0.3" - "@expo/json-file": "npm:~9.0.1" - "@expo/plist": "npm:^0.2.1" + "@expo/config-types": "npm:^53.0.3" + "@expo/json-file": "npm:~9.1.4" + "@expo/plist": "npm:^0.3.4" "@expo/sdk-runtime-versions": "npm:^1.0.0" chalk: "npm:^4.1.2" debug: "npm:^4.3.5" @@ -3337,25 +4846,103 @@ __metadata: slugify: "npm:^1.6.6" xcode: "npm:^3.0.1" xml2js: "npm:0.6.0" - checksum: 10/7f8f88cc9b5000693b6eb719818aefc0fc7b0dcec90f330cfa3d62643b3a4feb9bcd230d61a3e7227ef31caa7746c856e7587fc7703f2011f7146e1ca7faac2e + checksum: 10/0ebf7ff4b697098ed676aff78de68c937abc4761c2c8cb0aae037bba3fa387214d6d52515e071c08a21cc09f4e5c0b7bc9d84bf179c7f3b1ffecd5db7be062db + languageName: node + linkType: hard + +"@expo/config-plugins@npm:~10.1.1": + version: 10.1.1 + resolution: "@expo/config-plugins@npm:10.1.1" + dependencies: + "@expo/config-types": "npm:^53.0.5" + "@expo/json-file": "npm:~9.1.5" + "@expo/plist": "npm:^0.3.5" + "@expo/sdk-runtime-versions": "npm:^1.0.0" + chalk: "npm:^4.1.2" + debug: "npm:^4.3.5" + getenv: "npm:^2.0.0" + glob: "npm:^10.4.2" + resolve-from: "npm:^5.0.0" + semver: "npm:^7.5.4" + slash: "npm:^3.0.0" + slugify: "npm:^1.6.6" + xcode: "npm:^3.0.1" + xml2js: "npm:0.6.0" + checksum: 10/fd73516bb820a681acc144299f26a0769691e2f7e4abfee465faf39dd7883258511738f4526dcaee0abaf00058aff4b803513cbee315723e62c642fec5ed425c + languageName: node + linkType: hard + +"@expo/config-types@npm:^53.0.3": + version: 53.0.3 + resolution: "@expo/config-types@npm:53.0.3" + checksum: 10/e1521e97083f0b1ddc5de373d7e68de0c83dbbba278e2bc484b5d4cb5bd55c9d3907de2b1a06922fa8c620b80b6f17938f00b6c2bdf353aca6283b0d55137572 + languageName: node + linkType: hard + +"@expo/config-types@npm:^53.0.4": + version: 53.0.4 + resolution: "@expo/config-types@npm:53.0.4" + checksum: 10/536395517b132db489e3a046061f70065f4139bfff39116d129861c69b0d789e8089cca9f4a48b9ffc44991b9f12ae1cd03cc0707a9c4c0e3319c8a4c2b9fc8b + languageName: node + linkType: hard + +"@expo/config-types@npm:^53.0.5": + version: 53.0.5 + resolution: "@expo/config-types@npm:53.0.5" + checksum: 10/71971858185b6163459271734903258c9cdd26a0ffc9775d038f37ebb71ab07153494b0157b96eed03600789592862458e81dfbcc8ef440d28fdcf965f0ba012 + languageName: node + linkType: hard + +"@expo/config@npm:~11.0.10": + version: 11.0.10 + resolution: "@expo/config@npm:11.0.10" + dependencies: + "@babel/code-frame": "npm:~7.10.4" + "@expo/config-plugins": "npm:~10.0.2" + "@expo/config-types": "npm:^53.0.4" + "@expo/json-file": "npm:^9.1.4" + deepmerge: "npm:^4.3.1" + getenv: "npm:^1.0.0" + glob: "npm:^10.4.2" + require-from-string: "npm:^2.0.2" + resolve-from: "npm:^5.0.0" + resolve-workspace-root: "npm:^2.0.0" + semver: "npm:^7.6.0" + slugify: "npm:^1.3.4" + sucrase: "npm:3.35.0" + checksum: 10/488c5c8e249d9f554b59a5ae72917613471653b3fee2e9623e7f91ea9fbb64675ecfe9270dcd5f8193fac14000f8e6b00386605296cc5626041ec49ccd30053b languageName: node linkType: hard -"@expo/config-types@npm:^52.0.0, @expo/config-types@npm:^52.0.3": - version: 52.0.3 - resolution: "@expo/config-types@npm:52.0.3" - checksum: 10/4dffe9533003db794f3f453c9acff9a7acc68fb4623c7f81b098f5fdc28d6d0befb421eda083760dbfc16d90ecaea991156c1a6cc9a2e59d46218586095c9e78 +"@expo/config@npm:~11.0.12": + version: 11.0.12 + resolution: "@expo/config@npm:11.0.12" + dependencies: + "@babel/code-frame": "npm:~7.10.4" + "@expo/config-plugins": "npm:~10.1.1" + "@expo/config-types": "npm:^53.0.5" + "@expo/json-file": "npm:^9.1.5" + deepmerge: "npm:^4.3.1" + getenv: "npm:^2.0.0" + glob: "npm:^10.4.2" + require-from-string: "npm:^2.0.2" + resolve-from: "npm:^5.0.0" + resolve-workspace-root: "npm:^2.0.0" + semver: "npm:^7.6.0" + slugify: "npm:^1.3.4" + sucrase: "npm:3.35.0" + checksum: 10/91b84f7426a8ec5fb8fa71a2400ad725b4d4f5c13d223245bd2913440b1143006a4ee6a5f2d77d37bcb7d26391cb4b38082f06f7bff13e759a79da81f902b200 languageName: node linkType: hard -"@expo/config@npm:~10.0.4, @expo/config@npm:~10.0.6, @expo/config@npm:~10.0.8": - version: 10.0.8 - resolution: "@expo/config@npm:10.0.8" +"@expo/config@npm:~11.0.6, @expo/config@npm:~11.0.7, @expo/config@npm:~11.0.8": + version: 11.0.8 + resolution: "@expo/config@npm:11.0.8" dependencies: "@babel/code-frame": "npm:~7.10.4" - "@expo/config-plugins": "npm:~9.0.14" - "@expo/config-types": "npm:^52.0.3" - "@expo/json-file": "npm:^9.0.1" + "@expo/config-plugins": "npm:~10.0.2" + "@expo/config-types": "npm:^53.0.3" + "@expo/json-file": "npm:^9.1.4" deepmerge: "npm:^4.3.1" getenv: "npm:^1.0.0" glob: "npm:^10.4.2" @@ -3365,7 +4952,7 @@ __metadata: semver: "npm:^7.6.0" slugify: "npm:^1.3.4" sucrase: "npm:3.35.0" - checksum: 10/178e478d36b9d6dcb92376a43cb59fdf1b539ac359502df4e5d6c7b747407e1498520b43d3b0d50c79f52f412679c5e68bdb5b79f74f7277ce51375ec96a7f5e + checksum: 10/2027a94424917395c7f063e82d683ccbd8f007fca771894e2107b092b85a156a2b208938e8084f7d4c4464ab745e6902e32f825511a64c45fdc84d89aa7f6db3 languageName: node linkType: hard @@ -3389,22 +4976,22 @@ __metadata: languageName: node linkType: hard -"@expo/env@npm:~0.4.0, @expo/env@npm:~0.4.1": - version: 0.4.1 - resolution: "@expo/env@npm:0.4.1" +"@expo/env@npm:~1.0.5": + version: 1.0.5 + resolution: "@expo/env@npm:1.0.5" dependencies: chalk: "npm:^4.0.0" debug: "npm:^4.3.4" dotenv: "npm:~16.4.5" dotenv-expand: "npm:~11.0.6" getenv: "npm:^1.0.0" - checksum: 10/826131bf9725f2b6d24cf348f5122ee94ce503c96a8013a193afa8bbf56d86f3a115c910d1a6cf588cef91d9c0192598942d2a22f1bb4eacec02502e1d27f40b + checksum: 10/4e75d0a519f10b8d0b886c4827b5314d4bee491b2c03df9d027349eb441bc84886e4a20b4b67b859e7ecba67e708c393203e43fae770f471fba1025cca73f540 languageName: node linkType: hard -"@expo/fingerprint@npm:0.11.3": - version: 0.11.3 - resolution: "@expo/fingerprint@npm:0.11.3" +"@expo/fingerprint@npm:0.12.4": + version: 0.12.4 + resolution: "@expo/fingerprint@npm:0.12.4" dependencies: "@expo/spawn-async": "npm:^1.7.2" arg: "npm:^5.0.2" @@ -3412,23 +4999,22 @@ __metadata: debug: "npm:^4.3.4" find-up: "npm:^5.0.0" getenv: "npm:^1.0.0" - minimatch: "npm:^3.0.4" + minimatch: "npm:^9.0.0" p-limit: "npm:^3.1.0" resolve-from: "npm:^5.0.0" semver: "npm:^7.6.0" bin: fingerprint: bin/cli.js - checksum: 10/5a993c7e0a9f27508bea6e47fac6a32995a5ec70219f0f87eebdcbc1b972fd1dea1c922fa903eb1ca428e96b7740af269da6a16d1fab61eab1f806a2f1663743 + checksum: 10/5f0573df823e9879d37597b47b52186049b843000ae780af8d2724cc3b1b111210a61178142a4c2be66206337f5c687082078d139539093c7893d28df54adf06 languageName: node linkType: hard -"@expo/image-utils@npm:^0.6.0": - version: 0.6.3 - resolution: "@expo/image-utils@npm:0.6.3" +"@expo/image-utils@npm:^0.7.4": + version: 0.7.4 + resolution: "@expo/image-utils@npm:0.7.4" dependencies: "@expo/spawn-async": "npm:^1.7.2" chalk: "npm:^4.0.0" - fs-extra: "npm:9.0.0" getenv: "npm:^1.0.0" jimp-compact: "npm:0.16.1" parse-png: "npm:^2.1.0" @@ -3436,128 +5022,138 @@ __metadata: semver: "npm:^7.6.0" temp-dir: "npm:~2.0.0" unique-string: "npm:~2.0.0" - checksum: 10/aeaef02c98ce8a2b3d00e7a5b988d0be5956531ac68dc4a0182cd735afaab4f707bb8a1bd55975d6a977c082b59303d07ddc59ea3d72605270f94abac6c6683f + checksum: 10/dba225cde9a2312e45b4d0d1ce8d2310db2549f9b44b10ca9bcd470cee74fa7ff3178cc7aae8f6eaf4d2c8c8af2b2cc4b4aee1a3202085e8b91749f6ea279532 languageName: node linkType: hard -"@expo/json-file@npm:^9.0.0, @expo/json-file@npm:^9.0.1, @expo/json-file@npm:~9.0.0, @expo/json-file@npm:~9.0.1": - version: 9.0.1 - resolution: "@expo/json-file@npm:9.0.1" +"@expo/json-file@npm:^9.1.4, @expo/json-file@npm:~9.1.4": + version: 9.1.4 + resolution: "@expo/json-file@npm:9.1.4" dependencies: "@babel/code-frame": "npm:~7.10.4" json5: "npm:^2.2.3" - write-file-atomic: "npm:^2.3.0" - checksum: 10/58c7467511aef692a3c6f888e262d4cdb573add66f3c4407dd3e7a7c19ee031d03a803eb0120ec48f989850d9d1cb6bc435670ad97cfc2e5066afd8b7b105348 + checksum: 10/bebc52e38262c20313c6738904858214b5551f5a0f0eab270bd99d5b1dec44fd354df77750914c142d43828932b983f883e3af31d04861dda08012f139e0fed9 languageName: node linkType: hard -"@expo/metro-config@npm:0.19.6, @expo/metro-config@npm:~0.19.0": - version: 0.19.6 - resolution: "@expo/metro-config@npm:0.19.6" +"@expo/json-file@npm:^9.1.5, @expo/json-file@npm:~9.1.5": + version: 9.1.5 + resolution: "@expo/json-file@npm:9.1.5" + dependencies: + "@babel/code-frame": "npm:~7.10.4" + json5: "npm:^2.2.3" + checksum: 10/b4a8019ac68ffb04606b03ff2fbec81ed031a9bbb2e9f21ba5e7a74f36d68ad1aa68111342ac26d2082f1322b5eb343ad0976b51793599501f33160cd92c25be + languageName: node + linkType: hard + +"@expo/metro-config@npm:0.20.13, @expo/metro-config@npm:~0.20.13": + version: 0.20.13 + resolution: "@expo/metro-config@npm:0.20.13" dependencies: "@babel/core": "npm:^7.20.0" "@babel/generator": "npm:^7.20.5" "@babel/parser": "npm:^7.20.0" "@babel/types": "npm:^7.20.0" - "@expo/config": "npm:~10.0.4" - "@expo/env": "npm:~0.4.0" - "@expo/json-file": "npm:~9.0.0" + "@expo/config": "npm:~11.0.8" + "@expo/env": "npm:~1.0.5" + "@expo/json-file": "npm:~9.1.4" "@expo/spawn-async": "npm:^1.7.2" chalk: "npm:^4.1.0" debug: "npm:^4.3.2" - fs-extra: "npm:^9.1.0" + dotenv: "npm:~16.4.5" + dotenv-expand: "npm:~11.0.6" getenv: "npm:^1.0.0" glob: "npm:^10.4.2" jsc-safe-url: "npm:^0.2.4" lightningcss: "npm:~1.27.0" - minimatch: "npm:^3.0.4" + minimatch: "npm:^9.0.0" postcss: "npm:~8.4.32" resolve-from: "npm:^5.0.0" - checksum: 10/1a98844a079f3c97854d84f5fac6b207dfc404da4ade0133b9b8c7581903dbf5f4a806fd4b7394bf009bbde39df4bfb10b09930245a017255efe88884b77013f + checksum: 10/a3f34ffd703d7acd16326bc30e959ff7172db8bddfdaa1d3eb3e2ea14e6cd68e10a02e38da144159e217d475d0bb99ac43a3b24d61a517c466c5be104a5779c0 languageName: node linkType: hard -"@expo/metro-runtime@npm:4.0.0": - version: 4.0.0 - resolution: "@expo/metro-runtime@npm:4.0.0" +"@expo/metro-runtime@npm:5.0.4": + version: 5.0.4 + resolution: "@expo/metro-runtime@npm:5.0.4" peerDependencies: react-native: "*" - checksum: 10/2f5bd81e4d0735dc9b99ac4f35169969eb9dc303669ddc3158f670a3c36115f87d479a6fda11c1d5acab551f1015a32271c83001e8874493f0ec58c02f8ddbf3 + checksum: 10/7aa5ddcab4effaebecb3b04885cf45ca32d5c320eb3045aa44b8d05266d8508512fe7b015be0bd7d1b9a338342a8a97605a5b65f69fdd8f0aae8dac0a617b3a1 languageName: node linkType: hard -"@expo/osascript@npm:^2.0.31": - version: 2.1.4 - resolution: "@expo/osascript@npm:2.1.4" +"@expo/npm-proofread@npm:^1.0.1": + version: 1.0.1 + resolution: "@expo/npm-proofread@npm:1.0.1" + dependencies: + semver: "npm:^5.3.0" + bin: + proofread: ./proofread.js + checksum: 10/a2654e13c31ee97eb3ed6f45a41e2b053b2fce737c7fcfe9bc8b73e9619ceeddb27637af549c6c315bd1e84805b367f947ee38b44620e4a912f83b699f3553d7 + languageName: node + linkType: hard + +"@expo/osascript@npm:^2.2.4": + version: 2.2.4 + resolution: "@expo/osascript@npm:2.2.4" dependencies: "@expo/spawn-async": "npm:^1.7.2" exec-async: "npm:^2.2.0" - checksum: 10/d35a36bd93f0477138e0b93da8bde8098d8b1158fbbf1c4121a8fc345681eb6aff51df8639c3d32fcfc98eedc9c018d044aa56684507604968c31c238a3e53de + checksum: 10/a0c24615e6cb6a8ae8b9d8c5df37156137ca9fb71e2a5e2bec9f98ab0bca5738554f5bbddcd5b73f03445f5f705c96437d3dc30221ac586f52f3f209a6893a25 languageName: node linkType: hard -"@expo/package-manager@npm:^1.5.0": - version: 1.6.1 - resolution: "@expo/package-manager@npm:1.6.1" +"@expo/package-manager@npm:^1.8.4": + version: 1.8.4 + resolution: "@expo/package-manager@npm:1.8.4" dependencies: - "@expo/json-file": "npm:^9.0.0" + "@expo/json-file": "npm:^9.1.4" "@expo/spawn-async": "npm:^1.7.2" - ansi-regex: "npm:^5.0.0" chalk: "npm:^4.0.0" - find-up: "npm:^5.0.0" - js-yaml: "npm:^3.13.1" - micromatch: "npm:^4.0.8" npm-package-arg: "npm:^11.0.0" ora: "npm:^3.4.0" resolve-workspace-root: "npm:^2.0.0" - split: "npm:^1.0.1" - sudo-prompt: "npm:9.1.1" - checksum: 10/64c08bdc64516c3085ba9bc5870efac5f93b6e8dc3f5e9e6047984df9a7043b639b024177a927b24f7eca989cf257d9ad430b6ac68900eea6c7ddf203cbed12a + checksum: 10/269517f6468b1f7ede0780d343dfed5d6d96dba813529229c28604360247d11972ef5d8229ed26cfd38cc9a02113d3f3f0abd91697de3f64f73df5c62f0f39d0 languageName: node linkType: hard -"@expo/plist@npm:^0.2.0, @expo/plist@npm:^0.2.1": - version: 0.2.1 - resolution: "@expo/plist@npm:0.2.1" +"@expo/plist@npm:^0.3.4": + version: 0.3.4 + resolution: "@expo/plist@npm:0.3.4" dependencies: - "@xmldom/xmldom": "npm:~0.7.7" + "@xmldom/xmldom": "npm:^0.8.8" base64-js: "npm:^1.2.3" - xmlbuilder: "npm:^14.0.0" - checksum: 10/141c640a5ca78538695ce70312939789019b47ea1482e5641afa61b558f9484823c94816a748640f64c1bec052cc69f693f64b2e35bd358d40525162def075e7 + xmlbuilder: "npm:^15.1.1" + checksum: 10/63bb0b8b187452fcacc80f541e362ad3c867bd5489699d57db009718cd30288d1057286a42d682343659464ff77d610d6867865ccbce1f55a53f7911dbfbda03 languageName: node linkType: hard -"@expo/prebuild-config@npm:^8.0.22": - version: 8.0.22 - resolution: "@expo/prebuild-config@npm:8.0.22" +"@expo/plist@npm:^0.3.5": + version: 0.3.5 + resolution: "@expo/plist@npm:0.3.5" dependencies: - "@expo/config": "npm:~10.0.4" - "@expo/config-plugins": "npm:~9.0.10" - "@expo/config-types": "npm:^52.0.0" - "@expo/image-utils": "npm:^0.6.0" - "@expo/json-file": "npm:^9.0.0" - "@react-native/normalize-colors": "npm:0.76.3" + "@xmldom/xmldom": "npm:^0.8.8" + base64-js: "npm:^1.2.3" + xmlbuilder: "npm:^15.1.1" + checksum: 10/a79f11e21c0072baf32444a0ca38883f966470df44d7bd30b244e4dba2aa1c66e186129d22c1ed7fd4139fd053ec9ae3d9d41a4f6fc36f51306a5b9a455d7676 + languageName: node + linkType: hard + +"@expo/prebuild-config@npm:^9.0.5": + version: 9.0.5 + resolution: "@expo/prebuild-config@npm:9.0.5" + dependencies: + "@expo/config": "npm:~11.0.7" + "@expo/config-plugins": "npm:~10.0.2" + "@expo/config-types": "npm:^53.0.3" + "@expo/image-utils": "npm:^0.7.4" + "@expo/json-file": "npm:^9.1.4" + "@react-native/normalize-colors": "npm:0.79.2" debug: "npm:^4.3.1" - fs-extra: "npm:^9.0.0" resolve-from: "npm:^5.0.0" semver: "npm:^7.6.0" xml2js: "npm:0.6.0" - checksum: 10/28780c041c2c6449ec81166c83f5b1f8e223a37e3445b94acfd69e9cd9cfb746a5178ff88133a1cb2044357c4797f0c1e341788a8947022030ebc8eed06ce966 - languageName: node - linkType: hard - -"@expo/rudder-sdk-node@npm:^1.1.1": - version: 1.1.1 - resolution: "@expo/rudder-sdk-node@npm:1.1.1" - dependencies: - "@expo/bunyan": "npm:^4.0.0" - "@segment/loosely-validate-event": "npm:^2.0.0" - fetch-retry: "npm:^4.1.1" - md5: "npm:^2.2.1" - node-fetch: "npm:^2.6.1" - remove-trailing-slash: "npm:^0.1.0" - uuid: "npm:^8.3.2" - checksum: 10/e5611d51cbeb8c7517b2265e909bfc6b59f4b973b3ce08f9581e233164ab4ca2ec3b9288f895750aea1c390fa40cce136665c659a937493b08f00d606a4436f8 + checksum: 10/f5600f7f2536d9cf96ecd8d55fc9a8901157e651d0e7415b7df7241fe05a0a2bc7b3050235b61b4f2ef63ad7de8c333617c30a51493ca0b0b24e87063e002aed languageName: node linkType: hard @@ -3568,15 +5164,15 @@ __metadata: languageName: node linkType: hard -"@expo/server@npm:^0.5.0": - version: 0.5.0 - resolution: "@expo/server@npm:0.5.0" +"@expo/server@npm:^0.6.2": + version: 0.6.2 + resolution: "@expo/server@npm:0.6.2" dependencies: - "@remix-run/node": "npm:^2.12.0" abort-controller: "npm:^3.0.0" debug: "npm:^4.3.4" source-map-support: "npm:~0.5.21" - checksum: 10/aec839bcd8e72dd366e7a02721a364644e84d0db4a34b402f676f3f95c1ed49770f8909108f6cb8a1ba1285e9acae3ed2ee2563f18620efdacb1b812eb5fe838 + undici: "npm:^6.18.2 || ^7.0.0" + checksum: 10/527d46230feb5abee0a9b326ccbe56c8238b4f72bb64f72c7c139b86f0e05853d3e596c5412510b976df7f2f5328f8cfe745d771f92a8c5a194d558acacb4cd5 languageName: node linkType: hard @@ -3589,7 +5185,7 @@ __metadata: languageName: node linkType: hard -"@expo/vector-icons@npm:^14.0.0, @expo/vector-icons@npm:^14.0.2": +"@expo/vector-icons@npm:^14.0.0": version: 14.0.4 resolution: "@expo/vector-icons@npm:14.0.4" dependencies: @@ -3598,6 +5194,24 @@ __metadata: languageName: node linkType: hard +"@expo/vector-icons@npm:^14.1.0": + version: 14.1.0 + resolution: "@expo/vector-icons@npm:14.1.0" + peerDependencies: + expo-font: "*" + react: "*" + react-native: "*" + checksum: 10/5b80a684042672e40f311a921fca7acc964fdddb1edd2c2c22253f5b9097e9fe441626ffa50c98ffc2622a3f1a9b5315a8a73796348d3b03108b4da2600384bc + languageName: node + linkType: hard + +"@expo/ws-tunnel@npm:^1.0.1": + version: 1.0.6 + resolution: "@expo/ws-tunnel@npm:1.0.6" + checksum: 10/83941098d2effee1aa69541c9f1c45e57ef5ae7777ff08fbc05218eb4a7f7fc98b9c922a69645b72a53924835c4e1080a137552e1c9bc8bf715fe479a8d147e2 + languageName: node + linkType: hard + "@expo/xcpretty@npm:^4.3.0": version: 4.3.2 resolution: "@expo/xcpretty@npm:4.3.2" @@ -3640,21 +5254,6 @@ __metadata: languageName: node linkType: hard -"@firebase/analytics-compat@npm:0.2.14": - version: 0.2.14 - resolution: "@firebase/analytics-compat@npm:0.2.14" - dependencies: - "@firebase/analytics": "npm:0.10.8" - "@firebase/analytics-types": "npm:0.8.2" - "@firebase/component": "npm:0.6.9" - "@firebase/util": "npm:1.10.0" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10/0e368159d24223076b488b27308c11e5ef50456aff49fc58e1f66616228021c61e60c3299f63ce52ddc2f7099d803e9048bc28cd952cf5c302917002c03c85ee - languageName: node - linkType: hard - "@firebase/analytics-compat@npm:0.2.16": version: 0.2.16 resolution: "@firebase/analytics-compat@npm:0.2.16" @@ -3670,10 +5269,18 @@ __metadata: languageName: node linkType: hard -"@firebase/analytics-types@npm:0.8.2": - version: 0.8.2 - resolution: "@firebase/analytics-types@npm:0.8.2" - checksum: 10/297fb7becbc51950c7de5809fed896c391d1e87b4d8bb4bf88f4e8760b2e32f903a7dd8e92de4424b49c4e2ecb60a44d49e2f9c68ac3f7ffe3a0194f78910392 +"@firebase/analytics-compat@npm:0.2.17": + version: 0.2.17 + resolution: "@firebase/analytics-compat@npm:0.2.17" + dependencies: + "@firebase/analytics": "npm:0.10.11" + "@firebase/analytics-types": "npm:0.8.3" + "@firebase/component": "npm:0.6.12" + "@firebase/util": "npm:1.10.3" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10/3b048b41e0405a3975050f5d55afa923263ba3768d7b1b635d70892504cac03bd0bcf353b44819959dc6de7c04f1df818e34cec705c8ce18cf5c0866abe277b9 languageName: node linkType: hard @@ -3699,34 +5306,18 @@ __metadata: languageName: node linkType: hard -"@firebase/analytics@npm:0.10.8": - version: 0.10.8 - resolution: "@firebase/analytics@npm:0.10.8" +"@firebase/analytics@npm:0.10.11": + version: 0.10.11 + resolution: "@firebase/analytics@npm:0.10.11" dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/installations": "npm:0.6.9" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.10.0" + "@firebase/component": "npm:0.6.12" + "@firebase/installations": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x - checksum: 10/152ddaf68146f02baa7060d34426c25ec13890a53942ffa2db09faa148bef35f59ee9810e6fb8f561fb3d115b71d1fb9fb111d2a0f0199aa510220782557c765 - languageName: node - linkType: hard - -"@firebase/app-check-compat@npm:0.3.15": - version: 0.3.15 - resolution: "@firebase/app-check-compat@npm:0.3.15" - dependencies: - "@firebase/app-check": "npm:0.8.8" - "@firebase/app-check-types": "npm:0.5.2" - "@firebase/component": "npm:0.6.9" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.10.0" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10/ae541d324d5f91dbb7b479855d3380c4fe73e365013b80973a54620405093e6fd2f8e418549155b3a527530472a19b6edf6df1481a708f823eba42e376105b28 + checksum: 10/804083f61ffc57dabeb7a1b49e16f86969d1b2a37fafc23633c90324768ab849e52324b6a10928d789e038ec2f5d93248717f18d5f0d2a4b916850b86051c214 languageName: node linkType: hard @@ -3746,10 +5337,19 @@ __metadata: languageName: node linkType: hard -"@firebase/app-check-interop-types@npm:0.3.2": - version: 0.3.2 - resolution: "@firebase/app-check-interop-types@npm:0.3.2" - checksum: 10/3effe656a4762c541838f4bde91b4498e51d48389046b930dc3dbb012e54b6ab0727f7c68a3e94198f633d57833346fc337a0847b6b03d2407030e1489d466fe +"@firebase/app-check-compat@npm:0.3.18": + version: 0.3.18 + resolution: "@firebase/app-check-compat@npm:0.3.18" + dependencies: + "@firebase/app-check": "npm:0.8.11" + "@firebase/app-check-types": "npm:0.5.3" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10/24b103fc309fa66d9830614c69bdf62810ecf0b77ad4fc9f318e05361a686cc3a684d84bddbd6afddc6a641739ead93ab1e8c28a75ed915750602b371aeb9b32 languageName: node linkType: hard @@ -3760,13 +5360,6 @@ __metadata: languageName: node linkType: hard -"@firebase/app-check-types@npm:0.5.2": - version: 0.5.2 - resolution: "@firebase/app-check-types@npm:0.5.2" - checksum: 10/2b33a7adfb7b6ebf5423940bf0af5909df69bf2d6184e12e989f6c76062077be16c31193795349862b4f8aab6b3059806b732a92995cae30fd77419f19a86c6e - languageName: node - linkType: hard - "@firebase/app-check-types@npm:0.5.3": version: 0.5.3 resolution: "@firebase/app-check-types@npm:0.5.3" @@ -3788,30 +5381,17 @@ __metadata: languageName: node linkType: hard -"@firebase/app-check@npm:0.8.8": - version: 0.8.8 - resolution: "@firebase/app-check@npm:0.8.8" +"@firebase/app-check@npm:0.8.11": + version: 0.8.11 + resolution: "@firebase/app-check@npm:0.8.11" dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.10.0" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x - checksum: 10/a3676f2143c8e438d7e8ac11bb163af30880f6ce6acc5cc54cfcc214b8efd5dabce14c040626f8a64a3967db144b99834f1108c2076a0eae8a6baf864b5a3d77 - languageName: node - linkType: hard - -"@firebase/app-compat@npm:0.2.41": - version: 0.2.41 - resolution: "@firebase/app-compat@npm:0.2.41" - dependencies: - "@firebase/app": "npm:0.10.11" - "@firebase/component": "npm:0.6.9" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.10.0" - tslib: "npm:^2.1.0" - checksum: 10/67e4b0572a3c24c4acc13e2c3b55a4fc778d286bae10f1df684a142c9790b4f131519fe84087341884bd67b04b822c3f7092b9748dfa3b52086b6f82ca8a1001 + checksum: 10/e3f6a3940037c17a2faaf97a700d33b2c7821e07460e0a854d9f542acdcb589514bb4699df3adba1fb1d17ee75261006939b8ef60ec44bbe6c8c827b0797aa77 languageName: node linkType: hard @@ -3828,10 +5408,16 @@ __metadata: languageName: node linkType: hard -"@firebase/app-types@npm:0.9.2": - version: 0.9.2 - resolution: "@firebase/app-types@npm:0.9.2" - checksum: 10/566b3714a4d7e8180514258e4b1549bf5b28ae0383b4ff53d3532a45e114048afdd27c1fef8688d871dd9e5ad5307e749776e23f094122655ac6b0fb550eb11a +"@firebase/app-compat@npm:0.2.50": + version: 0.2.50 + resolution: "@firebase/app-compat@npm:0.2.50" + dependencies: + "@firebase/app": "npm:0.11.1" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" + tslib: "npm:^2.1.0" + checksum: 10/a46ce03e55d7378939192801ec0e72459ed1ee0cb4c2ccab4a896aec1160b18e07227905466d163dc73d4514eb60eb481492c1ea030563b805af2e6f4f8aa34b languageName: node linkType: hard @@ -3842,19 +5428,6 @@ __metadata: languageName: node linkType: hard -"@firebase/app@npm:0.10.11": - version: 0.10.11 - resolution: "@firebase/app@npm:0.10.11" - dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.10.0" - idb: "npm:7.1.1" - tslib: "npm:^2.1.0" - checksum: 10/529d9e59b39e96cd97a8402e1cee508dbbb962aa1805345dc902ecbfe61709bb46ab3b821cd3b50b3d2e3e9f898515eb91cded030492e376550a97518cbcdb70 - languageName: node - linkType: hard - "@firebase/app@npm:0.10.17": version: 0.10.17 resolution: "@firebase/app@npm:0.10.17" @@ -3868,19 +5441,16 @@ __metadata: languageName: node linkType: hard -"@firebase/auth-compat@npm:0.5.14": - version: 0.5.14 - resolution: "@firebase/auth-compat@npm:0.5.14" +"@firebase/app@npm:0.11.1": + version: 0.11.1 + resolution: "@firebase/app@npm:0.11.1" dependencies: - "@firebase/auth": "npm:1.7.9" - "@firebase/auth-types": "npm:0.12.2" - "@firebase/component": "npm:0.6.9" - "@firebase/util": "npm:1.10.0" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" + idb: "npm:7.1.1" tslib: "npm:^2.1.0" - undici: "npm:6.19.7" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10/85d5259e7b04b14b5d02dc1fb19b015d742c594c14138f33f13146ed9f6caa7ed9d19d65bb99aaca57e70ffd2a491e520d8638eadefbd00f839d37ef972cbbda + checksum: 10/bbb3046d94dc7b0de005ec6b02b1452cf6dc4c81fababdc2347353f52d64b5fbc0aa88cdc25c83200f56b2fd4df0524a9de8954c5f38ad368aeae26e344188d5 languageName: node linkType: hard @@ -3899,10 +5469,18 @@ __metadata: languageName: node linkType: hard -"@firebase/auth-interop-types@npm:0.2.3": - version: 0.2.3 - resolution: "@firebase/auth-interop-types@npm:0.2.3" - checksum: 10/e55b8ded6bd1a5e6a2845c9c7ed520bb9a8a76e4ddf90249bf685986ac7b1fb079be2fa4edcb6a3aa81d1d56870a470eadcd5a8f20b797dccd803d72ed4c80aa +"@firebase/auth-compat@npm:0.5.18": + version: 0.5.18 + resolution: "@firebase/auth-compat@npm:0.5.18" + dependencies: + "@firebase/auth": "npm:1.9.0" + "@firebase/auth-types": "npm:0.13.0" + "@firebase/component": "npm:0.6.12" + "@firebase/util": "npm:1.10.3" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10/6a3fca333bc1ae2e9c44728aae8a8318cb370d76a744169d8a8c0d1c82f54a5db6f7d80a699a256d5f2e78732bb1bc1bc874f58a2c8382b3c61b9cce1b3b00a1 languageName: node linkType: hard @@ -3913,16 +5491,6 @@ __metadata: languageName: node linkType: hard -"@firebase/auth-types@npm:0.12.2": - version: 0.12.2 - resolution: "@firebase/auth-types@npm:0.12.2" - peerDependencies: - "@firebase/app-types": 0.x - "@firebase/util": 1.x - checksum: 10/f55449381de8e2a24ffaf19f12b5c4a093c8323034253ea7a5f7afc946327d20b09f32a483c12960862a1c4814645ea80bc4343f0a9f22db5dc048ca82773132 - languageName: node - linkType: hard - "@firebase/auth-types@npm:0.12.3": version: 0.12.3 resolution: "@firebase/auth-types@npm:0.12.3" @@ -3933,22 +5501,13 @@ __metadata: languageName: node linkType: hard -"@firebase/auth@npm:1.7.9": - version: 1.7.9 - resolution: "@firebase/auth@npm:1.7.9" - dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.10.0" - tslib: "npm:^2.1.0" - undici: "npm:6.19.7" +"@firebase/auth-types@npm:0.13.0": + version: 0.13.0 + resolution: "@firebase/auth-types@npm:0.13.0" peerDependencies: - "@firebase/app": 0.x - "@react-native-async-storage/async-storage": ^1.18.1 - peerDependenciesMeta: - "@react-native-async-storage/async-storage": - optional: true - checksum: 10/010013ec339c9ef7b4d9278c6cacfd8e2eb3282f27a3e4e89c42a5968955976a26277421f34fda3e9400409a22a61f632bcc03e713b3f39d71e4777bc003165d + "@firebase/app-types": 0.x + "@firebase/util": 1.x + checksum: 10/57d8e4b80e58d3a9e453b4676a29e3b0e548ca9f4c2b465137007bb5753e3bde2f6537f0be9779df17859ebc4e6b1b59c88215cdd59a32106391cf117072372d languageName: node linkType: hard @@ -3970,23 +5529,41 @@ __metadata: languageName: node linkType: hard -"@firebase/component@npm:0.6.11": - version: 0.6.11 - resolution: "@firebase/component@npm:0.6.11" +"@firebase/auth@npm:1.9.0": + version: 1.9.0 + resolution: "@firebase/auth@npm:1.9.0" dependencies: - "@firebase/util": "npm:1.10.2" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app": 0.x + "@react-native-async-storage/async-storage": ^1.18.1 + peerDependenciesMeta: + "@react-native-async-storage/async-storage": + optional: true + checksum: 10/868cbe90ca414393f913e27c09b9d0c33a50fe0dad9bc3ce5e4961fcd6548ffd38d6de6e1c93ca310f34539b8d1284921784048f20b939df8f1ffd800c2986ae + languageName: node + linkType: hard + +"@firebase/component@npm:0.6.11": + version: 0.6.11 + resolution: "@firebase/component@npm:0.6.11" + dependencies: + "@firebase/util": "npm:1.10.2" tslib: "npm:^2.1.0" checksum: 10/a70b88dfed7ec16766c472722362d65709fb88c9dfb9e61277e735f9515c88747196942d91c988f4fa4690fc4eb0b924f6f7fe2042d22c38a25b9cff82158fa6 languageName: node linkType: hard -"@firebase/component@npm:0.6.9": - version: 0.6.9 - resolution: "@firebase/component@npm:0.6.9" +"@firebase/component@npm:0.6.12": + version: 0.6.12 + resolution: "@firebase/component@npm:0.6.12" dependencies: - "@firebase/util": "npm:1.10.0" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" - checksum: 10/76c865d640e4b24a0e50876ecdc0e1199df38af562131a937b5a4bac924d61b6933339afb7906881dca509f38f3b0c511cd6b5008e061424c61b20876de9531e + checksum: 10/4dfd201d3709ef5eed477e13d399611a78a186ca8911846e24361f9848c3b4eecc14c295a8f78ec40c88816329fde0ba6cc30dce9a444fa43a619b7ea744f0dc languageName: node linkType: hard @@ -4005,17 +5582,18 @@ __metadata: languageName: node linkType: hard -"@firebase/database-compat@npm:1.0.8": - version: 1.0.8 - resolution: "@firebase/database-compat@npm:1.0.8" +"@firebase/data-connect@npm:0.3.0": + version: 0.3.0 + resolution: "@firebase/data-connect@npm:0.3.0" dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/database": "npm:1.0.8" - "@firebase/database-types": "npm:1.0.5" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.10.0" + "@firebase/auth-interop-types": "npm:0.2.4" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" - checksum: 10/28389efcc87da77b822cb27c31707824fe98e7b0a3bf9cbf2b0c0fccd9edd72e2681a9467b76b120281464dbfc814852ebca63d99a385a9cb68fb55c7b334105 + peerDependencies: + "@firebase/app": 0.x + checksum: 10/13edd416331103f331fda28e26be83b17989b29ccdd1c9b953eb1d376eb5b9760c5e4b9576d024f200ba9d75676074a4b19c7b41a0e5539d561709a79e28ed72 languageName: node linkType: hard @@ -4033,13 +5611,17 @@ __metadata: languageName: node linkType: hard -"@firebase/database-types@npm:1.0.5": - version: 1.0.5 - resolution: "@firebase/database-types@npm:1.0.5" +"@firebase/database-compat@npm:2.0.3": + version: 2.0.3 + resolution: "@firebase/database-compat@npm:2.0.3" dependencies: - "@firebase/app-types": "npm:0.9.2" - "@firebase/util": "npm:1.10.0" - checksum: 10/bdf667da0369dce8623987fc01cad8db09cfe1895130f69ab581d34a0ee043ca6113c32457629147ae1441a934d985ede9d7cbe104ac346de6d0c21629903a8b + "@firebase/component": "npm:0.6.12" + "@firebase/database": "npm:1.0.12" + "@firebase/database-types": "npm:1.0.8" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" + tslib: "npm:^2.1.0" + checksum: 10/5f91dbdc9ef12994a57c49ac17242fbfa072566c9e149698fb84b1f482ca41032bfce344a27c223e31826a1fcec1f24ddd1de2571c86fd6edd4b44a23742b289 languageName: node linkType: hard @@ -4053,6 +5635,16 @@ __metadata: languageName: node linkType: hard +"@firebase/database-types@npm:1.0.8": + version: 1.0.8 + resolution: "@firebase/database-types@npm:1.0.8" + dependencies: + "@firebase/app-types": "npm:0.9.3" + "@firebase/util": "npm:1.10.3" + checksum: 10/1b5483de082ff8d7551b21f087ba2f237bcd38ca9e3f48b1377b96213718e0a206437fe31a4e055c1b90d05a1f38f89fe1c92d50d907ca06c8727c73fc521c40 + languageName: node + linkType: hard + "@firebase/database@npm:1.0.10": version: 1.0.10 resolution: "@firebase/database@npm:1.0.10" @@ -4068,33 +5660,18 @@ __metadata: languageName: node linkType: hard -"@firebase/database@npm:1.0.8": - version: 1.0.8 - resolution: "@firebase/database@npm:1.0.8" +"@firebase/database@npm:1.0.12": + version: 1.0.12 + resolution: "@firebase/database@npm:1.0.12" dependencies: - "@firebase/app-check-interop-types": "npm:0.3.2" - "@firebase/auth-interop-types": "npm:0.2.3" - "@firebase/component": "npm:0.6.9" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.10.0" + "@firebase/app-check-interop-types": "npm:0.3.3" + "@firebase/auth-interop-types": "npm:0.2.4" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" faye-websocket: "npm:0.11.4" tslib: "npm:^2.1.0" - checksum: 10/adb199a6ad7866b418e8b319cc505e108bfc8200b5406f21857706df0849d4e5982a1b0e44e07001821edebef73c4dfffc7f96fb77a2cff10bb9ac26f17d40c3 - languageName: node - linkType: hard - -"@firebase/firestore-compat@npm:0.3.37": - version: 0.3.37 - resolution: "@firebase/firestore-compat@npm:0.3.37" - dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/firestore": "npm:4.7.2" - "@firebase/firestore-types": "npm:3.0.2" - "@firebase/util": "npm:1.10.0" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10/c152ba401f0d786699b25e1101d77351b7a6503f1a1f774efa7fecacc66aec58aca58a7b54e3f8587fcb45ffa3772d5e123ae79ddd90d0a87f2042ac34880d8a + checksum: 10/82ef169190b5dab5760a2802b9f88d59435792892a3727c17b52615803d2f8b62072605d270bc097513af7cd7c24b387bf777d0cccbbf4303c13c4f5bef6d610 languageName: node linkType: hard @@ -4113,13 +5690,18 @@ __metadata: languageName: node linkType: hard -"@firebase/firestore-types@npm:3.0.2": - version: 3.0.2 - resolution: "@firebase/firestore-types@npm:3.0.2" +"@firebase/firestore-compat@npm:0.3.43": + version: 0.3.43 + resolution: "@firebase/firestore-compat@npm:0.3.43" + dependencies: + "@firebase/component": "npm:0.6.12" + "@firebase/firestore": "npm:4.7.8" + "@firebase/firestore-types": "npm:3.0.3" + "@firebase/util": "npm:1.10.3" + tslib: "npm:^2.1.0" peerDependencies: - "@firebase/app-types": 0.x - "@firebase/util": 1.x - checksum: 10/81e91f836a026ecb70937407ca8699add7abb5b050d8815620cde97c3eec3f78f7dfbb366225758909f0df31d9f21e98a84ba62701bd27ee38b2609898c11acd + "@firebase/app-compat": 0.x + checksum: 10/9d542220b01603d8616604a7541fae4237ea3bf7b6734d60006ca457b7ef8727f9c8ef02c1fcf909771a28265add2f6ad3ce9e2d41a4da7fa617e252178eebc1 languageName: node linkType: hard @@ -4133,24 +5715,6 @@ __metadata: languageName: node linkType: hard -"@firebase/firestore@npm:4.7.2": - version: 4.7.2 - resolution: "@firebase/firestore@npm:4.7.2" - dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.10.0" - "@firebase/webchannel-wrapper": "npm:1.0.1" - "@grpc/grpc-js": "npm:~1.9.0" - "@grpc/proto-loader": "npm:^0.7.8" - tslib: "npm:^2.1.0" - undici: "npm:6.19.7" - peerDependencies: - "@firebase/app": 0.x - checksum: 10/066a125760bc2163bbc9c6fcde8b3f67da97791f8ce6f5ffa8ff3c40567aff97b2fe02020c3403857f104f051e4d6452aee60fe75ed5e408e467c611c397b4bb - languageName: node - linkType: hard - "@firebase/firestore@npm:4.7.5": version: 4.7.5 resolution: "@firebase/firestore@npm:4.7.5" @@ -4168,18 +5732,20 @@ __metadata: languageName: node linkType: hard -"@firebase/functions-compat@npm:0.3.14": - version: 0.3.14 - resolution: "@firebase/functions-compat@npm:0.3.14" +"@firebase/firestore@npm:4.7.8": + version: 4.7.8 + resolution: "@firebase/firestore@npm:4.7.8" dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/functions": "npm:0.11.8" - "@firebase/functions-types": "npm:0.6.2" - "@firebase/util": "npm:1.10.0" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" + "@firebase/webchannel-wrapper": "npm:1.0.3" + "@grpc/grpc-js": "npm:~1.9.0" + "@grpc/proto-loader": "npm:^0.7.8" tslib: "npm:^2.1.0" peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10/a8d6cbcdc646d78adecfcdc1f8fa14a5d9af2394dd69cac00c6826106b923e01d246c67fb7e09025ca7cfb876f8d5df97240cc056c64ccee8899ca5f17178a6c + "@firebase/app": 0.x + checksum: 10/2965b1fa8e2798c6a3fbbf0f0cd40375410dd0ececd623b7efd4446262d4b62d370dd89a159596ea970a055506fd24f0bb636e6381da0efe52d1a5ecbcef8514 languageName: node linkType: hard @@ -4198,10 +5764,18 @@ __metadata: languageName: node linkType: hard -"@firebase/functions-types@npm:0.6.2": - version: 0.6.2 - resolution: "@firebase/functions-types@npm:0.6.2" - checksum: 10/5b8733f9d4bd85a617d35dd10ce296d9ec0490494e584697c4eda8098ff1e865607d7880b84194e86c35d438bbcd714977c111180502d0d1b6b2da1cde1b37ca +"@firebase/functions-compat@npm:0.3.19": + version: 0.3.19 + resolution: "@firebase/functions-compat@npm:0.3.19" + dependencies: + "@firebase/component": "npm:0.6.12" + "@firebase/functions": "npm:0.12.2" + "@firebase/functions-types": "npm:0.6.3" + "@firebase/util": "npm:1.10.3" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10/a3918d32e4a6d9fac48dc7d1b8c0a0b4324caee0b2e5841c17983b0ea597d3367a855fe9d6f30ede232ee6fa6371ec85114cc9cfb240ca61304acb5f6ad779ff languageName: node linkType: hard @@ -4212,36 +5786,35 @@ __metadata: languageName: node linkType: hard -"@firebase/functions@npm:0.11.8": - version: 0.11.8 - resolution: "@firebase/functions@npm:0.11.8" +"@firebase/functions@npm:0.12.0": + version: 0.12.0 + resolution: "@firebase/functions@npm:0.12.0" dependencies: - "@firebase/app-check-interop-types": "npm:0.3.2" - "@firebase/auth-interop-types": "npm:0.2.3" - "@firebase/component": "npm:0.6.9" - "@firebase/messaging-interop-types": "npm:0.2.2" - "@firebase/util": "npm:1.10.0" + "@firebase/app-check-interop-types": "npm:0.3.3" + "@firebase/auth-interop-types": "npm:0.2.4" + "@firebase/component": "npm:0.6.11" + "@firebase/messaging-interop-types": "npm:0.2.3" + "@firebase/util": "npm:1.10.2" tslib: "npm:^2.1.0" - undici: "npm:6.19.7" peerDependencies: "@firebase/app": 0.x - checksum: 10/44f3e42df189f3f3cb3c366b38e93a0ffdfaa1a7b3f6dba624bcd9a7cda3d3271df66f2769b7cbe7e1e5ff01bf6ab3bef6c1e1e15c6646e34514d1e2ebb60555 + checksum: 10/1d9c2a17d3c4be52ca2678a1ab36be5e9bab8a4a40660996312e22cdcde0c4d25f9830c4f00a8896d378f23cd78907d824805293d3a9a14f8f81557c3903cacd languageName: node linkType: hard -"@firebase/functions@npm:0.12.0": - version: 0.12.0 - resolution: "@firebase/functions@npm:0.12.0" +"@firebase/functions@npm:0.12.2": + version: 0.12.2 + resolution: "@firebase/functions@npm:0.12.2" dependencies: "@firebase/app-check-interop-types": "npm:0.3.3" "@firebase/auth-interop-types": "npm:0.2.4" - "@firebase/component": "npm:0.6.11" + "@firebase/component": "npm:0.6.12" "@firebase/messaging-interop-types": "npm:0.2.3" - "@firebase/util": "npm:1.10.2" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x - checksum: 10/1d9c2a17d3c4be52ca2678a1ab36be5e9bab8a4a40660996312e22cdcde0c4d25f9830c4f00a8896d378f23cd78907d824805293d3a9a14f8f81557c3903cacd + checksum: 10/82c664dc1ea3a4f68b9bebd52f54b4c223224a5daa9a166a2d82514fc0e436fd06a1c506c1fee4782a1a693442bab68fb4bb7f000ab1861ce456a0ff4df442d9 languageName: node linkType: hard @@ -4260,27 +5833,18 @@ __metadata: languageName: node linkType: hard -"@firebase/installations-compat@npm:0.2.9": - version: 0.2.9 - resolution: "@firebase/installations-compat@npm:0.2.9" +"@firebase/installations-compat@npm:0.2.12": + version: 0.2.12 + resolution: "@firebase/installations-compat@npm:0.2.12" dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/installations": "npm:0.6.9" - "@firebase/installations-types": "npm:0.5.2" - "@firebase/util": "npm:1.10.0" + "@firebase/component": "npm:0.6.12" + "@firebase/installations": "npm:0.6.12" + "@firebase/installations-types": "npm:0.5.3" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app-compat": 0.x - checksum: 10/919e1a4f4b63f5fe757a3c9cefb4a36cbab92deb4a6e15f249c94d6e80d1c6d37e5e384a460af8c17fc88e3091594bf43d036c88b704516c279b5ab8401977e1 - languageName: node - linkType: hard - -"@firebase/installations-types@npm:0.5.2": - version: 0.5.2 - resolution: "@firebase/installations-types@npm:0.5.2" - peerDependencies: - "@firebase/app-types": 0.x - checksum: 10/2e795280c299d644b8c8e3fdfa5c6f20cb367dd3b7df32317211f84393fa169b33dee0cbed28de407f3b22dc8f1fb2f7a11ae5a373f8082cc570ef61ef6b91ba + checksum: 10/ffd5e08e65e7067c06a4eb5601a09b017fce006b38108c10c412df8144e79bd08b4347998740425f312288b5a0839818e634486875857df5518c05a737c46ad8 languageName: node linkType: hard @@ -4307,26 +5871,17 @@ __metadata: languageName: node linkType: hard -"@firebase/installations@npm:0.6.9": - version: 0.6.9 - resolution: "@firebase/installations@npm:0.6.9" +"@firebase/installations@npm:0.6.12": + version: 0.6.12 + resolution: "@firebase/installations@npm:0.6.12" dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/util": "npm:1.10.0" + "@firebase/component": "npm:0.6.12" + "@firebase/util": "npm:1.10.3" idb: "npm:7.1.1" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x - checksum: 10/349c8b7e877b002fb29f274f4d239fbca4c2c266ccb66ecfb5f1762f973a7fe1be99cc3346184d1230e6e35feb2b6f9e8b7169479fa0018b53e4a83837848619 - languageName: node - linkType: hard - -"@firebase/logger@npm:0.4.2": - version: 0.4.2 - resolution: "@firebase/logger@npm:0.4.2" - dependencies: - tslib: "npm:^2.1.0" - checksum: 10/961b4605220c0a56c5f3ccf4e6049e44c27303c1ca998c6fa1d19de785c76d93e3b1a3da455e9f40655711345d8d779912366e4f369d93eda8d08c407cc5b140 + checksum: 10/093295de087b4c9287d06243eb19814e25674047aafa4f5db9a222d8e64283d0362f37edf8cfbe882a80eac1d2d9fc52b821fbb01151ac925f023765251dd1de languageName: node linkType: hard @@ -4339,20 +5894,6 @@ __metadata: languageName: node linkType: hard -"@firebase/messaging-compat@npm:0.2.11": - version: 0.2.11 - resolution: "@firebase/messaging-compat@npm:0.2.11" - dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/messaging": "npm:0.12.11" - "@firebase/util": "npm:1.10.0" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10/8ace6d65adcf891b272875b7b3f43978a15644b23f7ee796346f02eb50007c20c99719f4991772911005697613bf122167ca150d8245d0fccb2b959472b4a625 - languageName: node - linkType: hard - "@firebase/messaging-compat@npm:0.2.15": version: 0.2.15 resolution: "@firebase/messaging-compat@npm:0.2.15" @@ -4367,10 +5908,17 @@ __metadata: languageName: node linkType: hard -"@firebase/messaging-interop-types@npm:0.2.2": - version: 0.2.2 - resolution: "@firebase/messaging-interop-types@npm:0.2.2" - checksum: 10/547f8ebf2c5a8dcbc484991b97d76bd3dc3eb4bd9d4e6ea2ffc652097c7065d92dc68d389ddb19fba41e0ce3b5f4cd757ed22f96b4744801149b0f8dbf323af7 +"@firebase/messaging-compat@npm:0.2.16": + version: 0.2.16 + resolution: "@firebase/messaging-compat@npm:0.2.16" + dependencies: + "@firebase/component": "npm:0.6.12" + "@firebase/messaging": "npm:0.12.16" + "@firebase/util": "npm:1.10.3" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app-compat": 0.x + checksum: 10/1887599e3f7d7db5a70f923118eda769130aa134c6a6ba0a9f599c541d78b2e00b9548fc51c12f430c60a6e902221fe951a4beeddd674f1c042ffa32d1593dc9 languageName: node linkType: hard @@ -4381,35 +5929,35 @@ __metadata: languageName: node linkType: hard -"@firebase/messaging@npm:0.12.11": - version: 0.12.11 - resolution: "@firebase/messaging@npm:0.12.11" +"@firebase/messaging@npm:0.12.15": + version: 0.12.15 + resolution: "@firebase/messaging@npm:0.12.15" dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/installations": "npm:0.6.9" - "@firebase/messaging-interop-types": "npm:0.2.2" - "@firebase/util": "npm:1.10.0" + "@firebase/component": "npm:0.6.11" + "@firebase/installations": "npm:0.6.11" + "@firebase/messaging-interop-types": "npm:0.2.3" + "@firebase/util": "npm:1.10.2" idb: "npm:7.1.1" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x - checksum: 10/1de21d56c74996e99151a902e0f1ff0825d47ebff044104483a838ff5cb4883433b2f541616b033255e4fd2780b29f71982d8832edf4987c101df97ed508828a + checksum: 10/29daf4f8d970b3893b234c4a38dff22233ac8d541c940de737803e5ff5a1da84271b96f5ab142fd8c7ae0afb95df2d4939c294dccc99ac7ebf9343827097fe0a languageName: node linkType: hard -"@firebase/messaging@npm:0.12.15": - version: 0.12.15 - resolution: "@firebase/messaging@npm:0.12.15" +"@firebase/messaging@npm:0.12.16": + version: 0.12.16 + resolution: "@firebase/messaging@npm:0.12.16" dependencies: - "@firebase/component": "npm:0.6.11" - "@firebase/installations": "npm:0.6.11" + "@firebase/component": "npm:0.6.12" + "@firebase/installations": "npm:0.6.12" "@firebase/messaging-interop-types": "npm:0.2.3" - "@firebase/util": "npm:1.10.2" + "@firebase/util": "npm:1.10.3" idb: "npm:7.1.1" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x - checksum: 10/29daf4f8d970b3893b234c4a38dff22233ac8d541c940de737803e5ff5a1da84271b96f5ab142fd8c7ae0afb95df2d4939c294dccc99ac7ebf9343827097fe0a + checksum: 10/e237f35c4b179a521a6a37255fa719784ec73f30b76d179c059f21bf1e7ee6f907299c137a7b55496134dc5c3578d365c62b2e44988323edd3d96e5468f016d6 languageName: node linkType: hard @@ -4429,26 +5977,19 @@ __metadata: languageName: node linkType: hard -"@firebase/performance-compat@npm:0.2.9": - version: 0.2.9 - resolution: "@firebase/performance-compat@npm:0.2.9" +"@firebase/performance-compat@npm:0.2.13": + version: 0.2.13 + resolution: "@firebase/performance-compat@npm:0.2.13" dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/logger": "npm:0.4.2" - "@firebase/performance": "npm:0.6.9" - "@firebase/performance-types": "npm:0.2.2" - "@firebase/util": "npm:1.10.0" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/performance": "npm:0.7.0" + "@firebase/performance-types": "npm:0.2.3" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app-compat": 0.x - checksum: 10/bc4e8b0208c9bc603518e1388713ec80658ee109c6af80d429479447ccb85e8e831269383233c379ed66bf37469d13f5c234074d0c0c9e7e69e909be5fdeca4f - languageName: node - linkType: hard - -"@firebase/performance-types@npm:0.2.2": - version: 0.2.2 - resolution: "@firebase/performance-types@npm:0.2.2" - checksum: 10/d25ae06cb75ab6b44ffacf7affadc1f651881f283e58381c444eb63b62dfb74c33c544ab89843518ec1d15367ba7c4343b4d6b22de1f1df35126a1283baa578d + checksum: 10/d87da72d45134fc3708f0faf26b9cd8b74e609f67ebea906a5fc3a23f78c65f1559409be5f6c6c06dbae6cd12eb2b8ff6b685c4a02effa76e37d2b0e913847ac languageName: node linkType: hard @@ -4474,18 +6015,19 @@ __metadata: languageName: node linkType: hard -"@firebase/performance@npm:0.6.9": - version: 0.6.9 - resolution: "@firebase/performance@npm:0.6.9" +"@firebase/performance@npm:0.7.0": + version: 0.7.0 + resolution: "@firebase/performance@npm:0.7.0" dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/installations": "npm:0.6.9" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.10.0" + "@firebase/component": "npm:0.6.12" + "@firebase/installations": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" + web-vitals: "npm:^4.2.4" peerDependencies: "@firebase/app": 0.x - checksum: 10/d682d0b1e342ed3eda1a5ddab39c8ddac33afc9edb2c7335a2f9a28eb8c268b975bbf450a3bad5443138edebaf2aa731dca0b774bcf3211a6dc215b35d86d849 + checksum: 10/5428160baccd7c8d8248e1a78afcd75882842f5fbe8aecccfc3dc6b3cab0698b45a3628566c54a137a09c094ae610a5a84da9f74801ecab548eb10a494e72f76 languageName: node linkType: hard @@ -4505,26 +6047,19 @@ __metadata: languageName: node linkType: hard -"@firebase/remote-config-compat@npm:0.2.9": - version: 0.2.9 - resolution: "@firebase/remote-config-compat@npm:0.2.9" +"@firebase/remote-config-compat@npm:0.2.12": + version: 0.2.12 + resolution: "@firebase/remote-config-compat@npm:0.2.12" dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/logger": "npm:0.4.2" - "@firebase/remote-config": "npm:0.4.9" - "@firebase/remote-config-types": "npm:0.3.2" - "@firebase/util": "npm:1.10.0" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/remote-config": "npm:0.5.0" + "@firebase/remote-config-types": "npm:0.4.0" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app-compat": 0.x - checksum: 10/a6db7509512d8d22b7ddf1127c741715e379e04e5b3246372bb0302d7c84afb421a94550adebecddcce5def115d61729a9580940dce6e65f8d77f9af30f69fe1 - languageName: node - linkType: hard - -"@firebase/remote-config-types@npm:0.3.2": - version: 0.3.2 - resolution: "@firebase/remote-config-types@npm:0.3.2" - checksum: 10/6c91599c653825708aba9fe9e4562997f108c3e4f3eaf5d188f31c859a6ad013414aa7a213b6b021b68049dd0dd57158546dbc9fb64384652274ef7f57ce7d7d + checksum: 10/931c4739c2b11b2719076630f09f5aa18f9edf8e89cf35c9b9a3a8cc5afc497c86e68cca165e1416afcb0b8040ed04363c676d31118fdcf4bf3823ef9172785c languageName: node linkType: hard @@ -4535,6 +6070,13 @@ __metadata: languageName: node linkType: hard +"@firebase/remote-config-types@npm:0.4.0": + version: 0.4.0 + resolution: "@firebase/remote-config-types@npm:0.4.0" + checksum: 10/67de8c448412974bdbdc10b6bca90d957fa81f967553ff9a4aee316d374f9ebb3a24fa2541af639c1a1ece79070fab0ab64c925bcf6bb807e212cba3297e5ddf + languageName: node + linkType: hard + "@firebase/remote-config@npm:0.4.11": version: 0.4.11 resolution: "@firebase/remote-config@npm:0.4.11" @@ -4550,33 +6092,18 @@ __metadata: languageName: node linkType: hard -"@firebase/remote-config@npm:0.4.9": - version: 0.4.9 - resolution: "@firebase/remote-config@npm:0.4.9" +"@firebase/remote-config@npm:0.5.0": + version: 0.5.0 + resolution: "@firebase/remote-config@npm:0.5.0" dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/installations": "npm:0.6.9" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.10.0" + "@firebase/component": "npm:0.6.12" + "@firebase/installations": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" peerDependencies: "@firebase/app": 0.x - checksum: 10/f14189f38c8cf75db16bf8b85dd004486b1dd8242f62d697c716fa85cd32928aed549ccea8c632a528870a424fc7f04f1132a14b3b099276cd7696c78e644b28 - languageName: node - linkType: hard - -"@firebase/storage-compat@npm:0.3.12": - version: 0.3.12 - resolution: "@firebase/storage-compat@npm:0.3.12" - dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/storage": "npm:0.13.2" - "@firebase/storage-types": "npm:0.8.2" - "@firebase/util": "npm:1.10.0" - tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app-compat": 0.x - checksum: 10/4eea49a57f1d7537da697e5ff8b4e035ff1af69e416e7eab14485753c39c25eaa5a71bd2bafba0985ac6a7ce803f98f2f2f83c613c78c8f74bce286e3259b8ec + checksum: 10/58a6fad255d3975700e65d4d19ec3360703f920bcbd3bd2ff21f239367af7405bfec5fddf3f800fb405dd4e4456f73cdf0c5cbf624a9512d77293f7cf14b64d8 languageName: node linkType: hard @@ -4595,13 +6122,18 @@ __metadata: languageName: node linkType: hard -"@firebase/storage-types@npm:0.8.2": - version: 0.8.2 - resolution: "@firebase/storage-types@npm:0.8.2" +"@firebase/storage-compat@npm:0.3.16": + version: 0.3.16 + resolution: "@firebase/storage-compat@npm:0.3.16" + dependencies: + "@firebase/component": "npm:0.6.12" + "@firebase/storage": "npm:0.13.6" + "@firebase/storage-types": "npm:0.8.3" + "@firebase/util": "npm:1.10.3" + tslib: "npm:^2.1.0" peerDependencies: - "@firebase/app-types": 0.x - "@firebase/util": 1.x - checksum: 10/e00716932370d2004dc9f7ef6d7e3aff72305b91569fa6ec15e8bc2ec784b03a150391e8be2c063234edbbfda7796da915d48e26ce2f1f7c5d3343acd39afd99 + "@firebase/app-compat": 0.x + checksum: 10/485aebbaf56875783eb02be29b6398e700d935a9cd6c7bbc92b27b230ad3021f75c75cdef64a7f3f90fa679207916362b0b202b2c10ad3631d256f8acd0bab8f languageName: node linkType: hard @@ -4615,20 +6147,6 @@ __metadata: languageName: node linkType: hard -"@firebase/storage@npm:0.13.2": - version: 0.13.2 - resolution: "@firebase/storage@npm:0.13.2" - dependencies: - "@firebase/component": "npm:0.6.9" - "@firebase/util": "npm:1.10.0" - tslib: "npm:^2.1.0" - undici: "npm:6.19.7" - peerDependencies: - "@firebase/app": 0.x - checksum: 10/d887f80cf95ef5daa80ffb2e6d564d25abb8a3e84099bee9730c95082597a12028bbf73bfe66fca2df3cdf04eaadea8e9d74ec0a826f946bc8f002293a9983ea - languageName: node - linkType: hard - "@firebase/storage@npm:0.13.4": version: 0.13.4 resolution: "@firebase/storage@npm:0.13.4" @@ -4642,12 +6160,16 @@ __metadata: languageName: node linkType: hard -"@firebase/util@npm:1.10.0": - version: 1.10.0 - resolution: "@firebase/util@npm:1.10.0" +"@firebase/storage@npm:0.13.6": + version: 0.13.6 + resolution: "@firebase/storage@npm:0.13.6" dependencies: + "@firebase/component": "npm:0.6.12" + "@firebase/util": "npm:1.10.3" tslib: "npm:^2.1.0" - checksum: 10/eb161f1c6294ff097f3c40236820e9e6e29cd6582e5e1254148157143272493580535ee2cb9e7c6055d3909b3ef39d8b64086895b071c665827acb66742b63eb + peerDependencies: + "@firebase/app": 0.x + checksum: 10/f82707d28a993cc92717f39cd39d0b77fe5be20a8f95ccf357c9a6e38b037b6d9210b08b9bad0eb64aac11dd7f49c76e167ba68439a237ec776996e59b3555a2 languageName: node linkType: hard @@ -4660,19 +6182,12 @@ __metadata: languageName: node linkType: hard -"@firebase/vertexai-preview@npm:0.0.4": - version: 0.0.4 - resolution: "@firebase/vertexai-preview@npm:0.0.4" +"@firebase/util@npm:1.10.3": + version: 1.10.3 + resolution: "@firebase/util@npm:1.10.3" dependencies: - "@firebase/app-check-interop-types": "npm:0.3.2" - "@firebase/component": "npm:0.6.9" - "@firebase/logger": "npm:0.4.2" - "@firebase/util": "npm:1.10.0" tslib: "npm:^2.1.0" - peerDependencies: - "@firebase/app": 0.x - "@firebase/app-types": 0.x - checksum: 10/8ec48d81f48aebdcc63b65d802c67bf36880f256e5c2f5f3b152dc91c8c0e924053fba2fac5218716612f8ee720b25d0822337a214f16f5b7e51ce0247dfc4e5 + checksum: 10/8e5e1664a09798348abfa0cd138157943f8ee9c6e3804e6cb1dcff004b351a03f14f4b2711338133bb89f7f824546664af2c2aa98e229becbc9294cdddeecc99 languageName: node linkType: hard @@ -4692,10 +6207,19 @@ __metadata: languageName: node linkType: hard -"@firebase/webchannel-wrapper@npm:1.0.1": - version: 1.0.1 - resolution: "@firebase/webchannel-wrapper@npm:1.0.1" - checksum: 10/22fc7e1e6dd36ab7c13f3a6c1ff51f4d405304424dc323cb146109e7a3ab3b592e2ddb29f53197ee5719a8448cdedb98d9e86a080f9365e389f8429b1c6555c2 +"@firebase/vertexai@npm:1.0.4": + version: 1.0.4 + resolution: "@firebase/vertexai@npm:1.0.4" + dependencies: + "@firebase/app-check-interop-types": "npm:0.3.3" + "@firebase/component": "npm:0.6.12" + "@firebase/logger": "npm:0.4.4" + "@firebase/util": "npm:1.10.3" + tslib: "npm:^2.1.0" + peerDependencies: + "@firebase/app": 0.x + "@firebase/app-types": 0.x + checksum: 10/13eb8ac4a94c6166ed138c7bab28dbab687341b094dfa0ca1d3c19d10002554a5eb4214efacb927480fb6691ee8d055bbf5ded79ef3441ee99422246fc347410 languageName: node linkType: hard @@ -4737,7 +6261,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/react-native@npm:^0.10.6": +"@floating-ui/react-native@npm:^0.10.7": version: 0.10.7 resolution: "@floating-ui/react-native@npm:0.10.7" dependencies: @@ -4749,17 +6273,17 @@ __metadata: languageName: node linkType: hard -"@floating-ui/react@npm:^0.26.24": - version: 0.26.28 - resolution: "@floating-ui/react@npm:0.26.28" +"@floating-ui/react@npm:^0.27.4": + version: 0.27.8 + resolution: "@floating-ui/react@npm:0.27.8" dependencies: "@floating-ui/react-dom": "npm:^2.1.2" - "@floating-ui/utils": "npm:^0.2.8" + "@floating-ui/utils": "npm:^0.2.9" tabbable: "npm:^6.0.0" peerDependencies: - react: ">=16.8.0" - react-dom: ">=16.8.0" - checksum: 10/7f8e6b27db48b68ca94756687af21857be04e7360ac922d7c8e22411f2895df6384af7bd40f4b48663d3cc5809bb5c6574cd9c9ea15543ec747b9a8e1c8c3008 + react: ">=17.0.0" + react-dom: ">=17.0.0" + checksum: 10/9423e3b7d6298918cb14ad3cfa36ba7b88ef86f18c4c47f7870b367716e918757dc0cf9842ef4995ee784b68aebe725c28c2ae143ecfe16380d3ccfd05cacb43 languageName: node linkType: hard @@ -4770,52 +6294,157 @@ __metadata: languageName: node linkType: hard -"@formatjs/ecma402-abstract@npm:1.11.4": - version: 1.11.4 - resolution: "@formatjs/ecma402-abstract@npm:1.11.4" +"@floating-ui/utils@npm:^0.2.9": + version: 0.2.9 + resolution: "@floating-ui/utils@npm:0.2.9" + checksum: 10/0ca786347db3dd8d9034b86d1449fabb96642788e5900cc5f2aee433cd7b243efbcd7a165bead50b004ee3f20a90ddebb6a35296fc41d43cfd361b6f01b69ffb + languageName: node + linkType: hard + +"@formatjs/ecma402-abstract@npm:2.3.3": + version: 2.3.3 + resolution: "@formatjs/ecma402-abstract@npm:2.3.3" dependencies: - "@formatjs/intl-localematcher": "npm:0.2.25" - tslib: "npm:^2.1.0" - checksum: 10/2bdab58db3fc78cba3bb6b6c4abe98bde20c599ab2834438faaff45d7dbdaa32b1a8a7a898bc4a038e8ae3e2baf9d48ec287be798c2588dc7a32e4cdbfd0039f + "@formatjs/fast-memoize": "npm:2.2.6" + "@formatjs/intl-localematcher": "npm:0.6.0" + decimal.js: "npm:10" + tslib: "npm:2" + checksum: 10/c73a704d5cba3b929a9a303a04b4e8a708bb2dea000ee41808c55b22941a70da01b065697cbc5a25898b2007405abd71f414ab8c327fe953f63ae4120c2cc98d languageName: node linkType: hard -"@formatjs/fast-memoize@npm:1.2.1": - version: 1.2.1 - resolution: "@formatjs/fast-memoize@npm:1.2.1" +"@formatjs/ecma402-abstract@npm:2.3.4": + version: 2.3.4 + resolution: "@formatjs/ecma402-abstract@npm:2.3.4" dependencies: - tslib: "npm:^2.1.0" - checksum: 10/7df9e941142be16e5862afe7387926cec44ec136d2c2f9a7e1598cb6c8c23a65e420ed90251ec9b48df083f5473b10d6fbbee2e9fc7233d5bf1f27efffba59a7 + "@formatjs/fast-memoize": "npm:2.2.7" + "@formatjs/intl-localematcher": "npm:0.6.1" + decimal.js: "npm:^10.4.3" + tslib: "npm:^2.8.0" + checksum: 10/573971ffc291096a4b9fcc80b4708124e89bf2e3ac50e0f78b41eb797e9aa1b842f4dc3665e4467a853c738386821769d9e40408a1d25bc73323a1f057a16cf2 languageName: node linkType: hard -"@formatjs/icu-messageformat-parser@npm:2.1.0": - version: 2.1.0 - resolution: "@formatjs/icu-messageformat-parser@npm:2.1.0" +"@formatjs/fast-memoize@npm:2.2.6": + version: 2.2.6 + resolution: "@formatjs/fast-memoize@npm:2.2.6" dependencies: - "@formatjs/ecma402-abstract": "npm:1.11.4" - "@formatjs/icu-skeleton-parser": "npm:1.3.6" - tslib: "npm:^2.1.0" - checksum: 10/74b5bee8ec2d793e2ad6084f53013dc6a5a4205190fe7fb15d36a5169a659fda6ccb1815812acc9d4e02edaaba9656180cad7e74cd9c4484aeb981ae3f5f81d7 + tslib: "npm:2" + checksum: 10/efa5601dddbd94412ee567d5d067dfd206afa2d08553435f6938e69acba3309b83b9b15021cd30550d5fb93817a53b7691098a11a73f621c2d9318efad49fd76 languageName: node linkType: hard -"@formatjs/icu-skeleton-parser@npm:1.3.6": - version: 1.3.6 - resolution: "@formatjs/icu-skeleton-parser@npm:1.3.6" +"@formatjs/fast-memoize@npm:2.2.7": + version: 2.2.7 + resolution: "@formatjs/fast-memoize@npm:2.2.7" dependencies: - "@formatjs/ecma402-abstract": "npm:1.11.4" - tslib: "npm:^2.1.0" - checksum: 10/01a1b86d208cc8453707d688e5c0d023c7a7ea1d26e59968cdeb2f33976bcb1d22030a4438d88587b32563c651108186cb4ddebf23a047e3b263d47c6d905b2f + tslib: "npm:^2.8.0" + checksum: 10/e7e6efc677d63a13d99a854305db471b69f64cbfebdcb6dbe507dab9aa7eaae482ca5de86f343c856ca0a2c8f251672bd1f37c572ce14af602c0287378097d43 languageName: node linkType: hard -"@formatjs/intl-localematcher@npm:0.2.25": - version: 0.2.25 - resolution: "@formatjs/intl-localematcher@npm:0.2.25" +"@formatjs/icu-messageformat-parser@npm:2.11.1": + version: 2.11.1 + resolution: "@formatjs/icu-messageformat-parser@npm:2.11.1" dependencies: - tslib: "npm:^2.1.0" - checksum: 10/ee00ddc23317dc47a58831aaca5112e101d8bb1f38adc0ecfe1a9d7e008d0bb1091519f07e1d7d805b0c1e28f2c3e75f697ae479e22423445814412c7669284c + "@formatjs/ecma402-abstract": "npm:2.3.3" + "@formatjs/icu-skeleton-parser": "npm:1.8.13" + tslib: "npm:2" + checksum: 10/86db326b859e2d9c3e8855cb8ca28864e1bf48f80489312b204bc51fd03f69e275509c086c599b9b171f33a02065f2c40d16f26bc5650e98aa30dce8a47fa3a8 + languageName: node + linkType: hard + +"@formatjs/icu-skeleton-parser@npm:1.8.13": + version: 1.8.13 + resolution: "@formatjs/icu-skeleton-parser@npm:1.8.13" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.3.3" + tslib: "npm:2" + checksum: 10/514e7b9cc2123f666ed5d27d27e31ae0ee8cbcb53695c0363fdf7c92ec8b5515ddb39836ffdfabe8282e259ac73fe41a6da110433037057d2c34625b51e6bc5f + languageName: node + linkType: hard + +"@formatjs/intl-displaynames@npm:^6.8.11": + version: 6.8.11 + resolution: "@formatjs/intl-displaynames@npm:6.8.11" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.3.4" + "@formatjs/intl-localematcher": "npm:0.6.1" + tslib: "npm:^2.8.0" + checksum: 10/05c785d9e767cc1e4d1bd40d6989c3318b6a98cb43dd6808f501f5e5538bb3a1fb8fa80f8d2282d598501d3d193a406f0127acce6b14cb7c595ab6d981437e6f + languageName: node + linkType: hard + +"@formatjs/intl-enumerator@npm:1.8.9": + version: 1.8.9 + resolution: "@formatjs/intl-enumerator@npm:1.8.9" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.3.3" + tslib: "npm:2" + checksum: 10/cc7290147ce6331b8803307af70a68583392fb5df4b74e2441cbda766528c6104ec96f31eaa5a41478236e04b579bea65a1a819f7c7292c891fe063701f157f6 + languageName: node + linkType: hard + +"@formatjs/intl-getcanonicallocales@npm:2.5.4, @formatjs/intl-getcanonicallocales@npm:^2.5.4": + version: 2.5.4 + resolution: "@formatjs/intl-getcanonicallocales@npm:2.5.4" + dependencies: + tslib: "npm:2" + checksum: 10/69c2cf0b0fc9f85cd7845b27733b5c9796a935f30968ab446f6d32a8563edeba9ee1abd680763e66e7543d17244729d3f474e06582b217ef63023237490d73d9 + languageName: node + linkType: hard + +"@formatjs/intl-locale@npm:^4.2.10": + version: 4.2.10 + resolution: "@formatjs/intl-locale@npm:4.2.10" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.3.3" + "@formatjs/intl-enumerator": "npm:1.8.9" + "@formatjs/intl-getcanonicallocales": "npm:2.5.4" + tslib: "npm:2" + checksum: 10/f6e5508ade093135df82da070436898027184d61a62f804e775d28bbfb0bd7512c593823362630cb3a2d5c86ebde5fe8a3c07891a7a94a6d03016a045006832b + languageName: node + linkType: hard + +"@formatjs/intl-localematcher@npm:0.6.0": + version: 0.6.0 + resolution: "@formatjs/intl-localematcher@npm:0.6.0" + dependencies: + tslib: "npm:2" + checksum: 10/d8fd984c14121949d0ba60732a096aed6dccb2ab93770c4bffaea1170c85f5639d2a71fe6e2c68ab62bf6f3583a9c4b1fcd11bd5fb8837bfb2582228c33398c1 + languageName: node + linkType: hard + +"@formatjs/intl-localematcher@npm:0.6.1": + version: 0.6.1 + resolution: "@formatjs/intl-localematcher@npm:0.6.1" + dependencies: + tslib: "npm:^2.8.0" + checksum: 10/c7b3bc8395d18670677f207b2fd107561fff5d6394a9b4273c29e0bea920300ec3a2eefead600ebb7761c04a770cada28f78ac059f84d00520bfb57a9db36998 + languageName: node + linkType: hard + +"@formatjs/intl-numberformat@npm:^8.15.3": + version: 8.15.3 + resolution: "@formatjs/intl-numberformat@npm:8.15.3" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.3.3" + "@formatjs/intl-localematcher": "npm:0.6.0" + decimal.js: "npm:10" + tslib: "npm:2" + checksum: 10/5688885dadcb6d56b552893a99024fe56e89f2323507af229780e1477dceb6dc21bc74f3460ce8016364ebbfe03d9b3f4e3026c56de7a475735a1f37147cf528 + languageName: node + linkType: hard + +"@formatjs/intl-pluralrules@npm:^5.4.3": + version: 5.4.3 + resolution: "@formatjs/intl-pluralrules@npm:5.4.3" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.3.3" + "@formatjs/intl-localematcher": "npm:0.6.0" + decimal.js: "npm:10" + tslib: "npm:2" + checksum: 10/99192aef736bf2b3642f4e5eaea68fc6443823222a6c48edd6a111d8e1e4e969a3c5e24acb33f2bc375d55e84b829fb387a4eee7e2612f8cf7565fbd43c3f1dd languageName: node linkType: hard @@ -4848,9 +6477,9 @@ __metadata: languageName: node linkType: hard -"@gorhom/bottom-sheet@npm:^5.0.6": - version: 5.0.6 - resolution: "@gorhom/bottom-sheet@npm:5.0.6" +"@gorhom/bottom-sheet@npm:^5.1.1": + version: 5.1.1 + resolution: "@gorhom/bottom-sheet@npm:5.1.1" dependencies: "@gorhom/portal": "npm:1.0.14" invariant: "npm:^2.2.4" @@ -4866,7 +6495,7 @@ __metadata: optional: true "@types/react-native": optional: true - checksum: 10/b8beb6bb4d80828deed5c74c8b19926cbb4887d530d07c19eaaa1d8faae6c79f942f83baad076c84d065f357f6666cbc257799fe8fc58cb375e6cbebf52b6227 + checksum: 10/6da29c3927f6a0b6d2a459e2a1cae48ce7c0fce41663b46b0aba98490af15199e31248f83ca18753eeb439faf2c1dd957f26e4eacc9796b4962df44170b1a2d3 languageName: node linkType: hard @@ -4922,7 +6551,18 @@ __metadata: languageName: node linkType: hard -"@humanfs/core@npm:^0.19.1": +"@hookform/resolvers@npm:^4.1.3": + version: 4.1.3 + resolution: "@hookform/resolvers@npm:4.1.3" + dependencies: + "@standard-schema/utils": "npm:^0.3.0" + peerDependencies: + react-hook-form: ^7.0.0 + checksum: 10/7cd4a872509c79053fd3a377bc2831348453b60f1af41564442b574d4f36b6303f751e715d01c2d9291b484581ae0152045e779aefafdd66802055e24eff7762 + languageName: node + linkType: hard + +"@humanfs/core@npm:^0.19.1": version: 0.19.1 resolution: "@humanfs/core@npm:0.19.1" checksum: 10/270d936be483ab5921702623bc74ce394bf12abbf57d9145a69e8a0d1c87eb1c768bd2d93af16c5705041e257e6d9cc7529311f63a1349f3678abc776fc28523 @@ -4953,10 +6593,10 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/retry@npm:^0.4.1": - version: 0.4.1 - resolution: "@humanwhocodes/retry@npm:0.4.1" - checksum: 10/39fafc7319e88f61befebd5e1b4f0136534ea6a9bd10d74366698187bd63544210ec5d79a87ed4d91297f1cc64c4c53d45fb0077a2abfdce212cf0d3862d5f04 +"@humanwhocodes/retry@npm:^0.4.2": + version: 0.4.3 + resolution: "@humanwhocodes/retry@npm:0.4.3" + checksum: 10/0b32cfd362bea7a30fbf80bb38dcaf77fee9c2cae477ee80b460871d03590110ac9c77d654f04ec5beaf71b6f6a89851bdf6c1e34ccdf2f686bd86fcd97d9e61 languageName: node linkType: hard @@ -5180,6 +6820,32 @@ __metadata: languageName: node linkType: hard +"@inversifyjs/common@npm:1.4.0": + version: 1.4.0 + resolution: "@inversifyjs/common@npm:1.4.0" + checksum: 10/39745f440c97fab25b1068a59c415a08620049c99502405840590c29c0e96cd8ebb544100da7f1fc933d9aa03ad14297df0120097b74bd3363e215b80a7e74e2 + languageName: node + linkType: hard + +"@inversifyjs/core@npm:1.3.5": + version: 1.3.5 + resolution: "@inversifyjs/core@npm:1.3.5" + dependencies: + "@inversifyjs/common": "npm:1.4.0" + "@inversifyjs/reflect-metadata-utils": "npm:0.2.4" + checksum: 10/890993e6dcf93b0e362afaf662e8f5db16bf3934611685acb58cd88963fdfc3ec6774db7cb6b89f0458f8a2c4388e5435b18712a7045ea48c756c21bab085291 + languageName: node + linkType: hard + +"@inversifyjs/reflect-metadata-utils@npm:0.2.4": + version: 0.2.4 + resolution: "@inversifyjs/reflect-metadata-utils@npm:0.2.4" + peerDependencies: + reflect-metadata: 0.2.2 + checksum: 10/83d657d3bfdd86b1a5f96820fed9e790493dc8d178aee432839ee4e9f2b071f159e78c21b71157ebb798a37e6e2a0948110f9cc0512ca1224690d1d4bba2d515 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -5285,7 +6951,7 @@ __metadata: languageName: node linkType: hard -"@jest/create-cache-key-function@npm:^29.2.1, @jest/create-cache-key-function@npm:^29.6.3": +"@jest/create-cache-key-function@npm:^29.2.1, @jest/create-cache-key-function@npm:^29.7.0": version: 29.7.0 resolution: "@jest/create-cache-key-function@npm:29.7.0" dependencies: @@ -5469,7 +7135,18 @@ __metadata: languageName: node linkType: hard -"@jridgewell/gen-mapping@npm:^0.3.2, @jridgewell/gen-mapping@npm:^0.3.5": +"@jridgewell/gen-mapping@npm:^0.3.2": + version: 0.3.5 + resolution: "@jridgewell/gen-mapping@npm:0.3.5" + dependencies: + "@jridgewell/set-array": "npm:^1.2.1" + "@jridgewell/sourcemap-codec": "npm:^1.4.10" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10/81587b3c4dd8e6c60252122937cea0c637486311f4ed208b52b62aae2e7a87598f63ec330e6cd0984af494bfb16d3f0d60d3b21d7e5b4aedd2602ff3fe9d32e2 + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.3.5": version: 0.3.8 resolution: "@jridgewell/gen-mapping@npm:0.3.8" dependencies: @@ -5538,6 +7215,86 @@ __metadata: languageName: node linkType: hard +"@ledgerhq/context-module@npm:^1.3.1": + version: 1.3.1 + resolution: "@ledgerhq/context-module@npm:1.3.1" + dependencies: + axios: "npm:^1.8.2" + crypto-js: "npm:^4.2.0" + ethers: "npm:6.13.4" + inversify: "npm:^6.2.2" + purify-ts: "npm:^2.1.0" + reflect-metadata: "npm:^0.2.2" + peerDependencies: + "@ledgerhq/device-management-kit": 0.6.5 + checksum: 10/9e90cfd990fd6ae91b7e0b762f88024500b338a1f9abe0a062caaf30d2d007cf76597297f7f0b2b10008c3c19cae4cf019278d2c9a635c116f7439f43686a735 + languageName: node + linkType: hard + +"@ledgerhq/device-management-kit@npm:^0.6.5": + version: 0.6.5 + resolution: "@ledgerhq/device-management-kit@npm:0.6.5" + dependencies: + "@sentry/minimal": "npm:^6.19.7" + axios: "npm:^1.8.2" + inversify: "npm:^6.2.2" + inversify-logger-middleware: "npm:^3.1.0" + isomorphic-ws: "npm:^5.0.0" + purify-ts: "npm:^2.1.0" + reflect-metadata: "npm:^0.2.2" + semver: "npm:^7.6.3" + url: "npm:^0.11.4" + uuid: "npm:^11.0.3" + ws: "npm:^8.18.0" + xstate: "npm:^5.19.2" + peerDependencies: + rxjs: ^7.8.2 + checksum: 10/4285640388ac2072654f1e779bb362f7ea36c67918f610c2c38b4adc88796db464032741b2e29f4b2928e4726b3680704023cdffa5e32a49232822f5d5a32387 + languageName: node + linkType: hard + +"@ledgerhq/device-signer-kit-ethereum@npm:^1.3.3": + version: 1.3.3 + resolution: "@ledgerhq/device-signer-kit-ethereum@npm:1.3.3" + dependencies: + "@ledgerhq/signer-utils": "npm:^1.0.4" + ethers: "npm:6.13.4" + inversify: "npm:^6.2.2" + inversify-logger-middleware: "npm:^3.1.0" + purify-ts: "npm:^2.1.0" + reflect-metadata: "npm:^0.2.2" + semver: "npm:^7.6.3" + xstate: "npm:^5.19.2" + peerDependencies: + "@ledgerhq/context-module": ^1.3.1 + "@ledgerhq/device-management-kit": ^0.6.5 + checksum: 10/9856040a5132267ac67f06e74176e5d5ad7c8e96405b8bfcff0e23faa97197646d9f13bd7ce97ece6258d1193781f60c0601e19c20db01791d92e61672a50a5b + languageName: node + linkType: hard + +"@ledgerhq/device-transport-kit-web-hid@npm:^1.1.0": + version: 1.1.0 + resolution: "@ledgerhq/device-transport-kit-web-hid@npm:1.1.0" + dependencies: + "@sentry/minimal": "npm:^6.19.7" + purify-ts: "npm:^2.1.0" + uuid: "npm:^11.0.3" + peerDependencies: + "@ledgerhq/device-management-kit": ">=0.6.2" + rxjs: ^7.8.2 + checksum: 10/ec959c0d647c6ed6156171a9e708cd6d185f62cc3b2048fb5bae8cb317cd42f80b8a7166dc42ed928574257bdca63bf1331b4261f90281c81be2046d5bbf6414 + languageName: node + linkType: hard + +"@ledgerhq/signer-utils@npm:^1.0.4": + version: 1.0.4 + resolution: "@ledgerhq/signer-utils@npm:1.0.4" + peerDependencies: + "@ledgerhq/device-management-kit": 0.6.2 + checksum: 10/10357e9355f48ac33baa64202e996cce8fc1d73e9bfd754c49baab3fb34b7ab0d61bc9966ee6e3f42220236c779762f15419a754451ddb9fa8d22ca5b9de1e7d + languageName: node + linkType: hard + "@lit-labs/react@npm:^1.0.2": version: 1.2.1 resolution: "@lit-labs/react@npm:1.2.1" @@ -5719,6 +7476,13 @@ __metadata: languageName: node linkType: hard +"@msgpack/msgpack@npm:3.1.2": + version: 3.1.2 + resolution: "@msgpack/msgpack@npm:3.1.2" + checksum: 10/e04ff37d7c89ffdd6b4fbcd1770af60b16c98afdf1c3c16190170dfe34764048eb45e3654016ac62cc616c7e4b09e611f8863317ca5f18b3a72974fb131e562e + languageName: node + linkType: hard + "@mswjs/interceptors@npm:^0.37.0": version: 0.37.4 resolution: "@mswjs/interceptors@npm:0.37.4" @@ -5872,7 +7636,27 @@ __metadata: languageName: node linkType: hard -"@mui/utils@npm:^5.16.6 || ^6.0.0, @mui/utils@npm:^6.3.0": +"@mui/utils@npm:^5.16.6 || ^6.0.0": + version: 6.2.1 + resolution: "@mui/utils@npm:6.2.1" + dependencies: + "@babel/runtime": "npm:^7.26.0" + "@mui/types": "npm:^7.2.20" + "@types/prop-types": "npm:^15.7.14" + clsx: "npm:^2.1.1" + prop-types: "npm:^15.8.1" + react-is: "npm:^19.0.0" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/6fc5e14ed0a7aba178640ad5f01a050980a24700fa71cf57485d5f2b1795717be51a90b39ef7fbeb58b43820f1d26436efe1b3a13fd5feb3d2ad6253534172ba + languageName: node + linkType: hard + +"@mui/utils@npm:^6.3.0": version: 6.3.0 resolution: "@mui/utils@npm:6.3.0" dependencies: @@ -5975,6 +7759,13 @@ __metadata: languageName: node linkType: hard +"@next/env@npm:15.2.4": + version: 15.2.4 + resolution: "@next/env@npm:15.2.4" + checksum: 10/2b6153fcb117f117385d5a18b61cd5696cf87c93575535fd8d1bae8864c194e974f5c66061dceb9d1ca50d390bd7c846e744adafc5e4cbcebcae94a10a7c54a0 + languageName: node + linkType: hard + "@next/eslint-plugin-next@npm:15.1.1": version: 15.1.1 resolution: "@next/eslint-plugin-next@npm:15.1.1" @@ -6008,6 +7799,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-darwin-arm64@npm:15.2.4": + version: 15.2.4 + resolution: "@next/swc-darwin-arm64@npm:15.2.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@next/swc-darwin-x64@npm:15.1.2": version: 15.1.2 resolution: "@next/swc-darwin-x64@npm:15.1.2" @@ -6015,6 +7813,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-darwin-x64@npm:15.2.4": + version: 15.2.4 + resolution: "@next/swc-darwin-x64@npm:15.2.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@next/swc-linux-arm64-gnu@npm:15.1.2": version: 15.1.2 resolution: "@next/swc-linux-arm64-gnu@npm:15.1.2" @@ -6022,6 +7827,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-arm64-gnu@npm:15.2.4": + version: 15.2.4 + resolution: "@next/swc-linux-arm64-gnu@npm:15.2.4" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@next/swc-linux-arm64-musl@npm:15.1.2": version: 15.1.2 resolution: "@next/swc-linux-arm64-musl@npm:15.1.2" @@ -6029,6 +7841,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-arm64-musl@npm:15.2.4": + version: 15.2.4 + resolution: "@next/swc-linux-arm64-musl@npm:15.2.4" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@next/swc-linux-x64-gnu@npm:15.1.2": version: 15.1.2 resolution: "@next/swc-linux-x64-gnu@npm:15.1.2" @@ -6036,6 +7855,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-x64-gnu@npm:15.2.4": + version: 15.2.4 + resolution: "@next/swc-linux-x64-gnu@npm:15.2.4" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@next/swc-linux-x64-musl@npm:15.1.2": version: 15.1.2 resolution: "@next/swc-linux-x64-musl@npm:15.1.2" @@ -6043,6 +7869,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-x64-musl@npm:15.2.4": + version: 15.2.4 + resolution: "@next/swc-linux-x64-musl@npm:15.2.4" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@next/swc-win32-arm64-msvc@npm:15.1.2": version: 15.1.2 resolution: "@next/swc-win32-arm64-msvc@npm:15.1.2" @@ -6050,6 +7883,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-win32-arm64-msvc@npm:15.2.4": + version: 15.2.4 + resolution: "@next/swc-win32-arm64-msvc@npm:15.2.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@next/swc-win32-x64-msvc@npm:15.1.2": version: 15.1.2 resolution: "@next/swc-win32-x64-msvc@npm:15.1.2" @@ -6057,6 +7897,46 @@ __metadata: languageName: node linkType: hard +"@next/swc-win32-x64-msvc@npm:15.2.4": + version: 15.2.4 + resolution: "@next/swc-win32-x64-msvc@npm:15.2.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@next/third-parties@npm:^15.2.0": + version: 15.2.0 + resolution: "@next/third-parties@npm:15.2.0" + dependencies: + third-party-capital: "npm:1.0.20" + peerDependencies: + next: ^13.0.0 || ^14.0.0 || ^15.0.0 + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + checksum: 10/7713a5d6e51bcbbfb7f499f905770a4cfe25638a39e99fcdaefafaa343e71ae0596802584df776c5029c4879c73adc9a04be511e920f8618e793f0fb9f726b7f + languageName: node + linkType: hard + +"@nicolo-ribaudo/chokidar-2@npm:2.1.8-no-fsevents.3": + version: 2.1.8-no-fsevents.3 + resolution: "@nicolo-ribaudo/chokidar-2@npm:2.1.8-no-fsevents.3" + checksum: 10/c6e83af3b5051a3f6562649ff8fe37de9934a4cc02138678ed1badbd13ed3334f7ae5f63f2bbc3432210f6b245f082ac97e9b2afe0c13730c9838b295658c185 + languageName: node + linkType: hard + +"@noble/ciphers@npm:1.2.1": + version: 1.2.1 + resolution: "@noble/ciphers@npm:1.2.1" + checksum: 10/7fa0d32529d8da6323b08afec97218f6d6bc0d1e135243bf10f7587a2819495c3f3f4a5af1f41045501bb1ade94238c76960366a5d6441970e49ba9cacb88740 + languageName: node + linkType: hard + +"@noble/ciphers@npm:1.3.0, @noble/ciphers@npm:^1.3.0": + version: 1.3.0 + resolution: "@noble/ciphers@npm:1.3.0" + checksum: 10/051660051e3e9e2ca5fb9dece2885532b56b7e62946f89afa7284a0fb8bc02e2bd1c06554dba68162ff42d295b54026456084198610f63c296873b2f1cd7a586 + languageName: node + linkType: hard + "@noble/curves@npm:1.2.0, @noble/curves@npm:~1.2.0": version: 1.2.0 resolution: "@noble/curves@npm:1.2.0" @@ -6075,6 +7955,42 @@ __metadata: languageName: node linkType: hard +"@noble/curves@npm:1.8.0": + version: 1.8.0 + resolution: "@noble/curves@npm:1.8.0" + dependencies: + "@noble/hashes": "npm:1.7.0" + checksum: 10/c54ce84cf54b8bda1a37a10dfae2e49e5b6cdf5dd98b399efa8b8a80a286b3f8f27bde53202cb308353bfd98719938991a78bed6e43f81f13b17f8181b7b82eb + languageName: node + linkType: hard + +"@noble/curves@npm:1.8.1, @noble/curves@npm:~1.8.1": + version: 1.8.1 + resolution: "@noble/curves@npm:1.8.1" + dependencies: + "@noble/hashes": "npm:1.7.1" + checksum: 10/e861db372cc0734b02a4c61c0f5a6688d4a7555edca3d8a9e7c846c9aa103ca52d3c3818e8bc333a1a95b5be7f370ff344668d5d759471b11c2d14c7f24b3984 + languageName: node + linkType: hard + +"@noble/curves@npm:1.9.1": + version: 1.9.1 + resolution: "@noble/curves@npm:1.9.1" + dependencies: + "@noble/hashes": "npm:1.8.0" + checksum: 10/5c82ec828ca4a4218b1666ba0ddffde17afd224d0bd5e07b64c2a0c83a3362483387f55c11cfd8db0fc046605394fe4e2c67fe024628a713e864acb541a7d2bb + languageName: node + linkType: hard + +"@noble/curves@npm:1.9.2, @noble/curves@npm:~1.9.0": + version: 1.9.2 + resolution: "@noble/curves@npm:1.9.2" + dependencies: + "@noble/hashes": "npm:1.8.0" + checksum: 10/f60f00ad86296054566b67be08fd659999bb64b692bfbf11dbe3be1f422ad4d826bf5ebb2015ce2e246538eab2b677707e0a46ffa8323a6fae7a9a30ec1fe318 + languageName: node + linkType: hard + "@noble/hashes@npm:1.3.2": version: 1.3.2 resolution: "@noble/hashes@npm:1.3.2" @@ -6089,13 +8005,34 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.6.1, @noble/hashes@npm:^1.3.3, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:^1.5.0, @noble/hashes@npm:~1.6.0": +"@noble/hashes@npm:1.6.1, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:^1.5.0, @noble/hashes@npm:~1.6.0": version: 1.6.1 resolution: "@noble/hashes@npm:1.6.1" checksum: 10/74d9ad7b1437a22ba3b877584add3367587fbf818113152f293025d20d425aa74c191d18d434797312f2270458bc9ab3241c34d14ec6115fb16438b3248f631f languageName: node linkType: hard +"@noble/hashes@npm:1.7.0": + version: 1.7.0 + resolution: "@noble/hashes@npm:1.7.0" + checksum: 10/ab038a816c8c9bb986e92797e3d9c5a5b37c020e0c3edc55bcae5061dbdd457f1f0a22787f83f4787c17415ba0282a20a1e455d36ed0cdcace4ce21ef1869f60 + languageName: node + linkType: hard + +"@noble/hashes@npm:1.7.1, @noble/hashes@npm:^1.1.2, @noble/hashes@npm:~1.7.1": + version: 1.7.1 + resolution: "@noble/hashes@npm:1.7.1" + checksum: 10/ca3120da0c3e7881d6a481e9667465cc9ebbee1329124fb0de442e56d63fef9870f8cc96f264ebdb18096e0e36cebc0e6e979a872d545deb0a6fed9353f17e05 + languageName: node + linkType: hard + +"@noble/hashes@npm:1.8.0, @noble/hashes@npm:~1.8.0": + version: 1.8.0 + resolution: "@noble/hashes@npm:1.8.0" + checksum: 10/474b7f56bc6fb2d5b3a42132561e221b0ea4f91e590f4655312ca13667840896b34195e2b53b7f097ec080a1fdd3b58d902c2a8d0fbdf51d2e238b53808a177e + languageName: node + linkType: hard + "@noble/hashes@npm:~1.3.0, @noble/hashes@npm:~1.3.2": version: 1.3.3 resolution: "@noble/hashes@npm:1.3.3" @@ -6159,15 +8096,6 @@ __metadata: languageName: node linkType: hard -"@npmcli/fs@npm:^3.1.0": - version: 3.1.1 - resolution: "@npmcli/fs@npm:3.1.1" - dependencies: - semver: "npm:^7.3.5" - checksum: 10/1e0e04087049b24b38bc0b30d87a9388ee3ca1d3fdfc347c2f77d84fcfe6a51f250bc57ba2c1f614d7e4285c6c62bf8c769bc19aa0949ea39e5b043ee023b0bd - languageName: node - linkType: hard - "@npmcli/fs@npm:^4.0.0": version: 4.0.0 resolution: "@npmcli/fs@npm:4.0.0" @@ -6224,6 +8152,62 @@ __metadata: languageName: node linkType: hard +"@oxc-transform/binding-darwin-arm64@npm:0.47.1": + version: 0.47.1 + resolution: "@oxc-transform/binding-darwin-arm64@npm:0.47.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxc-transform/binding-darwin-x64@npm:0.47.1": + version: 0.47.1 + resolution: "@oxc-transform/binding-darwin-x64@npm:0.47.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxc-transform/binding-linux-arm64-gnu@npm:0.47.1": + version: 0.47.1 + resolution: "@oxc-transform/binding-linux-arm64-gnu@npm:0.47.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@oxc-transform/binding-linux-arm64-musl@npm:0.47.1": + version: 0.47.1 + resolution: "@oxc-transform/binding-linux-arm64-musl@npm:0.47.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@oxc-transform/binding-linux-x64-gnu@npm:0.47.1": + version: 0.47.1 + resolution: "@oxc-transform/binding-linux-x64-gnu@npm:0.47.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@oxc-transform/binding-linux-x64-musl@npm:0.47.1": + version: 0.47.1 + resolution: "@oxc-transform/binding-linux-x64-musl@npm:0.47.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxc-transform/binding-win32-arm64-msvc@npm:0.47.1": + version: 0.47.1 + resolution: "@oxc-transform/binding-win32-arm64-msvc@npm:0.47.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxc-transform/binding-win32-x64-msvc@npm:0.47.1": + version: 0.47.1 + resolution: "@oxc-transform/binding-win32-x64-msvc@npm:0.47.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@parcel/watcher-android-arm64@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-android-arm64@npm:2.5.0" @@ -6379,6 +8363,17 @@ __metadata: languageName: node linkType: hard +"@peculiar/asn1-schema@npm:^2.3.13": + version: 2.4.0 + resolution: "@peculiar/asn1-schema@npm:2.4.0" + dependencies: + asn1js: "npm:^3.0.6" + pvtsutils: "npm:^1.3.6" + tslib: "npm:^2.8.1" + checksum: 10/026d586282e480d6cb69edd94f1e0a5dd59e696fe13ca469dc4afc9f1e6d6ec85ec2affdf02a402c694e2f27fb5e3422c9ac64a6bcc68fcb21835565fb7838a9 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -6393,6 +8388,13 @@ __metadata: languageName: node linkType: hard +"@pkgr/core@npm:^0.2.4": + version: 0.2.4 + resolution: "@pkgr/core@npm:0.2.4" + checksum: 10/8544f0346c3f7035b9e2fdf60179c68b12d3c76b3fba9533844099af67cf5c0ce5257538f5faa05953d48cc1536d046f003231f321b2f75b3fb449db8410a2b7 + languageName: node + linkType: hard + "@pmmmwh/react-refresh-webpack-plugin@npm:^0.5.11": version: 0.5.15 resolution: "@pmmmwh/react-refresh-webpack-plugin@npm:0.5.15" @@ -6517,37 +8519,42 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-compose-refs@npm:1.0.0": - version: 1.0.0 - resolution: "@radix-ui/react-compose-refs@npm:1.0.0" - dependencies: - "@babel/runtime": "npm:^7.13.10" +"@radix-ui/react-compose-refs@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-compose-refs@npm:1.1.2" peerDependencies: - react: ^16.8 || ^17.0 || ^18.0 - checksum: 10/fb98be2e275a1a758ccac647780ff5b04be8dcf25dcea1592db3b691fecf719c4c0700126da605b2f512dd89caa111352b9fad59528d736b4e0e9a0e134a74a1 + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/9a91f0213014ffa40c5b8aae4debb993be5654217e504e35aa7422887eb2d114486d37e53c482d0fffb00cd44f51b5269fcdf397b280c71666fa11b7f32f165d languageName: node linkType: hard -"@radix-ui/react-slot@npm:1.0.1": - version: 1.0.1 - resolution: "@radix-ui/react-slot@npm:1.0.1" +"@radix-ui/react-slot@npm:1.2.0": + version: 1.2.0 + resolution: "@radix-ui/react-slot@npm:1.2.0" dependencies: - "@babel/runtime": "npm:^7.13.10" - "@radix-ui/react-compose-refs": "npm:1.0.0" + "@radix-ui/react-compose-refs": "npm:1.1.2" peerDependencies: - react: ^16.8 || ^17.0 || ^18.0 - checksum: 10/b00fc6ec54a20785263540d9e4a0e3a13d9bc54d7af49b64f6a268eba4a6560c291bd95bbaa7cf7609fdf6fd0ebae54605bb01313de3fa180b06f2a321e9a3b4 + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/6ce4fcf416de56928e9df8b8a6f30536b24c29e9d39db16873a065d62e5f009d47eee33b936cbe1da51dd39644bbd65b8c6ba05f6b6bbdc4070f5d94b8cd6c44 languageName: node linkType: hard -"@react-native-async-storage/async-storage@npm:1.23.1": - version: 1.23.1 - resolution: "@react-native-async-storage/async-storage@npm:1.23.1" +"@react-native-async-storage/async-storage@npm:2.1.2": + version: 2.1.2 + resolution: "@react-native-async-storage/async-storage@npm:2.1.2" dependencies: merge-options: "npm:^3.0.4" peerDependencies: - react-native: ^0.0.0-0 || >=0.60 <1.0 - checksum: 10/9d0acc7edb0ba9ee414d6e62c656e5d571c9d9615e7e9f4748865b965178998f2e73786b8050387ada00ddb6faaca97b5cf2e010ceaeebe91571991364a0f515 + react-native: ^0.0.0-0 || >=0.65 <1.0 + checksum: 10/d4f14fe520a1664935ea771db86be397902b743ae7dd805d7cfe142698bcb9758bfe075fe11f20347cc5551ee0673f15822be3127658957a276ac28e0c8dd5ca languageName: node linkType: hard @@ -6578,9 +8585,9 @@ __metadata: languageName: node linkType: hard -"@react-native-community/datetimepicker@npm:8.2.0": - version: 8.2.0 - resolution: "@react-native-community/datetimepicker@npm:8.2.0" +"@react-native-community/datetimepicker@npm:8.3.0": + version: 8.3.0 + resolution: "@react-native-community/datetimepicker@npm:8.3.0" dependencies: invariant: "npm:^2.2.4" peerDependencies: @@ -6593,22 +8600,33 @@ __metadata: optional: true react-native-windows: optional: true - checksum: 10/57b51a9423d3ba49029e2d9905db040f7091285c51578eef5273baf3dd2e22bde6d1a17962a8dc5e0ac588dccff0ab36c9774f9da76363fca458ec42d30c4d66 + checksum: 10/69adaaadbd8b57d1c049d0ca92dddad62b5c8dfba50e4660303c30746d39e9e976d6d61f59f001d5df15f25832dc740225fe394c667bd3a76dc42b66efc061d9 + languageName: node + linkType: hard + +"@react-native-community/slider@npm:4.5.6": + version: 4.5.6 + resolution: "@react-native-community/slider@npm:4.5.6" + checksum: 10/9c892ccdce9971e6cf69a91e3e8caab3fe668d6944f1a7195de228d5433c7fab2b15f6c945fd90464fc7b15b557ddcbf4d6b522b4b7d1a19f28ee2b4d6b6af93 languageName: node linkType: hard -"@react-native-community/slider@npm:4.5.5": - version: 4.5.5 - resolution: "@react-native-community/slider@npm:4.5.5" - checksum: 10/8c49f7e0b7d7c37b91c936805106e88ee31011dc210d5c237c07e35d330870536b560b48e6212195fffc2e2706b16c31715b6cebbb2cc33127702bff9850f134 +"@react-native-firebase/analytics@npm:^22.2.1": + version: 22.2.1 + resolution: "@react-native-firebase/analytics@npm:22.2.1" + dependencies: + superstruct: "npm:^2.0.2" + peerDependencies: + "@react-native-firebase/app": 22.2.1 + checksum: 10/b1665e2b72c36f32ae49c52201edd6a9acf5cfd3d23620bd10b59aa976ecfa8e47a1fc971fbab4554f94170fffdad7a534107a8ae05cc274bcfa2bffdf9bfeaf languageName: node linkType: hard -"@react-native-firebase/app@npm:^21.7.1": - version: 21.7.1 - resolution: "@react-native-firebase/app@npm:21.7.1" +"@react-native-firebase/app@npm:^22.2.1": + version: 22.2.1 + resolution: "@react-native-firebase/app@npm:22.2.1" dependencies: - firebase: "npm:10.13.2" + firebase: "npm:11.3.1" peerDependencies: expo: ">=47.0.0" react: "*" @@ -6616,37 +8634,62 @@ __metadata: peerDependenciesMeta: expo: optional: true - checksum: 10/33e35907cb564f6e4537eba963b202070c596c34a84027cccc343567d96f714f444cfa3e56a3f2c276c0aa9eef274a5b223f4308e70b8f0fc628db4362c24e3e + checksum: 10/93ac1c5e45f9ca1c93b8b8e1d53f53688d48b5614e24983a87a5141f1671a5c46329277d4dbaa2248b734a69b6fb428464b52d4aca2c1f1a777c81db440a0d1d languageName: node linkType: hard -"@react-native-firebase/messaging@npm:^21.7.1": - version: 21.7.1 - resolution: "@react-native-firebase/messaging@npm:21.7.1" +"@react-native-firebase/crashlytics@npm:^22.2.1": + version: 22.2.1 + resolution: "@react-native-firebase/crashlytics@npm:22.2.1" + dependencies: + stacktrace-js: "npm:^2.0.2" peerDependencies: - "@react-native-firebase/app": 21.7.1 + "@react-native-firebase/app": 22.2.1 expo: ">=47.0.0" peerDependenciesMeta: expo: optional: true - checksum: 10/035a7e07fb2ff4d7f5b60eb7ba17c55b72f4c0438649085059e724e14bbf3d7a433701e7e22ea92450ca72c14b5b1d71ecdee2d49b6a5eae7cca4e3411b6138f + checksum: 10/b6eb0a16fba273bfab5bb0901a03b1318d9dccf5a823735054a60a9d625cf19c1eceef76dbbf6bd9259fc9401a8423c45f249e74b2d46e67ee328057b370624d languageName: node linkType: hard -"@react-native-menu/menu@npm:^1.1.6": - version: 1.1.7 - resolution: "@react-native-menu/menu@npm:1.1.7" +"@react-native-firebase/messaging@npm:^22.2.1": + version: 22.2.1 + resolution: "@react-native-firebase/messaging@npm:22.2.1" + peerDependencies: + "@react-native-firebase/app": 22.2.1 + expo: ">=47.0.0" + peerDependenciesMeta: + expo: + optional: true + checksum: 10/e2cd1cb1df8d9f902ea66c807463b66182bb672602fb24751681d971cde1eada261b8238068a884797efc3291bd28e917828adc2cdd7ee2da58037b419070bf6 + languageName: node + linkType: hard + +"@react-native-masked-view/masked-view@npm:0.3.2": + version: 0.3.2 + resolution: "@react-native-masked-view/masked-view@npm:0.3.2" + peerDependencies: + react: ">=16" + react-native: ">=0.57" + checksum: 10/04ffbc01083aa563ca1e2d7ef6759e7b326b8129f5bb1aa5f3142348adab06d5e321a400cf70a5434324dfa906add383f8214640697c48c9e5311b30bfea03d9 + languageName: node + linkType: hard + +"@react-native-menu/menu@npm:^1.2.3": + version: 1.2.3 + resolution: "@react-native-menu/menu@npm:1.2.3" peerDependencies: react: "*" react-native: "*" - checksum: 10/59571ef815cb395a4961e2e68883cc6bb8b3c35b3d5153f49b5f5b46b255035a07369baf7c54628fbf5c7ed71202dc20e9317a7df54dff09e9de533285c61c28 + checksum: 10/d1970e96ce782874ebcca6f4f479b423a7fce0377a69bd94ec274372564a684fca543c36350d8d9fb92aee888b82f8beea61962db82c476de5b466d8a4c7460d languageName: node linkType: hard -"@react-native/assets-registry@npm:0.76.3": - version: 0.76.3 - resolution: "@react-native/assets-registry@npm:0.76.3" - checksum: 10/e56bf32d5900933474ff77b5441a285d6494fa8762eefcb3d3d1ffac85bade6464437142eb156f9c7214bff1a4107ff2054fe96d6e33f74b9b26001868706678 +"@react-native/assets-registry@npm:0.79.2": + version: 0.79.2 + resolution: "@react-native/assets-registry@npm:0.79.2" + checksum: 10/bca594f4524d7e53a229c6454cd6bdfc70f2f1d994f140cc322bf768507383133e99b0d5621bc49d4998ad0fc28e30361423d6f375de3658e2b98a994de0e704 languageName: node linkType: hard @@ -6659,13 +8702,78 @@ __metadata: languageName: node linkType: hard -"@react-native/babel-preset@npm:0.76.3, @react-native/babel-preset@npm:^0.76.2": - version: 0.76.3 - resolution: "@react-native/babel-preset@npm:0.76.3" +"@react-native/babel-plugin-codegen@npm:0.79.2": + version: 0.79.2 + resolution: "@react-native/babel-plugin-codegen@npm:0.79.2" dependencies: - "@babel/core": "npm:^7.25.2" - "@babel/plugin-proposal-export-default-from": "npm:^7.24.7" - "@babel/plugin-syntax-dynamic-import": "npm:^7.8.3" + "@babel/traverse": "npm:^7.25.3" + "@react-native/codegen": "npm:0.79.2" + checksum: 10/57fad0dbba59282b25a80a1e720c6e6a208b39913f76c2254d71b8615354ede55188772261d5d536f8534bd80d032ba5860cb2be91dcd4728c6977c081dea0c8 + languageName: node + linkType: hard + +"@react-native/babel-preset@npm:0.79.2": + version: 0.79.2 + resolution: "@react-native/babel-preset@npm:0.79.2" + dependencies: + "@babel/core": "npm:^7.25.2" + "@babel/plugin-proposal-export-default-from": "npm:^7.24.7" + "@babel/plugin-syntax-dynamic-import": "npm:^7.8.3" + "@babel/plugin-syntax-export-default-from": "npm:^7.24.7" + "@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3" + "@babel/plugin-syntax-optional-chaining": "npm:^7.8.3" + "@babel/plugin-transform-arrow-functions": "npm:^7.24.7" + "@babel/plugin-transform-async-generator-functions": "npm:^7.25.4" + "@babel/plugin-transform-async-to-generator": "npm:^7.24.7" + "@babel/plugin-transform-block-scoping": "npm:^7.25.0" + "@babel/plugin-transform-class-properties": "npm:^7.25.4" + "@babel/plugin-transform-classes": "npm:^7.25.4" + "@babel/plugin-transform-computed-properties": "npm:^7.24.7" + "@babel/plugin-transform-destructuring": "npm:^7.24.8" + "@babel/plugin-transform-flow-strip-types": "npm:^7.25.2" + "@babel/plugin-transform-for-of": "npm:^7.24.7" + "@babel/plugin-transform-function-name": "npm:^7.25.1" + "@babel/plugin-transform-literals": "npm:^7.25.2" + "@babel/plugin-transform-logical-assignment-operators": "npm:^7.24.7" + "@babel/plugin-transform-modules-commonjs": "npm:^7.24.8" + "@babel/plugin-transform-named-capturing-groups-regex": "npm:^7.24.7" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.24.7" + "@babel/plugin-transform-numeric-separator": "npm:^7.24.7" + "@babel/plugin-transform-object-rest-spread": "npm:^7.24.7" + "@babel/plugin-transform-optional-catch-binding": "npm:^7.24.7" + "@babel/plugin-transform-optional-chaining": "npm:^7.24.8" + "@babel/plugin-transform-parameters": "npm:^7.24.7" + "@babel/plugin-transform-private-methods": "npm:^7.24.7" + "@babel/plugin-transform-private-property-in-object": "npm:^7.24.7" + "@babel/plugin-transform-react-display-name": "npm:^7.24.7" + "@babel/plugin-transform-react-jsx": "npm:^7.25.2" + "@babel/plugin-transform-react-jsx-self": "npm:^7.24.7" + "@babel/plugin-transform-react-jsx-source": "npm:^7.24.7" + "@babel/plugin-transform-regenerator": "npm:^7.24.7" + "@babel/plugin-transform-runtime": "npm:^7.24.7" + "@babel/plugin-transform-shorthand-properties": "npm:^7.24.7" + "@babel/plugin-transform-spread": "npm:^7.24.7" + "@babel/plugin-transform-sticky-regex": "npm:^7.24.7" + "@babel/plugin-transform-typescript": "npm:^7.25.2" + "@babel/plugin-transform-unicode-regex": "npm:^7.24.7" + "@babel/template": "npm:^7.25.0" + "@react-native/babel-plugin-codegen": "npm:0.79.2" + babel-plugin-syntax-hermes-parser: "npm:0.25.1" + babel-plugin-transform-flow-enums: "npm:^0.0.2" + react-refresh: "npm:^0.14.0" + peerDependencies: + "@babel/core": "*" + checksum: 10/4cd8c2095489fe90a83e720f5c4e67f74b736d05d66ae9c5de73d6abb048fdab262eaea6416d99430601d8699bcaad68ff75890195af47298b427f29ad9f029b + languageName: node + linkType: hard + +"@react-native/babel-preset@npm:^0.76.2": + version: 0.76.3 + resolution: "@react-native/babel-preset@npm:0.76.3" + dependencies: + "@babel/core": "npm:^7.25.2" + "@babel/plugin-proposal-export-default-from": "npm:^7.24.7" + "@babel/plugin-syntax-dynamic-import": "npm:^7.8.3" "@babel/plugin-syntax-export-default-from": "npm:^7.24.7" "@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3" "@babel/plugin-syntax-optional-chaining": "npm:^7.8.3" @@ -6732,81 +8840,79 @@ __metadata: languageName: node linkType: hard -"@react-native/community-cli-plugin@npm:0.76.3": - version: 0.76.3 - resolution: "@react-native/community-cli-plugin@npm:0.76.3" +"@react-native/codegen@npm:0.79.2": + version: 0.79.2 + resolution: "@react-native/codegen@npm:0.79.2" + dependencies: + glob: "npm:^7.1.1" + hermes-parser: "npm:0.25.1" + invariant: "npm:^2.2.4" + nullthrows: "npm:^1.1.1" + yargs: "npm:^17.6.2" + peerDependencies: + "@babel/core": "*" + checksum: 10/1bb9f50f71fdc4cc32a19191715ed1bdd3e5c2c926da523e7850d79701c974c869bd6e3c815339f6a09e112d78c8a0c10ae2da7dfc5b4271775df160ff812ed7 + languageName: node + linkType: hard + +"@react-native/community-cli-plugin@npm:0.79.2": + version: 0.79.2 + resolution: "@react-native/community-cli-plugin@npm:0.79.2" dependencies: - "@react-native/dev-middleware": "npm:0.76.3" - "@react-native/metro-babel-transformer": "npm:0.76.3" + "@react-native/dev-middleware": "npm:0.79.2" chalk: "npm:^4.0.0" - execa: "npm:^5.1.1" + debug: "npm:^2.2.0" invariant: "npm:^2.2.4" - metro: "npm:^0.81.0" - metro-config: "npm:^0.81.0" - metro-core: "npm:^0.81.0" - node-fetch: "npm:^2.2.0" - readline: "npm:^1.3.0" + metro: "npm:^0.82.0" + metro-config: "npm:^0.82.0" + metro-core: "npm:^0.82.0" semver: "npm:^7.1.3" peerDependencies: - "@react-native-community/cli-server-api": "*" + "@react-native-community/cli": "*" peerDependenciesMeta: - "@react-native-community/cli-server-api": + "@react-native-community/cli": optional: true - checksum: 10/c415f26bdebb9c32230423879ca206a34343a644c307e4efc07d09a677b1394ef945b1b65d615c84913227cd03bf205315728c8cd9188e5cb51c5cb3b12b589d + checksum: 10/54359f0914fb6346a8820a6abcfd938c16c7579ffdf55cc95f44e945ee8af3b444964ccba4520a9be9a75b823201cd601113a03e271de8b9f1faa48e73602a74 languageName: node linkType: hard -"@react-native/debugger-frontend@npm:0.76.3": - version: 0.76.3 - resolution: "@react-native/debugger-frontend@npm:0.76.3" - checksum: 10/6a652a3c18fad1850ec369588e60c4503306ff307404028019d34b31789e9ccb63343d4e8c6b6263dd994f609579380e827e1f42daaacd770d3be636de1ef836 +"@react-native/debugger-frontend@npm:0.79.2": + version: 0.79.2 + resolution: "@react-native/debugger-frontend@npm:0.79.2" + checksum: 10/8a97eacba43984403bb3af8beac48109abbea9cb7d0ca23fd4dba9528647ec8f23452a92b7bfb70331d5ef6c3727e62406bf0712b3bb8476f04b5d83a11c39ef languageName: node linkType: hard -"@react-native/dev-middleware@npm:0.76.3": - version: 0.76.3 - resolution: "@react-native/dev-middleware@npm:0.76.3" +"@react-native/dev-middleware@npm:0.79.2": + version: 0.79.2 + resolution: "@react-native/dev-middleware@npm:0.79.2" dependencies: "@isaacs/ttlcache": "npm:^1.4.1" - "@react-native/debugger-frontend": "npm:0.76.3" + "@react-native/debugger-frontend": "npm:0.79.2" chrome-launcher: "npm:^0.15.2" chromium-edge-launcher: "npm:^0.2.0" connect: "npm:^3.6.5" debug: "npm:^2.2.0" + invariant: "npm:^2.2.4" nullthrows: "npm:^1.1.1" open: "npm:^7.0.3" - selfsigned: "npm:^2.4.1" - serve-static: "npm:^1.13.1" + serve-static: "npm:^1.16.2" ws: "npm:^6.2.3" - checksum: 10/d104275f7276837b92cc230dfdeeb8406f834518516bac99c82e81e54487540407627b670705f9b7f8e8a3a506bf7ee86043b4c4b37d6ce970d58309d8f5d251 - languageName: node - linkType: hard - -"@react-native/gradle-plugin@npm:0.76.3": - version: 0.76.3 - resolution: "@react-native/gradle-plugin@npm:0.76.3" - checksum: 10/331263f289418b5416f298328d8ef7b1cf7cc4fcb13707d941f666414c4628d59b2fba988ec4dc38b3a9dbcd2e937053e2259aec7dbdcac890017914ea24516d + checksum: 10/a36e747c64191e38b4d075f0b860d9b675712b5a731772fee7a6697b1b1182ab3381b595631ce5b5d186f5ce2f4009ee3aee84fcee77bb9b2506d52dcea311c6 languageName: node linkType: hard -"@react-native/js-polyfills@npm:0.76.3": - version: 0.76.3 - resolution: "@react-native/js-polyfills@npm:0.76.3" - checksum: 10/6bf86f6003a26fcee796a5e6642eea0c8b8f49016d5fb8c39a5c13397b7c3c26cc0e3c96c9dc40ac8983148a252904ec6fa2201fcbe6c47819bde891d2db0a77 +"@react-native/gradle-plugin@npm:0.79.2": + version: 0.79.2 + resolution: "@react-native/gradle-plugin@npm:0.79.2" + checksum: 10/a55eac49fd025304fec3e39dd7241565de4693231133cc92457741a8fa3141f015b851ae20483ca245d27be15e281edf54dc8ea9ccd1c0e10f788c2c260319b2 languageName: node linkType: hard -"@react-native/metro-babel-transformer@npm:0.76.3": - version: 0.76.3 - resolution: "@react-native/metro-babel-transformer@npm:0.76.3" - dependencies: - "@babel/core": "npm:^7.25.2" - "@react-native/babel-preset": "npm:0.76.3" - hermes-parser: "npm:0.23.1" - nullthrows: "npm:^1.1.1" - peerDependencies: - "@babel/core": "*" - checksum: 10/00cc1092042a530665ceadae6315441de8efb41448f2011fdfa7f586c8787346ec90c68f740980b56b1271cea2436497d82b78f5f1551b8df83eab032d05b251 +"@react-native/js-polyfills@npm:0.79.2": + version: 0.79.2 + resolution: "@react-native/js-polyfills@npm:0.79.2" + checksum: 10/436d9e69c4c95d2b58803ad1b750750998a46b2fab742fcdbb4df691abe357f09a3ca1e3f2e700faae37eb6112575cf9a9d44458addc6e36150a69fafd943218 languageName: node linkType: hard @@ -6817,17 +8923,10 @@ __metadata: languageName: node linkType: hard -"@react-native/normalize-colors@npm:0.76.3": - version: 0.76.3 - resolution: "@react-native/normalize-colors@npm:0.76.3" - checksum: 10/5675856bba8184f2049249cc217dc417857408fba6c4043d8ad2c941a33eba00af2574fa9e884c1f8073c99bd945308a310bbf89f63e67f17f4e6c11fdebf4fa - languageName: node - linkType: hard - -"@react-native/normalize-colors@npm:0.76.5": - version: 0.76.5 - resolution: "@react-native/normalize-colors@npm:0.76.5" - checksum: 10/ebe4df00677e779443c54a36acf034d51f582eebdb5745c7c185ad49d4f5ed9302c1b7560c59f0c5a278b02768057c2b97ab431e7af2d8f2a97c6b848f377210 +"@react-native/normalize-colors@npm:0.79.2": + version: 0.79.2 + resolution: "@react-native/normalize-colors@npm:0.79.2" + checksum: 10/5c9f5d30fb7a3c92b46c7f440dcfe270290d200708bbf2ba3573d05f342f548e036ee1af0cda6415e3d27af2a657e742506e1cf7948e1cdd2eb84b9c83f238e6 languageName: node linkType: hard @@ -6838,44 +8937,44 @@ __metadata: languageName: node linkType: hard -"@react-native/virtualized-lists@npm:0.76.3": - version: 0.76.3 - resolution: "@react-native/virtualized-lists@npm:0.76.3" +"@react-native/virtualized-lists@npm:0.79.2": + version: 0.79.2 + resolution: "@react-native/virtualized-lists@npm:0.79.2" dependencies: invariant: "npm:^2.2.4" nullthrows: "npm:^1.1.1" peerDependencies: - "@types/react": ^18.2.6 + "@types/react": ^19.0.0 react: "*" react-native: "*" peerDependenciesMeta: "@types/react": optional: true - checksum: 10/e3a43d669ff00379f93a03c8de4fddc88c0eafd30b1da9927c9613208e8b04bb1a41d1c955c29a2a7a0390ec318bc14aca6364541c9e29e0f803cedc8c0d2542 + checksum: 10/8743c7fa63414f591a82dc4d7ad14e80c31f1f20283b65c4977619cfadc35e1ca1a10c01f43f2f8108302cc747308f2492072ea5b60602a09b110429806e0b3c languageName: node linkType: hard -"@react-navigation/bottom-tabs@npm:^7.2.0": - version: 7.2.0 - resolution: "@react-navigation/bottom-tabs@npm:7.2.0" +"@react-navigation/bottom-tabs@npm:^7.3.10": + version: 7.3.10 + resolution: "@react-navigation/bottom-tabs@npm:7.3.10" dependencies: - "@react-navigation/elements": "npm:^2.2.5" + "@react-navigation/elements": "npm:^2.3.8" color: "npm:^4.2.3" peerDependencies: - "@react-navigation/native": ^7.0.14 + "@react-navigation/native": ^7.1.6 react: ">= 18.2.0" react-native: "*" react-native-safe-area-context: ">= 4.0.0" react-native-screens: ">= 4.0.0" - checksum: 10/b4e830751f9494504d8446c96dce3af488406cee92af0172996439fd1dcf24baf4f2ff2a1ad77cc6ef5a0265d08bf0f3431f4ac7e8d5ca80e5c8564f20479418 + checksum: 10/fdc6ee21182feb2730fef6f0300e3c031c294bf6ca21ae2aff5da726326e1a7001d8227066b96b11604d804b3b5db3eda5425019a2c522d7404c3b4f6a5ba34c languageName: node linkType: hard -"@react-navigation/core@npm:^7.3.1": - version: 7.3.1 - resolution: "@react-navigation/core@npm:7.3.1" +"@react-navigation/core@npm:^7.8.5": + version: 7.8.5 + resolution: "@react-navigation/core@npm:7.8.5" dependencies: - "@react-navigation/routers": "npm:^7.1.2" + "@react-navigation/routers": "npm:^7.3.5" escape-string-regexp: "npm:^4.0.0" nanoid: "npm:3.3.8" query-string: "npm:^7.1.3" @@ -6884,65 +8983,66 @@ __metadata: use-sync-external-store: "npm:^1.2.2" peerDependencies: react: ">= 18.2.0" - checksum: 10/94800b6b0a55cd9e7ffafbd508f434f65db4ce72d9a5f7d0420e6ca560d97c10ff95957a20a7f438fff28f1b96f37397cd433537058a076dc50e94ccd0c27975 + checksum: 10/c18e5efcb7ee49b14e2622ee557ed559f6bc3708f484f6ad2f9a1b1b152573dc4827afc9b4933dc9dc37ce000d8561a59eea728c6a0bc6a8041027ecdde3349f languageName: node linkType: hard -"@react-navigation/elements@npm:^2.2.5": - version: 2.2.5 - resolution: "@react-navigation/elements@npm:2.2.5" +"@react-navigation/elements@npm:^2.3.8": + version: 2.3.8 + resolution: "@react-navigation/elements@npm:2.3.8" dependencies: color: "npm:^4.2.3" peerDependencies: "@react-native-masked-view/masked-view": ">= 0.2.0" - "@react-navigation/native": ^7.0.14 + "@react-navigation/native": ^7.1.6 react: ">= 18.2.0" react-native: "*" react-native-safe-area-context: ">= 4.0.0" peerDependenciesMeta: "@react-native-masked-view/masked-view": optional: true - checksum: 10/f50a37ab35c80b9061ec2f540a656c90a4926e78585511dde5664d1ba5c2887918c33489f4518ff910a47bdba63390ae8ae8edceaf7f78335befe6f881067236 + checksum: 10/5893f44e540b2cf25ddbed98bc97a7828420cd4e7bfebb8f67383db760abdbacce1a7112de0b82e1f1efc5d8916e7f62d907b4ca4eb0861773efae45bad933db languageName: node linkType: hard -"@react-navigation/material-top-tabs@npm:^7.1.0": - version: 7.1.0 - resolution: "@react-navigation/material-top-tabs@npm:7.1.0" +"@react-navigation/material-top-tabs@npm:^7.2.10": + version: 7.2.10 + resolution: "@react-navigation/material-top-tabs@npm:7.2.10" dependencies: - "@react-navigation/elements": "npm:^2.2.5" + "@react-navigation/elements": "npm:^2.3.8" color: "npm:^4.2.3" - react-native-tab-view: "npm:^4.0.5" + react-native-tab-view: "npm:^4.0.10" peerDependencies: - "@react-navigation/native": ^7.0.14 + "@react-navigation/native": ^7.1.6 react: ">= 18.2.0" react-native: "*" react-native-pager-view: ">= 6.0.0" - checksum: 10/ae243d8e888ac522ac456248bdb41ae98115ca6709c0b4aa8f77a53faec79813e951028a0ead0751e7b1b639bfd7468e632ee34e3f981359ee5c92bd6f58c9b7 + react-native-safe-area-context: ">= 4.0.0" + checksum: 10/89bf68e5edffd1ece80a6dad79b8fc6d2cff47e0f30af311f58a2f11da99161f5e424de9d1bfd535ab1d44befb5f0b8114e4b02ddbeeb40e00a93b3806000bb3 languageName: node linkType: hard -"@react-navigation/native-stack@npm:^7.2.0": - version: 7.2.0 - resolution: "@react-navigation/native-stack@npm:7.2.0" +"@react-navigation/native-stack@npm:^7.3.10": + version: 7.3.10 + resolution: "@react-navigation/native-stack@npm:7.3.10" dependencies: - "@react-navigation/elements": "npm:^2.2.5" + "@react-navigation/elements": "npm:^2.3.8" warn-once: "npm:^0.1.1" peerDependencies: - "@react-navigation/native": ^7.0.14 + "@react-navigation/native": ^7.1.6 react: ">= 18.2.0" react-native: "*" react-native-safe-area-context: ">= 4.0.0" react-native-screens: ">= 4.0.0" - checksum: 10/54c6c7163e34a0dcafdbe2c2f0566bae9255aeac8dffd62af0f3b05771f2c74210c67beb4493557ba44470b5895aa76fd7e48a78c06b0d6528be0137805a5351 + checksum: 10/25c1dcad003183e491a7e34d11913ba73ba9557accb41a57082fbb525dafdeae64bf2d7c4c5a0478db6e3d9346edf1385a0432c188c78106f24df5fa77a900b2 languageName: node linkType: hard -"@react-navigation/native@npm:^7.0.14": - version: 7.0.14 - resolution: "@react-navigation/native@npm:7.0.14" +"@react-navigation/native@npm:^7.1.6": + version: 7.1.6 + resolution: "@react-navigation/native@npm:7.1.6" dependencies: - "@react-navigation/core": "npm:^7.3.1" + "@react-navigation/core": "npm:^7.8.5" escape-string-regexp: "npm:^4.0.0" fast-deep-equal: "npm:^3.1.3" nanoid: "npm:3.3.8" @@ -6950,31 +9050,30 @@ __metadata: peerDependencies: react: ">= 18.2.0" react-native: "*" - checksum: 10/6a9987da929141c11bc711c2c44998e4ef74160cc6aba902cf268350b8c78a91f999d90a5be09390063d99d1ef38fb6b43008393a0d1854a96ef41a1aa59395f + checksum: 10/706a69b14b33dbcca88e16218f897844f7c8941119cec4e72725e8ffecb5c8c7d7066a0bb97ba1ba816b375c8cae616221188665c0efd884fd3080820f98cac1 languageName: node linkType: hard -"@react-navigation/routers@npm:^7.1.2": - version: 7.1.2 - resolution: "@react-navigation/routers@npm:7.1.2" +"@react-navigation/routers@npm:^7.3.5": + version: 7.3.5 + resolution: "@react-navigation/routers@npm:7.3.5" dependencies: nanoid: "npm:3.3.8" - checksum: 10/8bc2f3907768e82898ea4b2daf83f301f40bdad50da2cb6d6b8382f0eb6da0804ec5beb88b183efdce2eb03f16d80b05e59a08e81e9809d99084605ad0f4dfb9 + checksum: 10/7907c454c5fcf781459893c3ee7e0c165c897884a1568ecc4403821487f86e1dfd4cfa3a3008f8df00896bef4ed4c1ca4d695f10bed25e3d3e905bab8d721ec5 languageName: node linkType: hard -"@redux-devtools/core@npm:^4.0.0": - version: 4.0.0 - resolution: "@redux-devtools/core@npm:4.0.0" +"@redux-devtools/core@npm:^4.1.1": + version: 4.1.1 + resolution: "@redux-devtools/core@npm:4.1.1" dependencies: - "@babel/runtime": "npm:^7.23.5" + "@babel/runtime": "npm:^7.26.9" "@redux-devtools/instrument": "npm:^2.2.0" - lodash: "npm:^4.17.21" peerDependencies: - react: ^16.8.4 || ^17.0.0 || ^18.0.0 + react: ^16.8.4 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-redux: ^7.0.0 || ^8.0.0 || ^9.0.0 redux: ^3.5.2 || ^4.0.0 || ^5.0.0 - checksum: 10/4206c1cff3dafe2b16843f396f939f4ffb4d92714f4bb3ea3a2f1860add919d0b40757ffd43e59cc8b7c0d9c5427e8fe9247a6a3c6c3c67dda419b5d6320de6e + checksum: 10/966e89c649c2c8c9013681e55b6ff43b49e75c17e3fd70b001bcefc06d45c5789aae0a47add0867d3d18364e3dffd478e61625ee13f31fa50ac2fc65c680bd6e languageName: node linkType: hard @@ -7002,31 +9101,33 @@ __metadata: languageName: node linkType: hard -"@redux-devtools/utils@npm:^3.0.0": - version: 3.0.1 - resolution: "@redux-devtools/utils@npm:3.0.1" +"@redux-devtools/utils@npm:^3.1.1": + version: 3.1.1 + resolution: "@redux-devtools/utils@npm:3.1.1" dependencies: - "@babel/runtime": "npm:^7.25.7" - "@redux-devtools/core": "npm:^4.0.0" + "@babel/runtime": "npm:^7.26.9" + "@redux-devtools/core": "npm:^4.1.1" "@redux-devtools/serialize": "npm:^0.4.2" "@types/get-params": "npm:^0.1.2" get-params: "npm:^0.1.2" immutable: "npm:^4.3.7" jsan: "npm:^3.1.14" - nanoid: "npm:^5.0.7" + nanoid: "npm:^5.1.2" redux: "npm:^5.0.1" peerDependencies: - "@redux-devtools/core": ^4.0.0 + "@redux-devtools/core": ^4.1.1 immutable: ^4.3.7 redux: ^4.0.0 || ^5.0.0 - checksum: 10/9e7e49571dc18b326db3328e889e215bb7a7492d33e1b5f44cf62b94b46b6c734b6c7e9a8e96dd8ae917fff9124159ab21977d1287a0d1964eff7cb387ccf00d + checksum: 10/23042d0582267a3df5789f8e5420a08f09fce3cee2615a64ee0db12d105cd2a04e6dbc7672cfd6535f8d0ed5e5480ad20bd645a6145cd97e8421c66b9832993b languageName: node linkType: hard -"@reduxjs/toolkit@npm:^2.4.0, @reduxjs/toolkit@npm:^2.5.0": - version: 2.5.0 - resolution: "@reduxjs/toolkit@npm:2.5.0" +"@reduxjs/toolkit@npm:^2.8.2": + version: 2.8.2 + resolution: "@reduxjs/toolkit@npm:2.8.2" dependencies: + "@standard-schema/spec": "npm:^1.0.0" + "@standard-schema/utils": "npm:^0.3.0" immer: "npm:^10.0.3" redux: "npm:^5.0.1" redux-thunk: "npm:^3.1.0" @@ -7039,122 +9140,22 @@ __metadata: optional: true react-redux: optional: true - checksum: 10/b24ee7e89bd00c5f3ee5df12cbc1b6395784ff215b9bf75d2c1b211d8494af220057ced666f49c6e815b723252bc3eb704f7be4a3c261ab06ee7b776f4565d4b - languageName: node - linkType: hard - -"@remix-run/node@npm:^2.12.0": - version: 2.15.0 - resolution: "@remix-run/node@npm:2.15.0" - dependencies: - "@remix-run/server-runtime": "npm:2.15.0" - "@remix-run/web-fetch": "npm:^4.4.2" - "@web3-storage/multipart-parser": "npm:^1.0.0" - cookie-signature: "npm:^1.1.0" - source-map-support: "npm:^0.5.21" - stream-slice: "npm:^0.1.2" - undici: "npm:^6.11.1" - peerDependencies: - typescript: ^5.1.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/aa7b087e33e10b66d0737ea6de51cc51de595a596dd982fef4059a68d21cc47ab60582bb7c786bd25e43dd4354df681df4caa9c99009e77d6dd9a650ecf6d3ea - languageName: node - linkType: hard - -"@remix-run/router@npm:1.21.0": - version: 1.21.0 - resolution: "@remix-run/router@npm:1.21.0" - checksum: 10/cf0fb69d19c1b79095ff67c59cea89086f3982a9a54c8a993818a60fc76e0ebab5a8db647c1a96a662729fad8e806ddd0a96622adf473f5a9f0b99998b2dbad4 - languageName: node - linkType: hard - -"@remix-run/server-runtime@npm:2.15.0": - version: 2.15.0 - resolution: "@remix-run/server-runtime@npm:2.15.0" - dependencies: - "@remix-run/router": "npm:1.21.0" - "@types/cookie": "npm:^0.6.0" - "@web3-storage/multipart-parser": "npm:^1.0.0" - cookie: "npm:^0.6.0" - set-cookie-parser: "npm:^2.4.8" - source-map: "npm:^0.7.3" - turbo-stream: "npm:2.4.0" - peerDependencies: - typescript: ^5.1.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/df2718b9e592b47e2f8c13f818126ffb00bd7a0a87186db640864c741ad68b65c255b54438a7eae9d346cf7569a6e4eda75c8c1f187b969975a0cd152556db02 - languageName: node - linkType: hard - -"@remix-run/web-blob@npm:^3.1.0": - version: 3.1.0 - resolution: "@remix-run/web-blob@npm:3.1.0" - dependencies: - "@remix-run/web-stream": "npm:^1.1.0" - web-encoding: "npm:1.1.5" - checksum: 10/24b95b90e2a5bfb17478c9e88a03c8de8db2cc6cbe744ee845cd8bfc278744f3d0a6f782f63b3361eef59488f544b60172fb98c628f113c936f597b303bc58f3 - languageName: node - linkType: hard - -"@remix-run/web-fetch@npm:^4.4.2": - version: 4.4.2 - resolution: "@remix-run/web-fetch@npm:4.4.2" - dependencies: - "@remix-run/web-blob": "npm:^3.1.0" - "@remix-run/web-file": "npm:^3.1.0" - "@remix-run/web-form-data": "npm:^3.1.0" - "@remix-run/web-stream": "npm:^1.1.0" - "@web3-storage/multipart-parser": "npm:^1.0.0" - abort-controller: "npm:^3.0.0" - data-uri-to-buffer: "npm:^3.0.1" - mrmime: "npm:^1.0.0" - checksum: 10/46961dae587d1d9eb1c678113cf8fcbe0e5779b3fdedd8b136c7065b47e967056d1508924992a58887b03ab406c44c838bca5d0f7d08a4bbaab9609178c3fe4b - languageName: node - linkType: hard - -"@remix-run/web-file@npm:^3.1.0": - version: 3.1.0 - resolution: "@remix-run/web-file@npm:3.1.0" - dependencies: - "@remix-run/web-blob": "npm:^3.1.0" - checksum: 10/c5ce184fc8e3a8d5736798c9fa784a3416890382be707da927926d173e67227dc60ae2494be680bf0074a00fac5a9a737387ce820349fb2fecdc31be034854a0 - languageName: node - linkType: hard - -"@remix-run/web-form-data@npm:^3.1.0": - version: 3.1.0 - resolution: "@remix-run/web-form-data@npm:3.1.0" - dependencies: - web-encoding: "npm:1.1.5" - checksum: 10/4eaa98da8f8827d2fd6e676920c6352679b35e0fd11f83d99f8a1ee009e8e51a0c710e8d228cd481ad00a81b3afe53a1db97321f3c6c16e14a3d67858e238a91 - languageName: node - linkType: hard - -"@remix-run/web-stream@npm:^1.1.0": - version: 1.1.0 - resolution: "@remix-run/web-stream@npm:1.1.0" - dependencies: - web-streams-polyfill: "npm:^3.1.1" - checksum: 10/9904b1539feee3a86d667e9803783dfc78e21b665a4e67edfd795bd1acee753fda88f50abbebf7cffa010539ed5287b4a0d09f55101b80f2c891c15db1066eea + checksum: 10/7834b7b91a364f98f5ffa932b1027179d9a0a27a145206a7b5256304acafe5516c6622540656435e310e4a08e579eecaee5c2582442c4ef6a4dbe6f417b7d395 languageName: node linkType: hard -"@reown/walletkit@npm:^1.1.1": - version: 1.1.2 - resolution: "@reown/walletkit@npm:1.1.2" +"@reown/walletkit@npm:^1.2.7": + version: 1.2.7 + resolution: "@reown/walletkit@npm:1.2.7" dependencies: - "@walletconnect/core": "npm:2.17.3" + "@walletconnect/core": "npm:2.21.3" "@walletconnect/jsonrpc-provider": "npm:1.0.14" "@walletconnect/jsonrpc-utils": "npm:1.0.8" "@walletconnect/logger": "npm:2.1.2" - "@walletconnect/sign-client": "npm:2.17.3" - "@walletconnect/types": "npm:2.17.3" - "@walletconnect/utils": "npm:2.17.3" - checksum: 10/0cc4bbca0dcdd2bdee8ee2e18a8c2231f3673c9b4ecb40e30f2f78f88ffe760a35b1cb02b18eafd0af10f0014b84d5272438b7e67964fcecc1e83d18fd913d80 + "@walletconnect/sign-client": "npm:2.21.3" + "@walletconnect/types": "npm:2.21.3" + "@walletconnect/utils": "npm:2.21.3" + checksum: 10/c932b72683bb1704d99dd55ee6abde32864befc529d5ddc46df818ae0219509ce95e2593a3c3915bfdfc5d4de286c703440879cbfc2b6463fd29c690c2ba3586 languageName: node linkType: hard @@ -7250,6 +9251,20 @@ __metadata: languageName: node linkType: hard +"@rrweb/types@npm:^2.0.0-alpha.18": + version: 2.0.0-alpha.18 + resolution: "@rrweb/types@npm:2.0.0-alpha.18" + checksum: 10/ddb632d49490ac6c20d011825b7b44e28a3783bbdabdf72f09649fdf2c5e107f756e7d926e5074b827c2311f28562905e8fa1911bbe8119ca014b3e24fb87f72 + languageName: node + linkType: hard + +"@rrweb/utils@npm:^2.0.0-alpha.18": + version: 2.0.0-alpha.18 + resolution: "@rrweb/utils@npm:2.0.0-alpha.18" + checksum: 10/d0ca790639816b13dcd353b9669059373c1f56585a5ad4a03c7559fd1d6cdae104e63f50f0076053b67ae1635be0f7b0e9bcb42ab9f9e094358952760499133d + languageName: node + linkType: hard + "@rtk-query/codegen-openapi@npm:^2.0.0": version: 2.0.0 resolution: "@rtk-query/codegen-openapi@npm:2.0.0" @@ -7301,28 +9316,42 @@ __metadata: "@babel/core": "npm:^7.25.2" "@babel/preset-env": "npm:^7.26.0" "@babel/preset-react": "npm:^7.26.3" - "@cowprotocol/app-data": "npm:^2.3.0" + "@cowprotocol/app-data": "npm:^3.1.0" "@eslint/js": "npm:^9.18.0" "@ethersproject/shims": "npm:^5.7.0" - "@expo/config-plugins": "npm:^9.0.10" - "@expo/vector-icons": "npm:^14.0.2" - "@gorhom/bottom-sheet": "npm:^5.0.6" + "@expo/config-plugins": "npm:~10.0.0" + "@expo/vector-icons": "npm:^14.1.0" + "@faker-js/faker": "npm:^9.0.3" + "@formatjs/intl-displaynames": "npm:^6.8.11" + "@formatjs/intl-getcanonicallocales": "npm:^2.5.4" + "@formatjs/intl-locale": "npm:^4.2.10" + "@formatjs/intl-numberformat": "npm:^8.15.3" + "@formatjs/intl-pluralrules": "npm:^5.4.3" + "@gorhom/bottom-sheet": "npm:^5.1.1" + "@hookform/resolvers": "npm:^4.1.3" "@notifee/react-native": "npm:^9.1.8" - "@react-native-async-storage/async-storage": "npm:1.23.1" + "@react-native-async-storage/async-storage": "npm:2.1.2" "@react-native-clipboard/clipboard": "npm:^1.15.0" "@react-native-community/blur": "npm:^4.4.1" - "@react-native-community/datetimepicker": "npm:8.2.0" - "@react-native-community/slider": "npm:4.5.5" - "@react-native-firebase/app": "npm:^21.7.1" - "@react-native-firebase/messaging": "npm:^21.7.1" - "@react-native-menu/menu": "npm:^1.1.6" + "@react-native-community/datetimepicker": "npm:8.3.0" + "@react-native-community/slider": "npm:4.5.6" + "@react-native-firebase/analytics": "npm:^22.2.1" + "@react-native-firebase/app": "npm:^22.2.1" + "@react-native-firebase/crashlytics": "npm:^22.2.1" + "@react-native-firebase/messaging": "npm:^22.2.1" + "@react-native-masked-view/masked-view": "npm:0.3.2" + "@react-native-menu/menu": "npm:^1.2.3" "@react-native/babel-preset": "npm:^0.76.2" - "@react-navigation/material-top-tabs": "npm:^7.1.0" - "@react-navigation/native": "npm:^7.0.14" - "@reduxjs/toolkit": "npm:^2.4.0" + "@react-navigation/material-top-tabs": "npm:^7.2.10" + "@react-navigation/native": "npm:^7.1.6" + "@reduxjs/toolkit": "npm:^2.8.2" "@rtk-query/codegen-openapi": "npm:^2.0.0" + "@safe-global/protocol-kit": "npm:^5.2.12" "@safe-global/store": "workspace:^" "@safe-global/test": "workspace:^" + "@safe-global/types-kit": "npm:^1.0.5" + "@safe-global/utils": "workspace:^" + "@shopify/flash-list": "npm:1.7.6" "@storybook/addon-essentials": "npm:^8.4.6" "@storybook/addon-interactions": "npm:^8.4.6" "@storybook/addon-onboarding": "npm:^8.4.6" @@ -7336,107 +9365,143 @@ __metadata: "@storybook/react-native": "npm:^8.4.2" "@storybook/react-webpack5": "npm:^8.4.6" "@storybook/test": "npm:^8.4.6" - "@tamagui/animations-moti": "npm:^1.117.1" - "@tamagui/babel-plugin": "npm:^1.121.12" - "@tamagui/config": "npm:^1.117.1" - "@tamagui/font-dm-sans": "npm:^1.121.3" - "@tamagui/toast": "npm:^1.117.1" - "@testing-library/react-native": "npm:^12.7.2" + "@tamagui/animations-moti": "npm:^1.126.3" + "@tamagui/babel-plugin": "npm:^1.126.3" + "@tamagui/config": "npm:^1.126.3" + "@tamagui/font-dm-sans": "npm:^1.126.3" + "@tamagui/toast": "npm:^1.126.3" + "@testing-library/react-native": "npm:^13.2.0" "@types/eslint__js": "npm:^8.42.3" - "@types/jest": "npm:^29.5.12" + "@types/jest": "npm:^29.5.14" "@types/lodash": "npm:^4.17.13" - "@types/node": "npm:^22.9.1" - "@types/react": "npm:~18.3.12" - babel-loader: "npm:^8.4.1" + "@types/node": "npm:^22.13.1" + "@types/qrcode": "npm:^1.5.5" + "@types/react": "npm:~19.0.10" + babel-loader: "npm:^10.0.0" babel-plugin-react-native-web: "npm:^0.19.13" blo: "npm:^1.2.0" burnt: "npm:^0.12.2" date-fns: "npm:^4.1.0" deepmerge: "npm:^4.3.1" - eslint: "npm:^9.19.0" + eslint: "npm:^9.29.0" eslint-config-prettier: "npm:^9.1.0" eslint-plugin-prettier: "npm:^5.2.1" eslint-plugin-react: "npm:^7.37.1" - ethers: "npm:^6.13.4" - expo: "npm:~52.0.14" - expo-blur: "npm:~14.0.1" - expo-build-properties: "npm:^0.13.2" - expo-constants: "npm:~17.0.4" - expo-dev-client: "npm:~5.0.5" - expo-font: "npm:~13.0.3" - expo-image: "npm:~2.0.3" - expo-linear-gradient: "npm:^14.0.1" - expo-linking: "npm:~7.0.3" - expo-router: "npm:~4.0.15" - expo-splash-screen: "npm:^0.29.16" - expo-status-bar: "npm:~2.0.0" - expo-system-ui: "npm:~4.0.6" - expo-web-browser: "npm:~14.0.1" + ethers: "npm:^6.14.3" + expo: "npm:^53.0.7" + expo-application: "npm:~6.1.4" + expo-blur: "npm:~14.1.4" + expo-build-properties: "npm:~0.14.6" + expo-constants: "npm:~17.1.5" + expo-dev-client: "npm:~5.1.8" + expo-device: "npm:~7.1.4" + expo-document-picker: "npm:^13.1.5" + expo-file-system: "npm:^18.1.10" + expo-font: "npm:~13.3.1" + expo-image: "npm:~2.1.6" + expo-linear-gradient: "npm:~14.1.4" + expo-linking: "npm:~7.1.4" + expo-router: "npm:~5.0.5" + expo-splash-screen: "npm:~0.30.8" + expo-status-bar: "npm:~2.2.3" + expo-system-ui: "npm:~5.0.7" + expo-task-manager: "npm:~13.1.5" + expo-web-browser: "npm:~14.1.6" + freerasp-react-native: "npm:^4.1.0" + glob: "npm:^11.0.1" globals: "npm:^15.14.0" jest: "npm:^29.7.0" - jest-expo: "npm:~52.0.2" + jest-expo: "npm:~53.0.4" lodash: "npm:^4.17.21" moti: "npm:^0.29.0" - react: "npm:18.3.1" - react-dom: "npm:^18.3.1" - react-native: "npm:0.76.3" + react: "npm:^19.0.0" + react-dom: "npm:19.0.0" + react-hook-form: "npm:^7.54.2" + react-native: "npm:0.79.2" + react-native-capture-protection: "npm:^2.1.0" react-native-collapsible-tab-view: "npm:^8.0.0" - react-native-device-crypto: "npm:^0.1.7" + react-native-device-crypto: "patch:react-native-device-crypto@npm%3A0.1.7#~/.yarn/patches/react-native-device-crypto-npm-0.1.7-dbd2698fc4.patch" react-native-device-info: "npm:^14.0.1" react-native-draggable-flatlist: "npm:^4.0.1" - react-native-gesture-handler: "npm:~2.20.2" - react-native-keychain: "npm:^9.2.2" - react-native-mmkv: "npm:^3.1.0" - react-native-pager-view: "npm:^6.5.1" + react-native-gesture-handler: "npm:^2.25.0" + react-native-keychain: "npm:^10.0.0" + react-native-mmkv: "npm:^3.2.0" + react-native-pager-view: "npm:6.7.1" + react-native-progress: "npm:^5.0.1" + react-native-qrcode-styled: "patch:react-native-qrcode-styled@npm%3A0.3.3#~/.yarn/patches/react-native-qrcode-styled-npm-0.3.3-b5336fc77c.patch" react-native-quick-crypto: "npm:^0.7.11" - react-native-reanimated: "npm:^3.16.7" - react-native-safe-area-context: "npm:4.12.0" - react-native-screens: "npm:^4.0.0" - react-native-svg: "npm:15.8.0" - react-native-web: "npm:^0.19.13" + react-native-reanimated: "npm:~3.17.4" + react-native-safe-area-context: "npm:^5.4.0" + react-native-screens: "npm:^4.10.0" + react-native-share: "npm:^12.0.9" + react-native-svg: "npm:15.11.2" + react-native-vision-camera: "npm:^4.7.0" + react-native-web: "npm:^0.20.0" react-redux: "npm:^9.1.2" redux: "npm:^5.0.1" - redux-devtools-expo-dev-plugin: "npm:^1.0.0" + redux-devtools-expo-dev-plugin: "npm:^2.0.0" redux-persist: "npm:^6.0.0" + siwe: "npm:^3.0.0" storybook: "npm:^8.4.6" - tamagui: "npm:^1.117.1" + tamagui: "npm:^1.126.3" timezone-mock: "npm:^1.3.6" ts-node: "npm:^10.9.2" tsconfig-paths-webpack-plugin: "npm:^4.2.0" - typescript: "npm:~5.3.3" - typescript-eslint: "npm:^8.8.1" + typescript: "npm:5.8.3" + typescript-eslint: "npm:^8.31.1" + webpack: "npm:^5.97.1" + zod: "npm:^3.24.2" languageName: unknown linkType: soft -"@safe-global/protocol-kit@npm:4.1.5": - version: 4.1.5 - resolution: "@safe-global/protocol-kit@npm:4.1.5" +"@safe-global/notification-service-ios@workspace:expo-plugins/notification-service-ios": + version: 0.0.0-use.local + resolution: "@safe-global/notification-service-ios@workspace:expo-plugins/notification-service-ios" + dependencies: + expo-module-scripts: "npm:^4.1.7" + typescript: "npm:5.8.3" + languageName: unknown + linkType: soft + +"@safe-global/protocol-kit@npm:^5.1.1": + version: 5.2.8 + resolution: "@safe-global/protocol-kit@npm:5.2.8" dependencies: - "@noble/hashes": "npm:^1.3.3" - "@safe-global/safe-core-sdk-types": "npm:^5.1.0" - "@safe-global/safe-deployments": "npm:^1.37.28" - "@safe-global/safe-modules-deployments": "npm:^2.2.1" + "@noble/curves": "npm:^1.6.0" + "@peculiar/asn1-schema": "npm:^2.3.13" + "@safe-global/safe-deployments": "npm:^1.37.35" + "@safe-global/safe-modules-deployments": "npm:^2.2.10" + "@safe-global/types-kit": "npm:^1.0.5" abitype: "npm:^1.0.2" - ethereumjs-util: "npm:^7.1.5" - ethers: "npm:^6.13.1" - semver: "npm:^7.6.2" - checksum: 10/e4b4b78527080954fe9523e31571cbe82ef5a6ef0a73dcd5037329761ecf06670f4759da7dbc4fdd1b73bc016314b7297aecd893fb39f98bde69b46ff2e4374a + semver: "npm:^7.6.3" + viem: "npm:^2.21.8" + dependenciesMeta: + "@noble/curves": + optional: true + "@peculiar/asn1-schema": + optional: true + checksum: 10/cc034131572fd32ad53d9d1b389082cac477ec88a6ccbe8bd7fe144f0ebdf290a2b99e0c74eca33afc3b02dca00d80a95f9d09bbeed568ed78552c71e75dcf81 languageName: node linkType: hard -"@safe-global/protocol-kit@patch:@safe-global/protocol-kit@npm%3A4.1.5#~/.yarn/patches/@safe-global-protocol-kit-npm-4.1.5-15df80b7bc.patch": - version: 4.1.5 - resolution: "@safe-global/protocol-kit@patch:@safe-global/protocol-kit@npm%3A4.1.5#~/.yarn/patches/@safe-global-protocol-kit-npm-4.1.5-15df80b7bc.patch::version=4.1.5&hash=0a9eac" +"@safe-global/protocol-kit@npm:^5.2.12": + version: 5.2.12 + resolution: "@safe-global/protocol-kit@npm:5.2.12" dependencies: - "@noble/hashes": "npm:^1.3.3" - "@safe-global/safe-core-sdk-types": "npm:^5.1.0" - "@safe-global/safe-deployments": "npm:^1.37.28" - "@safe-global/safe-modules-deployments": "npm:^2.2.1" + "@noble/curves": "npm:^1.6.0" + "@peculiar/asn1-schema": "npm:^2.3.13" + "@safe-global/safe-deployments": "npm:^1.37.40" + "@safe-global/safe-modules-deployments": "npm:^2.2.12" + "@safe-global/types-kit": "npm:^1.0.5" abitype: "npm:^1.0.2" - ethereumjs-util: "npm:^7.1.5" - ethers: "npm:^6.13.1" - semver: "npm:^7.6.2" - checksum: 10/dca4390a615091700ae2e6f313df420280e742e369bff6870e748bf8f603e01de8e121e8fb150715c8e095adfff92a0bca027f8e9c5542b0e63f97e5049906e4 + semver: "npm:^7.6.3" + viem: "npm:^2.21.8" + dependenciesMeta: + "@noble/curves": + optional: true + "@peculiar/asn1-schema": + optional: true + checksum: 10/cc18f783c9683254b51759a38cdfc0d4a1f022706fa276adbeeabf888cc4733fb3d54b4de63c004a87864af56b822658d97983a603ebff269a0227fed5a18fbd languageName: node linkType: hard @@ -7459,44 +9524,49 @@ __metadata: languageName: node linkType: hard -"@safe-global/safe-core-sdk-types@npm:^5.0.1, @safe-global/safe-core-sdk-types@npm:^5.1.0": - version: 5.1.0 - resolution: "@safe-global/safe-core-sdk-types@npm:5.1.0" +"@safe-global/safe-deployments@npm:1.37.40": + version: 1.37.40 + resolution: "@safe-global/safe-deployments@npm:1.37.40" dependencies: - abitype: "npm:^1.0.2" - checksum: 10/2ad129abe2367084c75b906dfa06bc4c2b43413fc00048164168b9fd9336a4ea7494b624d61282963062a20e6c6fe45ac28cd9c16c8b787f26ce19f0d8d054e9 + semver: "npm:^7.6.2" + checksum: 10/312e838e9e4555f7e74073774fa9df1f8421163cb65f2f129d616e1c53015df4869904cee018acb38f882d34ffec24170323bfb42111da1b1660d041f1bfaf91 languageName: node linkType: hard -"@safe-global/safe-deployments@npm:1.37.28": - version: 1.37.28 - resolution: "@safe-global/safe-deployments@npm:1.37.28" +"@safe-global/safe-deployments@patch:@safe-global/safe-deployments@npm%3A1.37.40#~/.yarn/patches/@safe-global-safe-deployments-npm-1.37.40-a424a8f650.patch": + version: 1.37.40 + resolution: "@safe-global/safe-deployments@patch:@safe-global/safe-deployments@npm%3A1.37.40#~/.yarn/patches/@safe-global-safe-deployments-npm-1.37.40-a424a8f650.patch::version=1.37.40&hash=23e2bc" dependencies: semver: "npm:^7.6.2" - checksum: 10/b34574a47284a6868718a203d9bb4e0ad3f3c4ba4bd52537ac3e22d83f76c1f2c69816e6346178d329b8cdd3a27dd2cd59c2e1300c1efa92ebb0294fe1b9df69 + checksum: 10/465c784d981d76ee49e9947866403e9a52605b414593c69a6250f432c9fd5ad88d1c714a232568e83de5a694bb3907a0be22ce12148c5c78fd9aec0d328b4c31 languageName: node linkType: hard -"@safe-global/safe-deployments@patch:@safe-global/safe-deployments@npm%3A1.37.28#~/.yarn/patches/@safe-global-safe-deployments-npm-1.37.28-060d6063bb.patch": - version: 1.37.28 - resolution: "@safe-global/safe-deployments@patch:@safe-global/safe-deployments@npm%3A1.37.28#~/.yarn/patches/@safe-global-safe-deployments-npm-1.37.28-060d6063bb.patch::version=1.37.28&hash=ae5fd0" - dependencies: - semver: "npm:^7.6.2" - checksum: 10/6e67e858f655eca4b96c9067c847efc93e57805bd11b6e65325cc0dfe64c7f81de4fde932162360bc10de366cd1fa0fcde286b01509aa8cad64f8588bbf38aeb +"@safe-global/safe-gateway-typescript-sdk@npm:3.23.1": + version: 3.23.1 + resolution: "@safe-global/safe-gateway-typescript-sdk@npm:3.23.1" + checksum: 10/22912217095341bdac331d964961379c4f9c1d08e7e70a623388ccb940461ec2805d1c17d5aea6c2d48b915c49f0921faf23d150f4b948dfda8c9647bc785371 languageName: node linkType: hard -"@safe-global/safe-gateway-typescript-sdk@npm:3.22.7, @safe-global/safe-gateway-typescript-sdk@npm:^3.5.3": - version: 3.22.7 - resolution: "@safe-global/safe-gateway-typescript-sdk@npm:3.22.7" - checksum: 10/0b16b943be3f268ceb17b962053b152046f0c9126c764fcbc7ddf3b34974396e75d3c29d2515b993c35cec9d583282b28b20d8f065582de425350d73d1f7e237 +"@safe-global/safe-gateway-typescript-sdk@npm:^3.5.3": + version: 3.22.4 + resolution: "@safe-global/safe-gateway-typescript-sdk@npm:3.22.4" + checksum: 10/5b088499a01a0d0190b4ab6828bfb2df779b603bbcee7645c23ad8e420670aab4ce7ca39b858fc62ee03fded77b322c3f8a9b0203f41ecb779d08f47bd4bfe0c languageName: node linkType: hard -"@safe-global/safe-modules-deployments@npm:^2.2.1, @safe-global/safe-modules-deployments@npm:^2.2.6": - version: 2.2.6 - resolution: "@safe-global/safe-modules-deployments@npm:2.2.6" - checksum: 10/2788918cf955784e19723d7be4097b0485a85eaf326596ba0fdbcc3aff82a34848f5e537b0493fdeadb30292e2878703c55b8271b8e1fe487957b8d0645eeb75 +"@safe-global/safe-modules-deployments@npm:2.2.12": + version: 2.2.12 + resolution: "@safe-global/safe-modules-deployments@npm:2.2.12" + checksum: 10/a8f735631f8c0c10b5e2b7aa8ce5a92e74a3d2fd3d8647e55031569a1895c8e6a5c224a834d41f0ff399831e889cdc4003f057b8c934c3f514ddd2acf0cc24f7 + languageName: node + linkType: hard + +"@safe-global/safe-modules-deployments@patch:@safe-global/safe-modules-deployments@npm%3A2.2.12#~/.yarn/patches/@safe-global-safe-modules-deployments-npm-2.2.12-c30bb15dee.patch": + version: 2.2.12 + resolution: "@safe-global/safe-modules-deployments@patch:@safe-global/safe-modules-deployments@npm%3A2.2.12#~/.yarn/patches/@safe-global-safe-modules-deployments-npm-2.2.12-c30bb15dee.patch::version=2.2.12&hash=2af2e9" + checksum: 10/c92398a8aa8f7fb5bd5abec95a0c45ea987858c61b69ee26c8752e9fc19b2c6c3abd4c89c153ea74aeaf02163d9a22d15a564a19b63556e3607a6b130b06633e languageName: node linkType: hard @@ -7504,11 +9574,17 @@ __metadata: version: 0.0.0-use.local resolution: "@safe-global/safe-wallet@workspace:." dependencies: + "@yarnpkg/types": "npm:^4.0.1" husky: "npm:^9.1.6" lint-staged: "npm:^15.2.10" - msw: "npm:^2.7.0" + msw: "npm:^2.7.3" prettier: "npm:^3.4.2" react-markdown: "npm:9.0.1" + dependenciesMeta: + cypress: + built: true + next: + built: true languageName: unknown linkType: soft @@ -7518,6 +9594,7 @@ __metadata: dependencies: "@types/jest": "npm:^29.5.14" jest: "npm:^29.7.0" + redux-persist: "npm:^6.0.0" ts-node: "npm:^10.9.2" languageName: unknown linkType: soft @@ -7548,22 +9625,53 @@ __metadata: languageName: node linkType: hard +"@safe-global/types-kit@npm:^1.0.5": + version: 1.0.5 + resolution: "@safe-global/types-kit@npm:1.0.5" + dependencies: + abitype: "npm:^1.0.2" + checksum: 10/732e8312458dab14557f0d08676ce2a55cb74b8296487ccacbb14564fdad44ff80ac1804ab75c73b8f21fc5ae6bc354d033ca968a6f1344e29316ec4b4018311 + languageName: node + linkType: hard + +"@safe-global/utils@workspace:^, @safe-global/utils@workspace:packages/utils": + version: 0.0.0-use.local + resolution: "@safe-global/utils@workspace:packages/utils" + dependencies: + "@cowprotocol/app-data": "npm:^3.1.0" + "@faker-js/faker": "npm:^9.0.3" + "@types/jest": "npm:^29.5.14" + ethers: "npm:^6.14.3" + jest: "npm:^29.7.0" + ts-node: "npm:^10.9.2" + peerDependencies: + "@safe-global/protocol-kit": ^5.x + "@safe-global/types-kit": ^1.x + ethers: ^6.x + languageName: unknown + linkType: soft + "@safe-global/web@workspace:apps/web": version: 0.0.0-use.local resolution: "@safe-global/web@workspace:apps/web" dependencies: "@chromatic-com/storybook": "npm:^1.3.1" - "@cowprotocol/app-data": "npm:^2.4.0" + "@cowprotocol/app-data": "npm:^3.1.0" "@cowprotocol/widget-react": "npm:^0.13.0" + "@datadog/browser-logs": "npm:^6.6.3" "@ducanh2912/next-pwa": "npm:^10.2.9" "@emotion/cache": "npm:^11.13.5" "@emotion/react": "npm:^11.13.5" "@emotion/server": "npm:^11.11.0" "@emotion/styled": "npm:^11.13.5" - "@eslint/eslintrc": "npm:^3.1.0" + "@eslint/eslintrc": "npm:^3.3.1" "@eslint/js": "npm:^9.18.0" "@faker-js/faker": "npm:^9.0.3" "@gnosis.pm/zodiac": "npm:^4.0.3" + "@ledgerhq/context-module": "npm:^1.3.1" + "@ledgerhq/device-management-kit": "npm:^0.6.5" + "@ledgerhq/device-signer-kit-ethereum": "npm:^1.3.3" + "@ledgerhq/device-transport-kit-web-hid": "npm:^1.1.0" "@mdx-js/loader": "npm:^3.0.1" "@mdx-js/react": "npm:^3.0.1" "@mui/icons-material": "npm:^6.1.6" @@ -7571,22 +9679,23 @@ __metadata: "@mui/x-date-pickers": "npm:^7.23.3" "@next/bundle-analyzer": "npm:^15.0.4" "@next/mdx": "npm:^15.0.4" + "@next/third-parties": "npm:^15.2.0" "@openzeppelin/contracts": "npm:^4.9.6" - "@reduxjs/toolkit": "npm:^2.5.0" - "@reown/walletkit": "npm:^1.1.1" + "@reduxjs/toolkit": "npm:^2.8.2" + "@reown/walletkit": "npm:^1.2.7" "@safe-global/api-kit": "npm:^2.4.6" - "@safe-global/protocol-kit": "patch:@safe-global/protocol-kit@npm%3A4.1.5#~/.yarn/patches/@safe-global-protocol-kit-npm-4.1.5-15df80b7bc.patch" + "@safe-global/protocol-kit": "npm:^5.2.12" "@safe-global/safe-apps-sdk": "npm:^9.1.0" "@safe-global/safe-client-gateway-sdk": "npm:v1.60.1" - "@safe-global/safe-core-sdk-types": "npm:^5.0.1" - "@safe-global/safe-deployments": "patch:@safe-global/safe-deployments@npm%3A1.37.28#~/.yarn/patches/@safe-global-safe-deployments-npm-1.37.28-060d6063bb.patch" - "@safe-global/safe-gateway-typescript-sdk": "npm:3.22.7" - "@safe-global/safe-modules-deployments": "npm:^2.2.6" + "@safe-global/safe-deployments": "patch:@safe-global/safe-deployments@npm%3A1.37.40#~/.yarn/patches/@safe-global-safe-deployments-npm-1.37.40-a424a8f650.patch" + "@safe-global/safe-gateway-typescript-sdk": "npm:3.23.1" + "@safe-global/safe-modules-deployments": "patch:@safe-global/safe-modules-deployments@npm%3A2.2.12#~/.yarn/patches/@safe-global-safe-modules-deployments-npm-2.2.12-c30bb15dee.patch" "@safe-global/store": "workspace:^" "@safe-global/test": "workspace:^" + "@safe-global/types-kit": "npm:^1.0.5" "@sentry/react": "npm:^7.91.0" "@sentry/types": "npm:^7.74.0" - "@spindl-xyz/attribution-lite": "npm:^1.4.0" + "@spindl-xyz/attribution-lite": "npm:^1.8.1" "@storybook/addon-designs": "npm:^8.0.3" "@storybook/addon-essentials": "npm:^8.0.6" "@storybook/addon-interactions": "npm:^8.0.6" @@ -7603,44 +9712,44 @@ __metadata: "@testing-library/react": "npm:^16.1.0" "@testing-library/user-event": "npm:^14.5.2" "@typechain/ethers-v6": "npm:^0.5.1" - "@types/jest": "npm:^29.5.4" + "@types/jest": "npm:^29.5.14" "@types/js-cookie": "npm:^3.0.6" "@types/lodash": "npm:^4.14.182" "@types/mdx": "npm:^2.0.13" - "@types/node": "npm:18.11.18" + "@types/node": "npm:22.13.1" "@types/qrcode": "npm:^1.5.5" "@types/react": "npm:^19.0.0" "@types/react-dom": "npm:^19.0.0" - "@types/react-gtm-module": "npm:^2.0.3" "@types/semver": "npm:^7.3.10" "@typescript-eslint/eslint-plugin": "npm:^7.6.0" "@typescript-eslint/parser": "npm:^8.18.1" - "@walletconnect/core": "npm:^2.17.2" + "@walletconnect/core": "npm:^2.20.2" "@walletconnect/utils": "npm:^2.17.3" "@web3-onboard/coinbase": "npm:^2.4.2" - "@web3-onboard/core": "npm:2.21.4" - "@web3-onboard/injected-wallets": "npm:^2.11.2" + "@web3-onboard/core": "npm:2.24.0" + "@web3-onboard/hw-common": "npm:^2.3.3" + "@web3-onboard/injected-wallets": "npm:^2.11.3" "@web3-onboard/walletconnect": "npm:^2.6.1" blo: "npm:^1.1.1" + cheerio: "npm:^1.0.0" classnames: "npm:^2.5.1" cross-env: "npm:^7.0.3" cypress: "npm:^13.15.2" cypress-file-upload: "npm:^5.0.8" cypress-visual-regression: "npm:^5.2.2" date-fns: "npm:^2.30.0" - eslint: "npm:^9.19.0" + eslint: "npm:^9.29.0" eslint-config-next: "npm:^15.0.4" eslint-config-prettier: "npm:^9.1.0" eslint-plugin-no-only-tests: "npm:^3.3.0" eslint-plugin-prettier: "npm:^5.2.1" eslint-plugin-storybook: "npm:^0.11.0" eslint-plugin-unused-imports: "npm:^4.1.4" - ethers: "npm:^6.13.4" + ethers: "npm:6.14.3" exponential-backoff: "npm:^3.1.0" fake-indexeddb: "npm:^4.0.2" firebase: "npm:^11.1.0" - fuse.js: "npm:^7.0.0" - gray-matter: "npm:^4.0.3" + fuse.js: "npm:^7.1.0" husky: "npm:^9.0.11" idb-keyval: "npm:^6.2.1" jest: "npm:^29.7.0" @@ -7648,34 +9757,40 @@ __metadata: jest-fixed-jsdom: "npm:^0.0.9" js-cookie: "npm:^3.0.1" lodash: "npm:^4.17.21" + mixpanel-browser: "npm:^2.66.0" mockdate: "npm:^3.0.5" - msw: "npm:^2.7.0" - next: "npm:^15.1.2" + msw: "npm:^2.7.3" + next: "patch:next@15.2.4#../../.yarn/patches/next-npm-15.2.4-06a6671f62.patch" papaparse: "npm:^5.3.2" - prettier: "npm:^3.3.3" + prettier: "npm:^3.4.2" qrcode.react: "npm:^3.1.0" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" react-dropzone: "npm:^14.2.3" - react-gtm-module: "npm:^2.0.11" react-hook-form: "npm:7.41.1" react-papaparse: "npm:^4.0.2" react-redux: "npm:^9.1.2" remark-frontmatter: "npm:^5.0.0" remark-gfm: "npm:^4.0.0" remark-heading-id: "npm:^1.0.1" - remark-mdx-frontmatter: "npm:^5.0.0" - semver: "npm:^7.6.3" + remark-mdx-frontmatter: "npm:^5.2.0" + semver: "npm:^7.7.1" storybook: "npm:^8.3.4" ts-prune: "npm:^0.10.3" typechain: "npm:^8.3.2" - typescript: "npm:^5.4.5" + typescript: "npm:5.8.3" typescript-plugin-css-modules: "npm:^4.2.2" - webpack: "npm:^5.97.1" zodiac-roles-deployments: "npm:^2.3.4" languageName: unknown linkType: soft +"@scure/base@npm:1.2.6, @scure/base@npm:~1.2.5": + version: 1.2.6 + resolution: "@scure/base@npm:1.2.6" + checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 + languageName: node + linkType: hard + "@scure/base@npm:~1.1.0, @scure/base@npm:~1.1.2": version: 1.1.9 resolution: "@scure/base@npm:1.1.9" @@ -7690,6 +9805,13 @@ __metadata: languageName: node linkType: hard +"@scure/base@npm:~1.2.2, @scure/base@npm:~1.2.4": + version: 1.2.4 + resolution: "@scure/base@npm:1.2.4" + checksum: 10/4b61679209af40143b49ce7b7570e1d9157c19df311ea6f57cd212d764b0b82222dbe3707334f08bec181caf1f047aca31aa91193c678d6548312cb3f9c82ab1 + languageName: node + linkType: hard + "@scure/bip32@npm:1.3.2": version: 1.3.2 resolution: "@scure/bip32@npm:1.3.2" @@ -7712,6 +9834,28 @@ __metadata: languageName: node linkType: hard +"@scure/bip32@npm:1.6.2": + version: 1.6.2 + resolution: "@scure/bip32@npm:1.6.2" + dependencies: + "@noble/curves": "npm:~1.8.1" + "@noble/hashes": "npm:~1.7.1" + "@scure/base": "npm:~1.2.2" + checksum: 10/474ee315a8631aa1a7d378b0521b4494e09a231519ec53d879088cb88c8ff644a89b27a02a8bf0b5a9b1c4c0417acc70636ccdb121b800c34594ae53c723f8d7 + languageName: node + linkType: hard + +"@scure/bip32@npm:1.7.0": + version: 1.7.0 + resolution: "@scure/bip32@npm:1.7.0" + dependencies: + "@noble/curves": "npm:~1.9.0" + "@noble/hashes": "npm:~1.8.0" + "@scure/base": "npm:~1.2.5" + checksum: 10/f90e0c23ab6a31a164856ae9cb9a8cae2886df608c74a6c0c4875095b017e30ffd92f28f73b8c52890d9a89fca86d19f6d60bb1ea7cad64c7987f92ae83509ad + languageName: node + linkType: hard + "@scure/bip39@npm:1.2.1": version: 1.2.1 resolution: "@scure/bip39@npm:1.2.1" @@ -7732,13 +9876,23 @@ __metadata: languageName: node linkType: hard -"@segment/loosely-validate-event@npm:^2.0.0": - version: 2.0.0 - resolution: "@segment/loosely-validate-event@npm:2.0.0" +"@scure/bip39@npm:1.5.4": + version: 1.5.4 + resolution: "@scure/bip39@npm:1.5.4" + dependencies: + "@noble/hashes": "npm:~1.7.1" + "@scure/base": "npm:~1.2.4" + checksum: 10/9f08b433511d7637bc48c51aa411457d5f33da5a85bd03370bf394822b0ea8c007ceb17247a3790c28237303d8fc20c4e7725765940cd47e1365a88319ad0d5c + languageName: node + linkType: hard + +"@scure/bip39@npm:1.6.0": + version: 1.6.0 + resolution: "@scure/bip39@npm:1.6.0" dependencies: - component-type: "npm:^1.2.1" - join-component: "npm:^1.1.0" - checksum: 10/4e0b097de2c564673acceb5a0688bb8cf045bab4a1ffed1be19293a6bd2859af723e0d012349ff1d51433a6aad19f729383a302c3c0a9fc831e251cd16ade5ad + "@noble/hashes": "npm:~1.8.0" + "@scure/base": "npm:~1.2.5" + checksum: 10/63e60c40fa1bda2c1b50351546fee6d7b0947cc814aa7a4209dcedd3693b5053302c8fca28292f5f50735e11c613265359acdc019127393dbab17e53489fc449 languageName: node linkType: hard @@ -7802,6 +9956,17 @@ __metadata: languageName: node linkType: hard +"@sentry/hub@npm:6.19.7": + version: 6.19.7 + resolution: "@sentry/hub@npm:6.19.7" + dependencies: + "@sentry/types": "npm:6.19.7" + "@sentry/utils": "npm:6.19.7" + tslib: "npm:^1.9.3" + checksum: 10/ef2381ec399305ee56f7cff990c5bf0f221119193ac1b0862d237c42c9e214a8a3dcabe55085e197710c9667f1c541fffc3fe37e89d7562f3c86432c22d7f09a + languageName: node + linkType: hard + "@sentry/integrations@npm:7.120.2": version: 7.120.2 resolution: "@sentry/integrations@npm:7.120.2" @@ -7814,9 +9979,20 @@ __metadata: languageName: node linkType: hard -"@sentry/react@npm:^7.91.0": - version: 7.120.2 - resolution: "@sentry/react@npm:7.120.2" +"@sentry/minimal@npm:^6.19.7": + version: 6.19.7 + resolution: "@sentry/minimal@npm:6.19.7" + dependencies: + "@sentry/hub": "npm:6.19.7" + "@sentry/types": "npm:6.19.7" + tslib: "npm:^1.9.3" + checksum: 10/eac4f79f7116dee90bfd8ea284c777c267e70c0b51883bc419f176dd5283b2b1955ede0bc471759f26a8c686f78f7a664560684a8998fc4c6f85d9e1539d39f9 + languageName: node + linkType: hard + +"@sentry/react@npm:^7.91.0": + version: 7.120.2 + resolution: "@sentry/react@npm:7.120.2" dependencies: "@sentry/browser": "npm:7.120.2" "@sentry/core": "npm:7.120.2" @@ -7841,6 +10017,13 @@ __metadata: languageName: node linkType: hard +"@sentry/types@npm:6.19.7": + version: 6.19.7 + resolution: "@sentry/types@npm:6.19.7" + checksum: 10/f9f70e94c4a3876f6119f7e3979051ea2a054adce6f5583de9f70a08642c7d2c2f80a70a1f9fe5f9fad4e99315f4483340ded1110ae2e7c825c4c1f210fc2507 + languageName: node + linkType: hard + "@sentry/types@npm:7.120.2, @sentry/types@npm:^7.74.0": version: 7.120.2 resolution: "@sentry/types@npm:7.120.2" @@ -7848,6 +10031,16 @@ __metadata: languageName: node linkType: hard +"@sentry/utils@npm:6.19.7": + version: 6.19.7 + resolution: "@sentry/utils@npm:6.19.7" + dependencies: + "@sentry/types": "npm:6.19.7" + tslib: "npm:^1.9.3" + checksum: 10/0ea94d32940705d77b019ca821e45a5866bb3d443e0f19b9bf5edf3d7ffed68c451803f3388913fec4da875e4b7df46b5f8a8681c4d69972fb3d775d864997b2 + languageName: node + linkType: hard + "@sentry/utils@npm:7.120.2": version: 7.120.2 resolution: "@sentry/utils@npm:7.120.2" @@ -7857,6 +10050,20 @@ __metadata: languageName: node linkType: hard +"@shopify/flash-list@npm:1.7.6": + version: 1.7.6 + resolution: "@shopify/flash-list@npm:1.7.6" + dependencies: + recyclerlistview: "npm:4.2.3" + tslib: "npm:2.8.1" + peerDependencies: + "@babel/runtime": "*" + react: "*" + react-native: "*" + checksum: 10/2c635a3719013db2c8fd284593d6d5f6e495862c60bcafb5e56d0818e58cb8d202b19d80decb14df35041f957aa498223abcb76e642d91626e0a96056675c548 + languageName: node + linkType: hard + "@sideway/address@npm:^4.1.3": version: 4.1.5 resolution: "@sideway/address@npm:4.1.5" @@ -7905,10 +10112,20 @@ __metadata: languageName: node linkType: hard -"@spindl-xyz/attribution-lite@npm:^1.4.0": - version: 1.8.0 - resolution: "@spindl-xyz/attribution-lite@npm:1.8.0" - checksum: 10/da2cfdde056cd9249a8949ac879f73e2da7a2103675cd2352ee0ad4d42a9edcf30610d1e1105ca8b274a5c35133e690c025d0ff221577904d9dd384fbc733bb2 +"@spindl-xyz/attribution-lite@npm:^1.8.1": + version: 1.8.1 + resolution: "@spindl-xyz/attribution-lite@npm:1.8.1" + checksum: 10/f747394048d0d3a89f8853eea16f11f22c6dd77fe9880347a69c961d52fc60583166643f80cd2c1d90e4a5ba55bf1c0e525d20df548de7c9fecdebea63bb8570 + languageName: node + linkType: hard + +"@spruceid/siwe-parser@npm:^3.0.0": + version: 3.0.0 + resolution: "@spruceid/siwe-parser@npm:3.0.0" + dependencies: + "@noble/hashes": "npm:^1.1.2" + apg-js: "npm:^4.4.0" + checksum: 10/19239d4a18a953812e4a5cd25c51a9869b67643b00cf9b6f36091b0d7b8be09934d1ba41c7df60f6e05307ba5cbc7c4812c69b5d3de64a132ea08b80a35cb87f languageName: node linkType: hard @@ -8082,6 +10299,20 @@ __metadata: languageName: node linkType: hard +"@standard-schema/spec@npm:^1.0.0": + version: 1.0.0 + resolution: "@standard-schema/spec@npm:1.0.0" + checksum: 10/aee780cc1431888ca4b9aba9b24ffc8f3073fc083acc105e3951481478a2f4dc957796931b2da9e2d8329584cf211e4542275f188296c1cdff3ed44fd93a8bc8 + languageName: node + linkType: hard + +"@standard-schema/utils@npm:^0.3.0": + version: 0.3.0 + resolution: "@standard-schema/utils@npm:0.3.0" + checksum: 10/7084f875d322792f2e0a5904009434c8374b9345b09ba89828b68fd56fa3c2b366d35bf340d9e8c72736ef01793c2f70d350c372ed79845dc3566c58d34b4b51 + languageName: node + linkType: hard + "@storybook/addon-actions@npm:8.4.7, @storybook/addon-actions@npm:^8.4.2": version: 8.4.7 resolution: "@storybook/addon-actions@npm:8.4.7" @@ -9115,1224 +11346,1103 @@ __metadata: languageName: node linkType: hard -"@tamagui/accordion@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/accordion@npm:1.119.0" +"@tamagui/accordion@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/accordion@npm:1.126.3" dependencies: - "@tamagui/collapsible": "npm:1.119.0" - "@tamagui/collection": "npm:1.119.0" - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/polyfill-dev": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" + "@tamagui/collapsible": "npm:1.126.3" + "@tamagui/collection": "npm:1.126.3" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/polyfill-dev": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/text": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/7b205f04f982c289e537d46192937b8fdf3b4383e657e90bcd21e12c484aeae5e6462497008d06df585082a5cd79740a73a0e6e0ba8eddd62d894ad5e63da2d5 + checksum: 10/5d7cae8ccbc4c421407078cac0f4581804a4026f4a6486c89100367599e3a58f3ba528e77f27ec0d4bb8f85bcb6ae72d837feb363d0d08ad7e18f510a426b5af languageName: node linkType: hard -"@tamagui/adapt@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/adapt@npm:1.119.0" +"@tamagui/adapt@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/adapt@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/portal": "npm:1.119.0" - checksum: 10/863cf9706bcb06be9f644cd49dba9e20992bf8139362e59f8eebebdc54e992554138eb9de73ce7119c8709f089ee4102f1c3f56dd997728caa830af97174676f + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/portal": "npm:1.126.3" + "@tamagui/z-index-stack": "npm:1.126.3" + checksum: 10/5fdc2d3bb0629ea13ddecdd110f05787c75b12aa0e12b37a5ecf98356c6f1540ebd15d2da28af8e001473b0092784d710fb4a1df989171187c276cfb3097c741 languageName: node linkType: hard -"@tamagui/alert-dialog@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/alert-dialog@npm:1.119.0" +"@tamagui/alert-dialog@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/alert-dialog@npm:1.126.3" dependencies: - "@tamagui/animate-presence": "npm:1.119.0" - "@tamagui/aria-hidden": "npm:1.119.0" - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/dialog": "npm:1.119.0" - "@tamagui/dismissable": "npm:1.119.0" - "@tamagui/focus-scope": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/polyfill-dev": "npm:1.119.0" - "@tamagui/popper": "npm:1.119.0" - "@tamagui/portal": "npm:1.119.0" - "@tamagui/remove-scroll": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/text": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" + "@tamagui/animate-presence": "npm:1.126.3" + "@tamagui/aria-hidden": "npm:1.126.3" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/dialog": "npm:1.126.3" + "@tamagui/dismissable": "npm:1.126.3" + "@tamagui/focus-scope": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/polyfill-dev": "npm:1.126.3" + "@tamagui/popper": "npm:1.126.3" + "@tamagui/portal": "npm:1.126.3" + "@tamagui/remove-scroll": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/text": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/27637c086611f2bfd27928bdd8c9d4a8b9768a914ca6b2f6cc966ae170daf5ca76b6c0e3b2670f1f4a767427689ee9c0e9cce81d057ab475e9b38845a3e60778 - languageName: node - linkType: hard - -"@tamagui/animate-presence@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/animate-presence@npm:1.119.0" - dependencies: - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/use-constant": "npm:1.119.0" - "@tamagui/use-force-update": "npm:1.119.0" - "@tamagui/use-presence": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" - checksum: 10/eac23c726a75b657dd42efd7f697d1cbbb7099afc68cab3092e64614f6c6715f96a48fa9664a48f931379f412fa3e3207b09a8d3351b7686d5be1e7cdda0ddce + checksum: 10/3da433d8184eb0fb001140e65dff3cbbbc5c8fd51b34071867fa348ce07110d4306c534ba0de9632ca2692537ada5d9008e4e65243564629acf2885ca77972b9 languageName: node linkType: hard -"@tamagui/animate@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/animate@npm:1.119.0" +"@tamagui/animate-presence@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/animate-presence@npm:1.126.3" dependencies: - "@tamagui/animate-presence": "npm:1.119.0" - checksum: 10/fef915a45e3875a037e9773e28e99fec75222b2ef0a316e9b45cb21e8b3d1aadf30ca800907f1e57432ad3d716e589b2828c85dc9322255b5e73a420ea529509 + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/use-constant": "npm:1.126.3" + "@tamagui/use-force-update": "npm:1.126.3" + "@tamagui/use-presence": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" + checksum: 10/c1605089cb549c10d15a7cc191d657c393ec38b82589140d04f09ee1463d64e6adbb61157982af7ab7a52c89613824bb2c82a17363c88ac8c0394c16567b7a5e languageName: node linkType: hard -"@tamagui/animations-css@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/animations-css@npm:1.119.0" +"@tamagui/animate@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/animate@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.119.0" - "@tamagui/cubic-bezier-animator": "npm:1.119.0" - "@tamagui/use-presence": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" - peerDependencies: - react: "*" - checksum: 10/d105ab5d3f22c2b404198c997bace1685b8211f9cea338e6c208059cfbc981d077f2462b166e3d91b45eaee22fa7c9dd4e0a2bb81d8c2677b2fd948188e19985 + "@tamagui/animate-presence": "npm:1.126.3" + checksum: 10/b5df82d3d3d39bb93f704a0fc7a944ec13084db26177a9b1da1953345cb7b8047490c906ad7af72b439ccb1e782608004e0ca5f30acf999577bb6b404d801354 languageName: node linkType: hard -"@tamagui/animations-css@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/animations-css@npm:1.121.12" +"@tamagui/animations-css@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/animations-css@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.121.12" - "@tamagui/cubic-bezier-animator": "npm:1.121.12" - "@tamagui/use-presence": "npm:1.121.12" - "@tamagui/web": "npm:1.121.12" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/cubic-bezier-animator": "npm:1.126.3" + "@tamagui/use-presence": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" react-dom: "*" - checksum: 10/06986c05286b2dedaf60d8faa5bffb957eccf703366d847dfd2dad3de666661e91a69e5b5037d56e4e2b575c19c809be3d7c8b55a1b157f612cd730d81c0f182 + checksum: 10/db09d9264c5cb7bf8b25b06d21f1692cb904757e8117a0c4f406dc94c3e97cce3b14c5a460346b3efbd2eb97cfcb29c0e6a8951aa3dbc24869cb05eae8ecbe0e languageName: node linkType: hard -"@tamagui/animations-moti@npm:1.119.0, @tamagui/animations-moti@npm:^1.117.1": - version: 1.119.0 - resolution: "@tamagui/animations-moti@npm:1.119.0" +"@tamagui/animations-moti@npm:1.126.3, @tamagui/animations-moti@npm:^1.126.3": + version: 1.126.3 + resolution: "@tamagui/animations-moti@npm:1.126.3" dependencies: - "@tamagui/use-presence": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/use-presence": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" moti: "npm:^0.29.0" peerDependencies: react: "*" - checksum: 10/b641fefd460b61edb4437158e793b377fbe3759c5597c9bc68981f4cc091a1dc6727c43220be12a734fa6c5dcc93d71a2acecfb5a61a947142aec60ff7efdc44 + checksum: 10/055f1bd6ea5086ced3588f36e3ab6390bfc94439c2ab1bafb692a0e993de6cf9f1c9737e1d12e874a72c85ba69dc470bd957c568c72428a2f39a109520940003 languageName: node linkType: hard -"@tamagui/animations-react-native@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/animations-react-native@npm:1.119.0" +"@tamagui/animations-react-native@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/animations-react-native@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.119.0" - "@tamagui/use-presence": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/use-presence": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/05a33dac26bea55faf8d4085dc29ff96373cfed614483f533f496e13d3e949cb22b47b5f39864e9009a3255c20c637cf5ba0b18561f468dab9f69fb75de9cf87 + checksum: 10/8a301eb1e4d2645db801f3206af5f517d6d255f534a58beaa69253cc07d3c96fb8349617abcbc4b45665c411e525182878090e36550e7243ecab855bb592235e languageName: node linkType: hard -"@tamagui/aria-hidden@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/aria-hidden@npm:1.119.0" +"@tamagui/aria-hidden@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/aria-hidden@npm:1.126.3" dependencies: aria-hidden: "npm:^1.1.3" peerDependencies: react: "*" - checksum: 10/174b967f44d0d173c04c1c14542c13c350259166e3b8207d83d6bec51d51088fb689f8791cba130160e7a2262b080a2e5b550652f04089c7e868b10e109b8a64 + checksum: 10/da939c7dd352f716cf0a2fca6ce58e5b061be4c22a5c2965ae0e6765bd888a256151688a4a9044af9c8d96702275159924cba9505053b2794e7dbb1ef1c9d5d5 languageName: node linkType: hard -"@tamagui/avatar@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/avatar@npm:1.119.0" +"@tamagui/avatar@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/avatar@npm:1.126.3" dependencies: - "@tamagui/core": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/image": "npm:1.119.0" - "@tamagui/shapes": "npm:1.119.0" - "@tamagui/text": "npm:1.119.0" + "@tamagui/core": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/image": "npm:1.126.3" + "@tamagui/shapes": "npm:1.126.3" + "@tamagui/text": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/e7eb46030376e7fa881d23b9d1e64ea67fb7afb8447b31ac4b7c5232d1eff91ae52dacdcc727e6a55049c10dabe1372dcc5a3a907aafb9d473728275e5236567 + checksum: 10/47a9ea76d9144639983a8b731f714d0dea7312f1fd590ffbe21f4e1c0256e76c61a1844284154d9b94bb6dd6148c3cdee3f56d6131bb08780049a9468ac17ae0 languageName: node linkType: hard -"@tamagui/babel-plugin-fully-specified@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/babel-plugin-fully-specified@npm:1.121.12" +"@tamagui/babel-plugin-fully-specified@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/babel-plugin-fully-specified@npm:1.126.3" dependencies: "@babel/core": "npm:^7.25.2" - checksum: 10/5fb406b7b7902f736984da7aecc5fa11882e3605beb4c07ee0eede48e6772e3ea054db650e6745a2981d803f1cd11e582cb297811afb3215fd4ff2e3f84a038e + checksum: 10/6b984288812e073c42a639b3c55ec8b376187eea5a260506ffe8665ff0098defe98924e78d8a5ab1e150bb64f168faa0e3686985c41363dbca48a3dc6739892f languageName: node linkType: hard -"@tamagui/babel-plugin@npm:^1.121.12": - version: 1.121.12 - resolution: "@tamagui/babel-plugin@npm:1.121.12" +"@tamagui/babel-plugin@npm:^1.126.3": + version: 1.126.3 + resolution: "@tamagui/babel-plugin@npm:1.126.3" dependencies: + "@babel/core": "npm:^7.25.2" "@babel/generator": "npm:^7.25.5" "@babel/helper-plugin-utils": "npm:^7.24.8" "@babel/template": "npm:^7.25.0" "@babel/traverse": "npm:^7.25.4" - "@tamagui/static": "npm:1.121.12" - checksum: 10/59dfc4deec9dd8651a84f61c444c439c89fd5d9dc1c8e540afcca69ac2242a1fa975ad29c3ab34050b8466962e36d5c908d509545c70aa8ab286eba2de673de7 + "@tamagui/static": "npm:1.126.3" + checksum: 10/1f26097033c4899418668e6e6976a91ae99100fd9407f814f2c3073aaf746d629dc80420192cb25dcff889e0a88cef5ecc99aff5565b02d3fab2c0c1bd3a9060 languageName: node linkType: hard -"@tamagui/build@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/build@npm:1.121.12" +"@tamagui/build@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/build@npm:1.126.3" dependencies: "@babel/core": "npm:^7.25.2" "@swc/core": "npm:^1.7.21" - "@tamagui/babel-plugin-fully-specified": "npm:1.121.12" + "@tamagui/babel-plugin-fully-specified": "npm:1.126.3" "@types/fs-extra": "npm:^9.0.13" chokidar: "npm:^3.5.2" - esbuild: "npm:^0.24.2" + esbuild: "npm:^0.25.0" esbuild-plugin-es5: "npm:^2.1.1" esbuild-register: "npm:^3.6.0" execa: "npm:^5.0.0" fast-glob: "npm:^3.2.11" fs-extra: "npm:^11.2.0" lodash.debounce: "npm:^4.0.8" - typescript: "npm:^5.7.2" + oxc-transform: "npm:^0.47.1" + typescript: "npm:^5.8.2" bin: tamagui-build: tamagui-build.js teesx: teesx.sh - checksum: 10/ccebb456f7d5e1eb1a23ad16e1d1d87f75160056069ab1c211bc40277a5a392203fefed1d97b0e7957f5f172e94823560bc091c62e50d02f77dba8b44fe95d92 + checksum: 10/bc38db1c6ab103943134047ccb551083669ea797dca91bc1dcba9811dbf808fb4e39a2bbfe70ad982150f4c5314edb83fa42b21db94e0e3d8e4a75bf8ece088b languageName: node linkType: hard -"@tamagui/button@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/button@npm:1.119.0" +"@tamagui/button@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/button@npm:1.126.3" dependencies: - "@tamagui/font-size": "npm:1.119.0" - "@tamagui/get-button-sized": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/helpers-tamagui": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/text": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/font-size": "npm:1.126.3" + "@tamagui/get-button-sized": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/helpers-tamagui": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/text": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/96a5ce5044643a972997dd0285ffbdb004ce4fa358d7a08d6242684953106b31298bd3c2db2c561dce05aa777d31a60876b564d2558ae6aab71e76e3c85d6e49 + checksum: 10/ac3219c0ea22be684e53d7b7b1b6da1f008d741ad2e901835b0d5a9cabf2c1f2d1107566ad3fbffc8a0c348149256a268f714fa37686a7e78bd1e5d924f4098d languageName: node linkType: hard -"@tamagui/card@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/card@npm:1.119.0" +"@tamagui/card@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/card@npm:1.126.3" dependencies: - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/82b11ea11f04176054dfb473f811596a0b212820f6e0174a5bdbdb287d4c2bf30a2234c86837ec42a14f46786d5abb687afe88ae44c4e065378f18452ddbfbc3 + checksum: 10/37e0d96c26692fe05ced96506ce46f9a603b77594ab4fd7ff3dd3051849310688ca13855d7ddd7d133f1ec0290acbf5e851d0547b50e51cd0c0f2da3d1a4f299 languageName: node linkType: hard -"@tamagui/checkbox-headless@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/checkbox-headless@npm:1.119.0" +"@tamagui/checkbox-headless@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/checkbox-headless@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/focusable": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/label": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" - "@tamagui/use-previous": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/focusable": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/label": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/use-previous": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/32d8b4316100ed611c635054f9e254dff9b6434a8d9264f98f0dd3ee588bcea4e8ea6edaf4e544e18092f051c1ddc0b285d6dcbf590cdcd476945722012df72f + checksum: 10/ac2f6834531abd3d2a5770c034498934741aa514e284a78822e8fa35dff5db51c21ca9f10cf807af83a48c000921e168f562846200e85b87bb535b2e468c2a54 languageName: node linkType: hard -"@tamagui/checkbox@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/checkbox@npm:1.119.0" +"@tamagui/checkbox@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/checkbox@npm:1.126.3" dependencies: - "@tamagui/checkbox-headless": "npm:1.119.0" - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/focusable": "npm:1.119.0" - "@tamagui/font-size": "npm:1.119.0" - "@tamagui/get-token": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/helpers-tamagui": "npm:1.119.0" - "@tamagui/label": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" - "@tamagui/use-previous": "npm:1.119.0" + "@tamagui/checkbox-headless": "npm:1.126.3" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/focusable": "npm:1.126.3" + "@tamagui/font-size": "npm:1.126.3" + "@tamagui/get-token": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/helpers-tamagui": "npm:1.126.3" + "@tamagui/label": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/use-previous": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/b52f66664f7fe7b9282c68e633903f9aeb845c34556ab94c508939935dc5760828f66870da2f05d1c87b62c442cceb5e733b426eed9ffe89fe398cfc3b322e64 + checksum: 10/6510acc07232cbc3b3ead713232359dc42dd4a87e28d6556eb6f7122d9d5bd48b4bffc9ac637b31387b7f2c0145fe43702f2bdbc531ea19f0329763c32a21490 languageName: node linkType: hard -"@tamagui/cli-color@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/cli-color@npm:1.121.12" - checksum: 10/9521b3e6bb79566972c36392c357a878455838b970f680c80e988dd2123b72606907ae3352c4ba54c699a1b0ec9d1a5e79eed0b7b1e4189a6c4b0e1ec9a39938 +"@tamagui/cli-color@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/cli-color@npm:1.126.3" + checksum: 10/cc6f5df71b4d0c31410e53be7a1e51d9312e7af81417e3d72824cce38cd488baf276eef5dfd8a120e5b4b9b844e0a1cedd686a9c9d39b2f3f7838be30f1ceb7f languageName: node linkType: hard -"@tamagui/collapsible@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/collapsible@npm:1.119.0" +"@tamagui/collapsible@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/collapsible@npm:1.126.3" dependencies: - "@tamagui/animate-presence": "npm:1.119.0" - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/polyfill-dev": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" + "@tamagui/animate-presence": "npm:1.126.3" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/polyfill-dev": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/0450ccc9f2264432a4f3d32aa4a641b6f95dc56a358360e63c1e1f86ed5329cfffc1c711efb97a62578a7dee56289445aac0a342d0a312cded19c68aa474016f + checksum: 10/348fa9720cbcc1073dd5ade7daac8eff7b72d288e9a727663717f7bed7cbc50bdc3d4144e897ab432a519ae981ecce91510e7c8089c7e91fcb9ba4707127803e languageName: node linkType: hard -"@tamagui/collection@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/collection@npm:1.119.0" +"@tamagui/collection@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/collection@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/polyfill-dev": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/polyfill-dev": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/edfad3d467a01134d8e2c7ff17b213a5be6155da7735c2ad32bfa2c4cedc6d2f8a9ced960fab36bbd97ee7cfd6034219c009f31783e64670f23506ffca0407ed + checksum: 10/2f995d3d39759271739fdee38ba594e0b763fc00cd74cfd5fc5944e38a45745651994955854163cdea130f80859a1b1048d87f7f9bd842b353f14d0a6b075b0e languageName: node linkType: hard -"@tamagui/colors@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/colors@npm:1.119.0" - checksum: 10/9444f353fb7880686e6c22fb8f72ae16ef9dbc8e84c81cfd27768e72c16cc4054621145329525d5d8581fa94a64411b4c9d989a404ab97759887e31a6bb0e972 - languageName: node - linkType: hard - -"@tamagui/compose-refs@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/compose-refs@npm:1.119.0" - peerDependencies: - react: "*" - checksum: 10/abc8c747c4997d6632636bcd9e498b8e414b1326796d5663c7722ec3611b832c404380c91c74bd7a88b59917efe214d9695ba828d30ba778c6edfd8fea7b2344 +"@tamagui/colors@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/colors@npm:1.126.3" + checksum: 10/d6f5737d3c50b3986e18e23bab495abd69c86aef5dabf9ee3eced41ba01c9b5241967f365051f566fe1d78963f8527ccd98a5e5c1897194775fef5741f909993 languageName: node linkType: hard -"@tamagui/compose-refs@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/compose-refs@npm:1.121.12" +"@tamagui/compose-refs@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/compose-refs@npm:1.126.3" peerDependencies: react: "*" - checksum: 10/2b4b46afd460f21beff05a38be753ddc4b0fb546026ad00c8b175a7ec7271cda5a43e6d01da4aa165e957158f8a231f9b4a5e4ddb2f286a666d483254c599b2d + checksum: 10/2e81af7de1fbc12a728026b846c25902c5965c8c928212469e433bd0b1f90c3a75219c4e1fa395aaee7aa8a819ba0997b006d5c9532e2defc2f9a97f2c6f7ea8 languageName: node linkType: hard -"@tamagui/config-default@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/config-default@npm:1.121.12" +"@tamagui/config-default@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/config-default@npm:1.126.3" dependencies: - "@tamagui/animations-css": "npm:1.121.12" - "@tamagui/core": "npm:1.121.12" - "@tamagui/shorthands": "npm:1.121.12" - checksum: 10/6b1ab4328c5a0ca90a858f75664fde9b6b5db74de086ca3d506a1259e78cf039a0f8a54251aa0adb1b9beb2dc33dcdd6cbc751356faa805010d5b16cf9e035a9 + "@tamagui/animations-css": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/shorthands": "npm:1.126.3" + checksum: 10/06968483723bd9c133333e86dd9621c732c7d89f1043f38b31596d781520afdaea5eb882b460fb702b24742023d039cf0a092d09ccdb76c7f4cd12f5e50cc0d8 languageName: node linkType: hard -"@tamagui/config@npm:^1.117.1": - version: 1.119.0 - resolution: "@tamagui/config@npm:1.119.0" +"@tamagui/config@npm:^1.126.3": + version: 1.126.3 + resolution: "@tamagui/config@npm:1.126.3" dependencies: - "@tamagui/animations-css": "npm:1.119.0" - "@tamagui/animations-moti": "npm:1.119.0" - "@tamagui/animations-react-native": "npm:1.119.0" - "@tamagui/colors": "npm:1.119.0" - "@tamagui/font-inter": "npm:1.119.0" - "@tamagui/font-silkscreen": "npm:1.119.0" - "@tamagui/react-native-media-driver": "npm:1.119.0" - "@tamagui/shorthands": "npm:1.119.0" - "@tamagui/themes": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" - checksum: 10/b53ce7b580d69af18395d511c15422b8496cc7a608eea576910c8a3aa62477b4783b4765f08b8abf40f012c26f4e83f7b457b87520853b2c80ad9916b4444111 + "@tamagui/animations-css": "npm:1.126.3" + "@tamagui/animations-moti": "npm:1.126.3" + "@tamagui/animations-react-native": "npm:1.126.3" + "@tamagui/colors": "npm:1.126.3" + "@tamagui/font-inter": "npm:1.126.3" + "@tamagui/font-silkscreen": "npm:1.126.3" + "@tamagui/react-native-media-driver": "npm:1.126.3" + "@tamagui/shorthands": "npm:1.126.3" + "@tamagui/themes": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" + checksum: 10/229c01144d34717496f613035d7b104427f2b13477882c3d4fd501ab711cef0702b6ad20340586fa6c74f86a07a0d93cae5afff1ffdb1a45aa99fd6baf21245b languageName: node linkType: hard -"@tamagui/constants@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/constants@npm:1.119.0" +"@tamagui/constants@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/constants@npm:1.126.3" peerDependencies: react: "*" - checksum: 10/32df92f27257137b02c2ad6d02aaa3027f59597b4fcfbb2441d273bd196302c026049d7ec93a032a3f5a5c8a501a3692c9989dcae2606d910d74986e679c0ad6 - languageName: node - linkType: hard - -"@tamagui/constants@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/constants@npm:1.121.12" - peerDependencies: - react: "*" - checksum: 10/a22913ce14a3effe4adfcab66d8ee45100a3db44505adbf6668a3b71cd1089698ee133e7635bceca68ae027c32ce2d38a84a85f9e72b7ad1eb5376c563771b99 - languageName: node - linkType: hard - -"@tamagui/core@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/core@npm:1.119.0" - dependencies: - "@tamagui/react-native-use-pressable": "npm:1.119.0" - "@tamagui/react-native-use-responder-events": "npm:1.119.0" - "@tamagui/use-event": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" - checksum: 10/ad92b71e37792601f6014e93a7119af407f22b3b506979eec18c72d139afc4ecca7c46d06d6910a111ca1e36811c73943f16964b14ac431ee6529a66429735d4 + checksum: 10/653ee8518496cb4975c3d0c5b837d3e0defe1f9a3a22755cdd2085b3e3b6f2d6f5612c0b727baec4c94b1e8ebbaec5ec6049339bd5b8d908fe853c8e18cd8e99 languageName: node linkType: hard -"@tamagui/core@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/core@npm:1.121.12" +"@tamagui/core@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/core@npm:1.126.3" dependencies: - "@tamagui/react-native-media-driver": "npm:1.121.12" - "@tamagui/react-native-use-pressable": "npm:1.121.12" - "@tamagui/react-native-use-responder-events": "npm:1.121.12" - "@tamagui/use-event": "npm:1.121.12" - "@tamagui/web": "npm:1.121.12" - checksum: 10/3113e1da003c8cfc7c19b142c45bdd6410b33ed2e4002901d7d04aed10eae53b9a63dfa1c4a6d72e579d940fa04f4b8cfc37220f948e856079361b1d36b3b61b + "@tamagui/react-native-media-driver": "npm:1.126.3" + "@tamagui/react-native-use-pressable": "npm:1.126.3" + "@tamagui/react-native-use-responder-events": "npm:1.126.3" + "@tamagui/use-event": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" + checksum: 10/409b4754df039b90aed0f6442c9d748318f5945ae49fc9bfda4c550d95bc3d828b547f208243986f1cd52318e0423168893d8105559d3869e0667ebf3e052c2b languageName: node linkType: hard -"@tamagui/create-context@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/create-context@npm:1.119.0" +"@tamagui/create-context@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/create-context@npm:1.126.3" peerDependencies: react: "*" - checksum: 10/2462ad6a5bbb18f1bcf0b0fab36ad33442788b347e32e105cfe7fdca6d595dc1d8df536da60bc4d75023e21a6e1c38a4b2112590b4faa13242395bd67e8af106 - languageName: node - linkType: hard - -"@tamagui/create-theme@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/create-theme@npm:1.119.0" - dependencies: - "@tamagui/web": "npm:1.119.0" - checksum: 10/6ef14304ac400630bec8d6c7d8e2ec3794e50ee36215bc445deaaf868153f6f279bc962489f9031b3ac85fa1f90910ce6a5550965ca8426e678fa2a367fb270c + checksum: 10/df4b43b7d96dc5f11ec423658ae216d6c5d7baf26992197ca610e10a53e3f35c75fbfe078a328de3c0346989f77dc31c6821547cabfa55f07c4709888b758626 languageName: node linkType: hard -"@tamagui/create-theme@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/create-theme@npm:1.121.12" +"@tamagui/create-theme@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/create-theme@npm:1.126.3" dependencies: - "@tamagui/web": "npm:1.121.12" - checksum: 10/e20be41869ff3d3118e962a5d6aab22066cfbb31f5cba3b11d537e77aecdc3baf207444a8817ea6430bf539e251bcc2b8725614826959528a610425a76997e03 + "@tamagui/web": "npm:1.126.3" + checksum: 10/2519e294e47a189cd45c5897dc41b9f3e8eb3015736e43ad6727770c791e778fb7762da4bbfd420818e3df41e9216069e0053922d57000b8c7164f91590268d7 languageName: node linkType: hard -"@tamagui/cubic-bezier-animator@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/cubic-bezier-animator@npm:1.119.0" - checksum: 10/222093f70b9d54d31fc12a6c7659cc9d797d6e915e63ab5e407c692ea61d7210628a0bcc1943767369bb710f2dcbb6680371c9a568552a796dab7f7574ec7998 +"@tamagui/cubic-bezier-animator@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/cubic-bezier-animator@npm:1.126.3" + checksum: 10/56f01a889f55cf5aa22aa8be2e74650a4789db2dfbdbebbc6ded4a85b3f66211bfb682e80fe5ac9be0600e9cfdd9e402f4697feabd53a49f1aa0514253f9d7f8 languageName: node linkType: hard -"@tamagui/cubic-bezier-animator@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/cubic-bezier-animator@npm:1.121.12" - checksum: 10/152bdaa6317939a93c04ecaa0f41fec42904faffbdf8fe2e3700fa96ab9d1a3ea9c33bacd12e988e885ab97e2fde59da466c9911da8176dbbe9b521c35c3f95c - languageName: node - linkType: hard - -"@tamagui/dialog@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/dialog@npm:1.119.0" +"@tamagui/dialog@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/dialog@npm:1.126.3" dependencies: - "@tamagui/adapt": "npm:1.119.0" - "@tamagui/animate-presence": "npm:1.119.0" - "@tamagui/aria-hidden": "npm:1.119.0" - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/dismissable": "npm:1.119.0" - "@tamagui/focus-scope": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/polyfill-dev": "npm:1.119.0" - "@tamagui/popper": "npm:1.119.0" - "@tamagui/portal": "npm:1.119.0" - "@tamagui/remove-scroll": "npm:1.119.0" - "@tamagui/sheet": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/text": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" + "@tamagui/adapt": "npm:1.126.3" + "@tamagui/animate-presence": "npm:1.126.3" + "@tamagui/aria-hidden": "npm:1.126.3" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/dismissable": "npm:1.126.3" + "@tamagui/focus-scope": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/polyfill-dev": "npm:1.126.3" + "@tamagui/popper": "npm:1.126.3" + "@tamagui/portal": "npm:1.126.3" + "@tamagui/remove-scroll": "npm:1.126.3" + "@tamagui/sheet": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/text": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/z-index-stack": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/484b9e167064977113dd9f93bba70bc6c6b9cb5d27300a8d6224d3d17101f2bfc0123ea41ac932a1b8bbce314ba535779e51cb525ccbd20cd41b70bbb6c5d8dc + checksum: 10/70cb248794c3651904fb450317b0ac42bf53121d2c694c53cc3e954e8c2c93cbcdbcbee7bcaa15a7b39157700db05a68e64d9ec90f8a23c91676034ff73c17a4 languageName: node linkType: hard -"@tamagui/dismissable@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/dismissable@npm:1.119.0" +"@tamagui/dismissable@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/dismissable@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/use-escape-keydown": "npm:1.119.0" - "@tamagui/use-event": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/use-escape-keydown": "npm:1.126.3" + "@tamagui/use-event": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/5a200c9ad0cb1d6bf3fae5acebb7b30717fc23796af44f3362197a8890978f3051f7a370cbdb5caa1dd92df6f372afdb90a3dc76855ebb89a4aa0aa6ade2b4e9 + checksum: 10/f548d7464bd35fdd63c8a233a14cf370afe03cee055319d8389b2a30766202a173d54c7d1a3ce6e9a2da3a63893482424cd71bd14797e61ff5fe5d1673d7bc66 languageName: node linkType: hard -"@tamagui/elements@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/elements@npm:1.119.0" +"@tamagui/elements@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/elements@npm:1.126.3" dependencies: - "@tamagui/core": "npm:1.119.0" + "@tamagui/core": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/cfc22124829034a425727fd22627bb9f069e13107eab69a26ab4a70671482dd83d21b9e121ab18f0b7cfd697ef87a8c1e528bb82faa9463d3c579e991b975db5 + checksum: 10/5b6aa2ba7ef4a34b5605adc2f136820faac0ff45415cbb8b9dc4a0504e19bb6054db098b2a612c47f3d607434b620d6ac58a3f64bbe97106724e41044f62ac0a languageName: node linkType: hard -"@tamagui/fake-react-native@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/fake-react-native@npm:1.119.0" - checksum: 10/592c062261751ce6285c42e81c994e71ca2e4b8cf67b36b9d0b4257aafbecd90e933921e04dc6fad4954ca8530b2049cae37c9e66d496bba9a8e26d2e77bcf10 +"@tamagui/fake-react-native@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/fake-react-native@npm:1.126.3" + checksum: 10/2fe2c5cef62d7293532a88fbdd3b07da2f94ffe026f48029f857be13043a3fd884398ae52370337c84abdb2f0cd2e48a7774cede866eab727a433a51ee5d0313 languageName: node linkType: hard -"@tamagui/fake-react-native@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/fake-react-native@npm:1.121.12" - checksum: 10/07dacfb5383a85dc53f38b929b21d5852180af290e1941155fa256faedff79ac5ff3f80ca8739a3abf72e5ec89806bbcb8e128e382c884b5bf4e95d52750f156 - languageName: node - linkType: hard - -"@tamagui/floating@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/floating@npm:1.119.0" +"@tamagui/floating@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/floating@npm:1.126.3" dependencies: "@floating-ui/react-dom": "npm:^2.1.2" - "@floating-ui/react-native": "npm:^0.10.6" + "@floating-ui/react-native": "npm:^0.10.7" peerDependencies: react: "*" - checksum: 10/00a8243e82da895674b905a86e9e61f52363096a9625308e99968c82d2e16c6f164ba5db7f47b9a6bc146570692da6bbe6b11f46a430ad27237e849cafc2dae2 + checksum: 10/2c350ae53915e709709b120561c983035d4ef2c522adda1f1ce32c192e41b8a363784e7567011807d191b7cca350734d7f866e4631d4bdc804545d56bf5578d5 languageName: node linkType: hard -"@tamagui/focus-scope@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/focus-scope@npm:1.119.0" +"@tamagui/focus-scope@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/focus-scope@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/start-transition": "npm:1.119.0" - "@tamagui/use-event": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/start-transition": "npm:1.126.3" + "@tamagui/use-event": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/adad53f405daa4643cda5332df01ff1e3e253aac554625f88497ecabe4d6fd00eac6bdf4f325fc240d5525b9744df0beb48a350e08e561eb4fcd6391e860013e + checksum: 10/0d0da7acef6f186acd4356a3758bc949643571b8bbaeadcb2abc2e9b04bccd038013df281fce972ed6a19f6e8338172b1bfe65380c11173d4289214a4989c5ce languageName: node linkType: hard -"@tamagui/focusable@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/focusable@npm:1.119.0" +"@tamagui/focusable@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/focusable@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/66cd2535c7748f303aa0d6d2905ed11d4b32aa5d86b85b700e0050705642899e1599e8b5b08fda24954f0c6fc259dbdf67afcce5add6b1aa8827f26887f34a39 + checksum: 10/2f9cecb9c9e85a89ff6a12a4f962464e440196948d162c1fa32aea07d56ae0125735f2df4b526f4fd136f534fdd3291a64b02b3a8f892ab6a990864304faaaaf languageName: node linkType: hard -"@tamagui/font-dm-sans@npm:^1.121.3": - version: 1.121.3 - resolution: "@tamagui/font-dm-sans@npm:1.121.3" - checksum: 10/90187a62c0cd668ef6bcd3810ae47b889a85dda316d023a3ada6cc959beae920ddf0f9a0a849b59d87eb7a5e7e065a1d305daeef44f7bb382c5909e16ff1ae9d +"@tamagui/font-dm-sans@npm:^1.126.3": + version: 1.126.3 + resolution: "@tamagui/font-dm-sans@npm:1.126.3" + checksum: 10/2e9dc3afe882552df0b28dafa2d490a6388cc9577bb48bceda4f26fab2d88ce5db09c5b073c31562caae8452cb880e8d4a1a0c96f3b9beee936f60b88fb1c0ae languageName: node linkType: hard -"@tamagui/font-inter@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/font-inter@npm:1.119.0" +"@tamagui/font-inter@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/font-inter@npm:1.126.3" dependencies: - "@tamagui/core": "npm:1.119.0" - checksum: 10/58163f8efefec0c65d2a0165231e1d0234b9af9ef4335ff3293a18fa560f22cba43bdd1e1c6fe388fc8d643bcd05fa4977cafe3ffe3e2bf2bcf84d1f0bbb2d1d + "@tamagui/core": "npm:1.126.3" + checksum: 10/5ab1537f87c5e032157c02cc4747986eaeab19c2ec45be7da1c4eed8a4fb0ba61d3674203019336c9a46c8a04e70c4927ddca2b4eb07f048e3762eb7a637039f languageName: node linkType: hard -"@tamagui/font-silkscreen@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/font-silkscreen@npm:1.119.0" +"@tamagui/font-silkscreen@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/font-silkscreen@npm:1.126.3" dependencies: - "@tamagui/core": "npm:1.119.0" - checksum: 10/34bec4d124e47bd6470fda9e3cbb35f22191e541b9f75d345a43acf135b09374860d6374d988bb75f0011dec209a16497560e4a8a931b4e6436e26a11609a6f7 + "@tamagui/core": "npm:1.126.3" + checksum: 10/bce34b5e6b4be0ce142dfcc067310edee2ea55f0e0367a397e8ae49476fe0f8efd7009d9a83f4e730196a777d11e79d84b5022b962c435ff54e7eafad843f003 languageName: node linkType: hard -"@tamagui/font-size@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/font-size@npm:1.119.0" +"@tamagui/font-size@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/font-size@npm:1.126.3" dependencies: - "@tamagui/core": "npm:1.119.0" + "@tamagui/core": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/2fa4d8c5817679fc7931ba2480ec7a1f7e843a663a6695451939139faf6eb421cc0f847e71303958014e7a301e5bdc88731dc62a05231ee7da01369b00617164 + checksum: 10/fed74dfe4bfa6a008cee4e968c1bb4553657e95b14491ae9739b24ddb0e7426f1d6c175c317569fd00b523723062b231e90aac8541dfea6247c0e9080c37e913 languageName: node linkType: hard -"@tamagui/form@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/form@npm:1.119.0" +"@tamagui/form@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/form@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/focusable": "npm:1.119.0" - "@tamagui/get-button-sized": "npm:1.119.0" - "@tamagui/get-font-sized": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/text": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/focusable": "npm:1.126.3" + "@tamagui/get-button-sized": "npm:1.126.3" + "@tamagui/get-font-sized": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/text": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/0fd4f9dbe9da3e8ea8f7ca76761762e106a2a497782152604c03540503c320e0014d7f9aa299d316a363ea8559be548c1ce5af5791cd29dd2184d4a270bbf8c0 + checksum: 10/d81347303c8f3aed3cbb948f5d8c93119ab00131b833450624b81bf9cbb39ebf0e5fd529d88447b7be0cd1bd6d796442a45d109ce19e34a166cb5b83515f5da0 languageName: node linkType: hard -"@tamagui/generate-themes@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/generate-themes@npm:1.121.12" +"@tamagui/generate-themes@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/generate-themes@npm:1.126.3" dependencies: - "@tamagui/create-theme": "npm:1.121.12" - "@tamagui/theme-builder": "npm:1.121.12" - "@tamagui/types": "npm:1.121.12" + "@tamagui/create-theme": "npm:1.126.3" + "@tamagui/theme-builder": "npm:1.126.3" + "@tamagui/types": "npm:1.126.3" esbuild-register: "npm:^3.6.0" fs-extra: "npm:^11.2.0" - checksum: 10/7ed721d02015df7581a36b1c184327bb46165500c1f1e8e3644e28e9c065c2088222b971c544e5be1e9c98cce8facafdbb7e715b72399fdf898208da46eadb13 + checksum: 10/2683f803eaf0836c583d5551aeccb043cdcc0ca113e308a9ef5de2b5939fbf46da4c5ead477be89ab24450422577608f9546dd5e961ea95c8c67294622faff68 languageName: node linkType: hard -"@tamagui/get-button-sized@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/get-button-sized@npm:1.119.0" +"@tamagui/get-button-sized@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/get-button-sized@npm:1.126.3" dependencies: - "@tamagui/get-token": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/get-token": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/7f0679b2c633aee93cdde26e9fb9c60b9cea08119360c058306f3a297342dcc21ea9ec969316e34e79b6131c5998924f41ad20bbea4fed2571f18898f4ad0b21 + checksum: 10/d93287ba90fc79e26aedc64e6d01b7ab9de4811a5ebad94c8bc4f554ac6c8df070770a44e70e8676d95b443fcd4bf3f7dd8142a8c816da92492eea8e2d9455aa languageName: node linkType: hard -"@tamagui/get-font-sized@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/get-font-sized@npm:1.119.0" +"@tamagui/get-font-sized@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/get-font-sized@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/cf783c254c2208fcce01fc5fb01fd6513a12f6036e33c27beb6a4809d5ef62ce4edbb14e057c253e6a97968e48b852c000072f8654fb38f28c776171253a2788 + checksum: 10/068861fd433ad5754de22c55953894de357d100968e0952acd1120e25d11d509d2a43f1c635722e396ad5af4de91165163618a45efb6444c6dac81827c95a4f9 languageName: node linkType: hard -"@tamagui/get-token@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/get-token@npm:1.119.0" +"@tamagui/get-token@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/get-token@npm:1.126.3" dependencies: - "@tamagui/web": "npm:1.119.0" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/e9a60b398961c8d59bc24bceeb3c629b4615010e4872e2e4218548026b60b571cc7fea7d9d886ce5d5815f0f2909864390ea0a5e6ddbe0bd59e1849bb081f684 + checksum: 10/526a5d9d61cde670b0f2ccbb6651383c16bace9cbcfa392c2263a2112a7a55c99915f25bbf17c275bfc640dd272bf591597d20ddde7ebe194ff4f53b8b268f77 languageName: node linkType: hard -"@tamagui/group@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/group@npm:1.119.0" +"@tamagui/group@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/group@npm:1.126.3" dependencies: - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/ab58ca946162f72d8491d5ee23041c140e514450a32d1d5b1639bfd38343fc019d7d5ed3732bc0a5735cd0c26b7be0db7b90060ff182bf5f11c0816e0afd1495 + checksum: 10/423066534e2013093c77c0ec1233b43b720fee8cdbdb0cdb9cc4b50d9e5738c64272dc38d1f52a353a41717d484afe10bbb4c0e6731c56e398a71c752fd270f6 languageName: node linkType: hard -"@tamagui/helpers-node@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/helpers-node@npm:1.121.12" +"@tamagui/helpers-node@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/helpers-node@npm:1.126.3" dependencies: - "@tamagui/types": "npm:1.121.12" - checksum: 10/59e4a0ac238ca22cab217ef0489084530f91e5fb2fafcee831c31409039143ea7f78fef0e365657e7f2020b90c3ec5010293e228a3cc33d36b0df7de5d85ceb1 + "@tamagui/types": "npm:1.126.3" + checksum: 10/67daa8ba73dd354f81fbf28fef03e09563e49955e483a3277b8b45d258641c05fd5b3ba9406c65c2e26abb9322ad5475242d5f45cf8b05123573a6cd062a98bb languageName: node linkType: hard -"@tamagui/helpers-tamagui@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/helpers-tamagui@npm:1.119.0" +"@tamagui/helpers-tamagui@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/helpers-tamagui@npm:1.126.3" dependencies: - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/878997f481e07c79b180192bcb9a6262b1a1b3617eb452cdeed5d813edae7ddddc4ec17bd951e3d6032d8b6bae7644d481b28d33d3376989fa21d35d173bdb1a - languageName: node - linkType: hard - -"@tamagui/helpers@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/helpers@npm:1.119.0" - dependencies: - "@tamagui/constants": "npm:1.119.0" - "@tamagui/simple-hash": "npm:1.119.0" - checksum: 10/3d941b7fcd33f0959928a665eaeb483a37be60bd2fbf3f6e0278d7e7f820483a52f517317f2711f569625badc2739ab95597fb15b804b2da9c5642b52273b764 + checksum: 10/0030da8ad750546e8085b1e844e7ecb3651246719baeca2b7ff5202c8f2b755a9fc527325d38bb68158d6396c0779f0ddce8ed1d2c695ec5369a2e59f82eb781 languageName: node linkType: hard -"@tamagui/helpers@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/helpers@npm:1.121.12" +"@tamagui/helpers@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/helpers@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.121.12" - "@tamagui/simple-hash": "npm:1.121.12" - checksum: 10/bfc38a6d74942643ea8c528b1221b989b4dd39739e763968fee25a01a98257df6f61623a7bf4c7aa3edf881a0804e71f19f9da3c95da9b9f434256f94b889e43 + "@tamagui/constants": "npm:1.126.3" + "@tamagui/simple-hash": "npm:1.126.3" + checksum: 10/a2d70e6789910b87c7b08f55e69f0e9cb6c2c5e3d90a23ba9a50befd42f72316485e797e9b3fa1d5419092f82a24bb6bcc1d33c672a60ab1ba5e7ec0ff8653e2 languageName: node linkType: hard -"@tamagui/image@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/image@npm:1.119.0" +"@tamagui/image@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/image@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/04bbea7cc22450e9181c4dcde470e0273f33467ecc91f393bf2dbf0d887763b8439381d895dc98538e8ed3dbd72cd153f4d10cde1d425a35efba098635ab402a + checksum: 10/80fc20ccb088eedfe5f7a51d5d912a3ea71de8a62cce18cf87034bd4e88d6048b414dd3f1a6325de2f4895ee2e8230a579c01eda9b7a40996ccac487a8cc09c3 languageName: node linkType: hard -"@tamagui/label@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/label@npm:1.119.0" +"@tamagui/label@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/label@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/focusable": "npm:1.119.0" - "@tamagui/get-button-sized": "npm:1.119.0" - "@tamagui/get-font-sized": "npm:1.119.0" - "@tamagui/text": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/focusable": "npm:1.126.3" + "@tamagui/get-button-sized": "npm:1.126.3" + "@tamagui/get-font-sized": "npm:1.126.3" + "@tamagui/text": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" react-native: "*" - checksum: 10/74fed94d05f4c1ea8c76e79a2e965a77ea6b7729b3217b39d642caee936bbf43e3f2a7254a405d10fab1e17e62748501353a16d0479f07c9f433da4d4434b28f + checksum: 10/7f9b16744eccf9ed60d9128e0c20477611e2784ba6af05344538466aea9b927bf0bfa1296a5aacca494b835d5fe41b3181c0db4ecaaa66d609175c76fb448a10 languageName: node linkType: hard -"@tamagui/linear-gradient@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/linear-gradient@npm:1.119.0" +"@tamagui/linear-gradient@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/linear-gradient@npm:1.126.3" dependencies: - "@tamagui/core": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" + "@tamagui/core": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/b45f8fcccd1586c610ffa5e20709c7207fe3f13f1f2752b6f0e06e2157cc0cb3accc17cc6c38290427e1f0d016dcb9f353fed762d4d53797fca6193690cce1cc + checksum: 10/f984b9b780deee84fa8f1586fe4eef747d95bfc731e8bc8b1a1c80158ddb761a2988df2ccc5d1f450d7daba3944bf2992781434e300c2f322937a7510a27d87b languageName: node linkType: hard -"@tamagui/list-item@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/list-item@npm:1.119.0" +"@tamagui/list-item@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/list-item@npm:1.126.3" dependencies: - "@tamagui/font-size": "npm:1.119.0" - "@tamagui/get-font-sized": "npm:1.119.0" - "@tamagui/get-token": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/helpers-tamagui": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/text": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/font-size": "npm:1.126.3" + "@tamagui/get-font-sized": "npm:1.126.3" + "@tamagui/get-token": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/helpers-tamagui": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/text": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/cb713811a8860ba1ece3d6767f842d53603d84ca9a35d840a9f2ffeef52bd506cff70e5f3995e74dc9aa7ca24f14e1b8c4a94973a0280fa66ec849f9624b01b4 - languageName: node - linkType: hard - -"@tamagui/normalize-css-color@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/normalize-css-color@npm:1.119.0" - dependencies: - "@react-native/normalize-color": "npm:^2.1.0" - checksum: 10/158c680d69b162c38468e9004b11fbb3948a9dabbc639bdce00b42a9c369e9b042a5a3cd00bdf78391edfd938ee5f87dac43630f60212f1dfb962b37f200a342 + checksum: 10/6bd9698369df30862cb15b788a0c8c4893fe008cfffbbff3277ebdce390504c9f0a30a231c85ea81935e0e7be16b54f085d215fe8e636f20a4f051bedcc032cb languageName: node linkType: hard -"@tamagui/normalize-css-color@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/normalize-css-color@npm:1.121.12" +"@tamagui/normalize-css-color@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/normalize-css-color@npm:1.126.3" dependencies: "@react-native/normalize-color": "npm:^2.1.0" - checksum: 10/59ca2116355dd4fe19090bc1f43cb794429ed7f8ef6bafb7647c10b00740f2f7cf6c05225bd845751df9e540fe0e1c450f7a44cd86bd6d5c1829a82f62e6265f - languageName: node - linkType: hard - -"@tamagui/polyfill-dev@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/polyfill-dev@npm:1.119.0" - checksum: 10/6055c33f013992f21142c227f7480eddd826d037bace4a3c7555bb158200dbb04068e0c02575fb3415df6a55a992b957faa949b0fceb0730320af3b7ff7512ad - languageName: node - linkType: hard - -"@tamagui/popover@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/popover@npm:1.119.0" - dependencies: - "@floating-ui/react": "npm:^0.26.24" - "@tamagui/adapt": "npm:1.119.0" - "@tamagui/animate": "npm:1.119.0" - "@tamagui/aria-hidden": "npm:1.119.0" - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/dismissable": "npm:1.119.0" - "@tamagui/floating": "npm:1.119.0" - "@tamagui/focus-scope": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/polyfill-dev": "npm:1.119.0" - "@tamagui/popper": "npm:1.119.0" - "@tamagui/portal": "npm:1.119.0" - "@tamagui/remove-scroll": "npm:1.119.0" - "@tamagui/scroll-view": "npm:1.119.0" - "@tamagui/sheet": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" + checksum: 10/7275a75a3540c4284ecd7881bdf90b37093349f431766501a7f45242d742ae899590346d58efa7b9a655d4436e277330d9dc6a0fa4c2ab523ef03df7034bbfdd + languageName: node + linkType: hard + +"@tamagui/polyfill-dev@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/polyfill-dev@npm:1.126.3" + checksum: 10/30f5198208d838b8da7aa4383c1eee65bbfaccd15da588a5c269c8e676f21bbeb7c6fe282ac00fc4caf58487daf46c0147a6cb81e4908644e6650cff0742d64a + languageName: node + linkType: hard + +"@tamagui/popover@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/popover@npm:1.126.3" + dependencies: + "@floating-ui/react": "npm:^0.27.4" + "@tamagui/adapt": "npm:1.126.3" + "@tamagui/animate": "npm:1.126.3" + "@tamagui/aria-hidden": "npm:1.126.3" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/dismissable": "npm:1.126.3" + "@tamagui/floating": "npm:1.126.3" + "@tamagui/focus-scope": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/polyfill-dev": "npm:1.126.3" + "@tamagui/popper": "npm:1.126.3" + "@tamagui/portal": "npm:1.126.3" + "@tamagui/remove-scroll": "npm:1.126.3" + "@tamagui/scroll-view": "npm:1.126.3" + "@tamagui/sheet": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" react-freeze: "npm:^1.0.3" peerDependencies: react: "*" - checksum: 10/b5cbfdbb83559cd2b182748f25ca07f06c62916c1af9869868fcdefd31d50f4db0b65dc0c89f11a86e944b444c6e8fc76f7978896cb2c8bc3000782f88ab12c2 + checksum: 10/cebb3d28561ab9a204bb062f8358ca3a3a51d2baf3c770860fad8f78e8de27c6a7874efa146fb7b7a6dc268049540460f9fe6f1d3d01462209bd7b95d385e2b5 languageName: node linkType: hard -"@tamagui/popper@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/popper@npm:1.119.0" +"@tamagui/popper@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/popper@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/floating": "npm:1.119.0" - "@tamagui/get-token": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/start-transition": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/floating": "npm:1.126.3" + "@tamagui/get-token": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/start-transition": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/0d5eb2df04a1548d0504efc8348a748f1e9cc8bd5642d9998dc67a6fb3b11c5ec92a9e694279d7cb646e6880727cd18d1dc6a4abe5f6dcc9d6b4c0c62485264a + checksum: 10/5910bd5168c9bda5ea654773d94da8c4ac11074e4e37a12ca222ac3a5b15dd314a8b73abadcd4ea291fe9d1258b2f59bf79e5c60695b6146ff0693336a704382 languageName: node linkType: hard -"@tamagui/portal@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/portal@npm:1.119.0" +"@tamagui/portal@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/portal@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/start-transition": "npm:1.119.0" - "@tamagui/use-did-finish-ssr": "npm:1.119.0" - "@tamagui/use-event": "npm:1.119.0" - checksum: 10/959da3052db39b8c241b2c7f9811760f3bc42caabaee1c8b513896d1897cb66f3a12b710880bd0da8b2f080cab185ba96be9d837645307d0678713fda744d0cc + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/start-transition": "npm:1.126.3" + "@tamagui/use-did-finish-ssr": "npm:1.126.3" + "@tamagui/use-event": "npm:1.126.3" + "@tamagui/z-index-stack": "npm:1.126.3" + checksum: 10/806d0dbc3eb35aeff52e03d5302544454f6b9148a225092bc77d431bcca868ae616f0010a45d39dfd2ff73b051379d2c90d9b51d4cc8b1595e7ecc8be3271845 languageName: node linkType: hard -"@tamagui/progress@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/progress@npm:1.119.0" +"@tamagui/progress@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/progress@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/get-token": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/get-token": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/f8c510ae02bb1583e1bcb7e0d4e436b89aa0936f9e9b78cc85a33a4a94a729f486f825c77e72c26a2054661124ea3c38a4c0e4a9e1984ac36af51ffddb471d83 + checksum: 10/00a33c28fdc73ad6ab9efe795aacce52c5cc9d13eb6186cf0bfafc87933ea6dcbb5ccf9e84a7d11164e76aafb4b3835415b4c70e138dbea1d683f0abce2e9548 languageName: node linkType: hard -"@tamagui/proxy-worm@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/proxy-worm@npm:1.121.12" - checksum: 10/982407562030d3944e3ebb0b5e3036c899183937117eb3c1a0b5068b3a31888a53c004bb0fced236b6bde5c518b03eed032a12bedb99eb19095fd6128b364881 +"@tamagui/proxy-worm@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/proxy-worm@npm:1.126.3" + checksum: 10/aa7b80a63ec7042a04750b162166ac664a4c4346c8db0280743b47c4bd4031cc7d8f4841a129ad471e3ce69e83bbb5247e304d55c05be9753067c53182fd22d6 languageName: node linkType: hard -"@tamagui/radio-group@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/radio-group@npm:1.119.0" +"@tamagui/radio-group@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/radio-group@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/focusable": "npm:1.119.0" - "@tamagui/get-token": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/label": "npm:1.119.0" - "@tamagui/radio-headless": "npm:1.119.0" - "@tamagui/roving-focus": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" - "@tamagui/use-previous": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/focusable": "npm:1.126.3" + "@tamagui/get-token": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/label": "npm:1.126.3" + "@tamagui/radio-headless": "npm:1.126.3" + "@tamagui/roving-focus": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/use-previous": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/810f1abb2d2785f2963f6f00eb7b3091686356c7ebcc3c7f1938cfd2ad0c81dda2f1d6d76d45669d6b9cd9b04e2f385978422bf9176ef913fd2f78424690d863 + checksum: 10/ef1f71c9c2784eb5d975ea27cc731eb1b51730804c5c8193eb7bb6ec0eeba0303bb2b690500ca92c1aa63ca6124489d40d21c6973df54e39596bffdd2812fa10 languageName: node linkType: hard -"@tamagui/radio-headless@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/radio-headless@npm:1.119.0" +"@tamagui/radio-headless@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/radio-headless@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/focusable": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/label": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" - "@tamagui/use-previous": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/focusable": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/label": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/use-previous": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/805d3f8c32c23fa38385f0325606993a29ee56e27fda03bc87a9fa37f8fb2ab2c8a98ef6469e3cc3863983c3f6128a032b3840fe695c7daa99354481a37b38b6 - languageName: node - linkType: hard - -"@tamagui/react-native-media-driver@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/react-native-media-driver@npm:1.119.0" - dependencies: - "@tamagui/web": "npm:1.119.0" - peerDependencies: - react-native: "*" - checksum: 10/cda0c124bd72204a6f77fa8ee3b71a064ad903ccd49ad465eeb83618ea73765fbf31ab36888dc940f386ada5c179c54daed368622e84700abc49f7c7d3c48db2 + checksum: 10/6d3e85f52f22d91723cdad4b1615338166ece7e0e53fa8017083e6f2001d4f1d0f445df7e9f70ac3ca1d5407afb74df235e2396eac849c1de8eb76796baa8f96 languageName: node linkType: hard -"@tamagui/react-native-media-driver@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/react-native-media-driver@npm:1.121.12" +"@tamagui/react-native-media-driver@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/react-native-media-driver@npm:1.126.3" dependencies: - "@tamagui/web": "npm:1.121.12" + "@tamagui/web": "npm:1.126.3" peerDependencies: react-native: "*" - checksum: 10/89e0577e99ac7438314ba38777dfc6a644c8f6625ec065dce6777d89dccab0f1a5eeaa5958941bd4e2d41f6527229aa3333c5867910a40900126279fa4d0e3f0 + checksum: 10/ef7c105bf86d03ef7d9b41a1d15e77dac73650dd9e3ff7757735ae5b24b0053951aadeb85e7de04ff9bc41d28e5ce29c1543b03bb244e2122d3967c5f0c8bcee languageName: node linkType: hard -"@tamagui/react-native-use-pressable@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/react-native-use-pressable@npm:1.119.0" +"@tamagui/react-native-use-pressable@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/react-native-use-pressable@npm:1.126.3" peerDependencies: react: "*" - checksum: 10/9c5d58e7da68a6254817beedb6ba92cd2165f41b89826f0c5338d13ae8725367485a284d4ea095825f2f549d6c27bdf0614021c5fc62f10977af52f591f379a2 + checksum: 10/900048fad4b7223e81da02fdad069842988c73ab58792e34453d7c1f461164631887c12645f9e3934f4a579483588ba22a15be3c014b7af51c89d1321a798445 languageName: node linkType: hard -"@tamagui/react-native-use-pressable@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/react-native-use-pressable@npm:1.121.12" +"@tamagui/react-native-use-responder-events@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/react-native-use-responder-events@npm:1.126.3" peerDependencies: react: "*" - checksum: 10/7f154f12eda486c0fab608433a2d23213d199a43c98ff1b82b338732058814dc000f619b54906f474107a34bf5c9fc2f6f54ddb9c49d418e763ff16a7b953e77 + checksum: 10/9c7ecff3072acdfb9e002fcfdf0e4e11dbaae2e09e4c05df4266aca7d77efb7731ef00c2bdd3f693dc6a16fde4b497fd4f105edba62699c1db2c0f6936d348ea languageName: node linkType: hard -"@tamagui/react-native-use-responder-events@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/react-native-use-responder-events@npm:1.119.0" - peerDependencies: - react: "*" - checksum: 10/d60e7d3f3125863d8a8dec4df02a70b4bc0556db1689b119f830444716b62b15d8160a28ce28bbfb449cccc6f14768fc4582c1d48440921e6877b2b9192b55f8 - languageName: node - linkType: hard - -"@tamagui/react-native-use-responder-events@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/react-native-use-responder-events@npm:1.121.12" - peerDependencies: - react: "*" - checksum: 10/f64ca50c56e01b3c357f5f022ceed90d599e2a7867f115949d12cc7b1b9d8950cc9e4fefc2f463f8ee752ddf7ba98311bd15009cf0c5bbc8bd8e47b6e09b3b16 - languageName: node - linkType: hard - -"@tamagui/react-native-web-internals@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/react-native-web-internals@npm:1.121.12" +"@tamagui/react-native-web-internals@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/react-native-web-internals@npm:1.126.3" dependencies: - "@tamagui/normalize-css-color": "npm:1.121.12" - "@tamagui/react-native-use-pressable": "npm:1.121.12" - "@tamagui/react-native-use-responder-events": "npm:1.121.12" - "@tamagui/simple-hash": "npm:1.121.12" - "@tamagui/web": "npm:1.121.12" - checksum: 10/b76a24a94b4fa08da95780d12c49ad9941f9bcda9f3c9d93fde0418a75e9f9a90791492672524c8ec53fd27a547cac44a88fcfe29502d4d0c9863d4485f76479 + "@tamagui/normalize-css-color": "npm:1.126.3" + "@tamagui/react-native-use-pressable": "npm:1.126.3" + "@tamagui/react-native-use-responder-events": "npm:1.126.3" + "@tamagui/simple-hash": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" + react: "npm:*" + checksum: 10/8c710cb2abd0146295b1d75c979f030915d3e9392b2ce6c5072b601372e0af93848e12aa10d7d9746b7d75f7543c1e883a79f20267796a84ae8a9790e752a4af languageName: node linkType: hard -"@tamagui/react-native-web-lite@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/react-native-web-lite@npm:1.121.12" +"@tamagui/react-native-web-lite@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/react-native-web-lite@npm:1.126.3" dependencies: - "@tamagui/normalize-css-color": "npm:1.121.12" - "@tamagui/react-native-use-pressable": "npm:1.121.12" - "@tamagui/react-native-use-responder-events": "npm:1.121.12" - "@tamagui/react-native-web-internals": "npm:1.121.12" + "@tamagui/normalize-css-color": "npm:1.126.3" + "@tamagui/react-native-use-pressable": "npm:1.126.3" + "@tamagui/react-native-use-responder-events": "npm:1.126.3" + "@tamagui/react-native-web-internals": "npm:1.126.3" invariant: "npm:^2.2.4" peerDependencies: react: "*" - checksum: 10/2205b81556cda2f8072c745b42a17278c382112df4e49d81cddd35e8f82774db70c550b86af399dfa1438d1a3a867d30a1802063a726b1285f9b63e8c047f923 + checksum: 10/6b7888ac2d7e85457a9debb764d2c351da01b5135912aeb49043fd3cc63f5c41bb7462bf4b0244fde903f3dbf38d8abf3267db8c9a047b37a9e1a61c3d90d734 languageName: node linkType: hard -"@tamagui/remove-scroll@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/remove-scroll@npm:1.119.0" +"@tamagui/remove-scroll@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/remove-scroll@npm:1.126.3" dependencies: react-remove-scroll: "npm:^2.6.0" peerDependencies: react: "*" - checksum: 10/468e467c68194052460b6881c5aa5f6c2eb8fb918a03dddb9ae8323fa40b90d88c469f5b31aee67ee87f8f06f874327cbab180aef4aa5fc0298453935bcf0320 + checksum: 10/c51adf69c08a441a01626bd6f2320e8d11f416e056a5b40241afef240a84950425ce8c3a6c298eb8e2e6d05a471dfcf2af579a3a4adc6c1921792888d36fd518 languageName: node linkType: hard -"@tamagui/roving-focus@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/roving-focus@npm:1.119.0" +"@tamagui/roving-focus@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/roving-focus@npm:1.126.3" dependencies: - "@tamagui/collection": "npm:1.119.0" - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" - "@tamagui/use-direction": "npm:1.119.0" - "@tamagui/use-event": "npm:1.119.0" + "@tamagui/collection": "npm:1.126.3" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/use-direction": "npm:1.126.3" + "@tamagui/use-event": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/f3c33ff8d2a31c00d0a848a223eaa30cf706d4d294374bae6f86e6aba85f8a757650785a220bbce23a4a846ad5cd3968a8bb60079d99dd8f9b0eaade0ccec566 + checksum: 10/7aba54f26c71e840dd8f77f8d80d359b34eb3922985037d95108d5eacdda3925f5c32c9e95e3a20b386b27fc3445503187516e6701e84ad84cb77f06c4f54635 languageName: node linkType: hard -"@tamagui/scroll-view@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/scroll-view@npm:1.119.0" +"@tamagui/scroll-view@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/scroll-view@npm:1.126.3" dependencies: - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/fe960d896815fb35feb424e417619ff8779ec78cd6b9b0f9cea59c3c7ce4aeffd96785977e147d5634f870c6a6dd925f6a53e08c23e141e534b3b22bb951aa35 + checksum: 10/458d99786517ba389901ef391bbac09fd47f82d503f3ff57ec8cb6548ef95b33fc453e6dfb1e0919e18830fb0bcc3661934faede794fcd148e5c5bc7bf7a62ba languageName: node linkType: hard -"@tamagui/select@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/select@npm:1.119.0" +"@tamagui/select@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/select@npm:1.126.3" dependencies: - "@floating-ui/react": "npm:^0.26.24" + "@floating-ui/react": "npm:^0.27.4" "@floating-ui/react-dom": "npm:^2.1.2" - "@floating-ui/react-native": "npm:^0.10.6" - "@tamagui/adapt": "npm:1.119.0" - "@tamagui/animate-presence": "npm:1.119.0" - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/dismissable": "npm:1.119.0" - "@tamagui/focus-scope": "npm:1.119.0" - "@tamagui/get-token": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/list-item": "npm:1.119.0" - "@tamagui/portal": "npm:1.119.0" - "@tamagui/remove-scroll": "npm:1.119.0" - "@tamagui/separator": "npm:1.119.0" - "@tamagui/sheet": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/text": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" - "@tamagui/use-debounce": "npm:1.119.0" - "@tamagui/use-event": "npm:1.119.0" - "@tamagui/use-previous": "npm:1.119.0" + "@floating-ui/react-native": "npm:^0.10.7" + "@tamagui/adapt": "npm:1.126.3" + "@tamagui/animate-presence": "npm:1.126.3" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/dismissable": "npm:1.126.3" + "@tamagui/focus-scope": "npm:1.126.3" + "@tamagui/get-token": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/list-item": "npm:1.126.3" + "@tamagui/portal": "npm:1.126.3" + "@tamagui/remove-scroll": "npm:1.126.3" + "@tamagui/separator": "npm:1.126.3" + "@tamagui/sheet": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/text": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/use-debounce": "npm:1.126.3" + "@tamagui/use-event": "npm:1.126.3" + "@tamagui/use-previous": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/187c7292b9a2cc00b3c9ac2147b50487f02272234110e8717581cd0ea96d62fffa48c4674904dac772dfbcaa9d21fa6b3a44bc704118aa8dd0f5743f792924bd + checksum: 10/eecf757cb6892452ac3287d3fa7820d34744c30de63fa1739c5a0da04ca38d792977b08ef8480d1440cf713e8cde6991588930365685ca4a27a6d8fa50a15b48 languageName: node linkType: hard -"@tamagui/separator@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/separator@npm:1.119.0" +"@tamagui/separator@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/separator@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/15e401d5b0107bb58c17cfbddef283f7723f6a87406a75094fb98bea773be1b3c3217ae0b11471ef218b43fad9132e1d65d2d455a34f3592f93d64a1cc6fd3a3 + checksum: 10/f3d78b92db1e745f5b638901463e53e73e8483300278003e8dc98b335f3b7b5dcb17b012d13cdecf69b28391417e15a84ed4c31153021b890cf95dcb456b3bb6 languageName: node linkType: hard -"@tamagui/shapes@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/shapes@npm:1.119.0" +"@tamagui/shapes@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/shapes@npm:1.126.3" dependencies: - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/92ddbcda915c6b5a29916934b7209d3ee1b579246b9a521ce58177a1efc73018fbffb5e83eace94ea5464a22810fdae9a22f4dfbae32da2cab9ba6778466ec8d - languageName: node - linkType: hard - -"@tamagui/sheet@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/sheet@npm:1.119.0" - dependencies: - "@tamagui/adapt": "npm:1.119.0" - "@tamagui/animate-presence": "npm:1.119.0" - "@tamagui/animations-react-native": "npm:1.119.0" - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/portal": "npm:1.119.0" - "@tamagui/remove-scroll": "npm:1.119.0" - "@tamagui/scroll-view": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/use-constant": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" - "@tamagui/use-did-finish-ssr": "npm:1.119.0" - "@tamagui/use-keyboard-visible": "npm:1.119.0" + checksum: 10/2df8592d452dd34d22170361751e23584b47f951256b51764b776103f26f56ee9abe8c91957ddb8eeb442d6861247f6ff8b845772270f384523f424cfe296c13 + languageName: node + linkType: hard + +"@tamagui/sheet@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/sheet@npm:1.126.3" + dependencies: + "@tamagui/adapt": "npm:1.126.3" + "@tamagui/animate-presence": "npm:1.126.3" + "@tamagui/animations-react-native": "npm:1.126.3" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/portal": "npm:1.126.3" + "@tamagui/remove-scroll": "npm:1.126.3" + "@tamagui/scroll-view": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/use-constant": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/use-did-finish-ssr": "npm:1.126.3" + "@tamagui/use-keyboard-visible": "npm:1.126.3" + "@tamagui/z-index-stack": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/7be8c90f3ea95c2abe291c2e1cb3e6b4baca11915379ed9ce755a8fdfa8c7b03d3a759e33114f13fbf3df74644e659cf183c52f31abae484a44d63802a153b49 + checksum: 10/d453e8481a86c841b20590ecda339cd4cc2edb63f1276f498c671fe50ef105cbd70b454862402a2f6a917f9c118ceb7bddde58e7a7efc691ecfbd387dafab023 languageName: node linkType: hard -"@tamagui/shorthands@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/shorthands@npm:1.119.0" - checksum: 10/a7ca299ca4f8e840eed76f5a4ab1fcea1ac8bdef85a1d412954a16519a41a0fc96985493acc19686697e722288ecabbe91c886e6d20130dd646d8231ab9b4fb1 - languageName: node - linkType: hard - -"@tamagui/shorthands@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/shorthands@npm:1.121.12" +"@tamagui/shorthands@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/shorthands@npm:1.126.3" dependencies: - "@tamagui/web": "npm:1.121.12" - checksum: 10/1d7afe832e52e3ca17853e4869b5803f0aa584918d3944230f21864ed753489bfea98418f258ea9d6b41d263b9b6ed0b933d7b89682b91aa3217f82407a0276c - languageName: node - linkType: hard - -"@tamagui/simple-hash@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/simple-hash@npm:1.119.0" - checksum: 10/55a1864e47b1d37ae504913d20b71733e3ef88775f868977b2a47a7bb4357f3771f300962e579a316585800e9de0110a6034325500381447c9c1cdead7b157a8 + "@tamagui/web": "npm:1.126.3" + checksum: 10/3b75681761779acfb2c9b815963f28cc319e731ab6eee3c06a5923f05f71b6cc21674d507ab6bf573f3c48b707952ee448c1209560eec2973afeda22e0273bc7 languageName: node linkType: hard -"@tamagui/simple-hash@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/simple-hash@npm:1.121.12" - checksum: 10/a64e964cd06933f83bba60d0633667b134b8df07f40176d17a4588be5c75896c17cb1a4e975782c0f0142b7df354007bcaa76206c1253d06bfd679ac0a41a9aa +"@tamagui/simple-hash@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/simple-hash@npm:1.126.3" + checksum: 10/587314bea4dbf13e6fce0d6717240aafbde1a589c586180b23fc43f6fd6c91f3b53eb777de1da30473ac029948323c80ec1f631874caa92f9c41cb61be1b077e languageName: node linkType: hard -"@tamagui/slider@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/slider@npm:1.119.0" +"@tamagui/slider@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/slider@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/get-token": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" - "@tamagui/use-debounce": "npm:1.119.0" - "@tamagui/use-direction": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/get-token": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/use-debounce": "npm:1.126.3" + "@tamagui/use-direction": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/04baa7c81871dc0d0f413b10a0b6f33b1bdb2b75a80a81900d26594d62efe0c31df294ec91558a747ddcde94b60e31fad7ece1e6afb17f44b8f2f62ce69d7631 + checksum: 10/fb6e0a4fe4b1e0545f764af8fbf1f3363ac8fa42c9ecdb6ff632555a2befc5e7285d52e94016de26dece7cbe85fb2fac91d51e770820efb2c97d573fe81ec473 languageName: node linkType: hard -"@tamagui/stacks@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/stacks@npm:1.119.0" +"@tamagui/stacks@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/stacks@npm:1.126.3" dependencies: - "@tamagui/core": "npm:1.119.0" + "@tamagui/core": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/461cf0c995a1cdb17866794380ef772a65589a0e39263df131a41a7279ebf9cbc2c58e45e5fc1ed3f3ad9a0dc1ef46abbb8e3ea4ecd54418058436e8ef8c3dee + checksum: 10/05a2dfb8847b1e50db7b47060cdcaa36e660f68ddafe292261cff7f8c4566545456992577fe3e6a4fc1602e6a9465bf309badd9b6c922a6f4602d10e6a6c3c19 languageName: node linkType: hard -"@tamagui/start-transition@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/start-transition@npm:1.119.0" - checksum: 10/00c849e9f740c7453620379918a0d648be77fc44d302bb4280ce9c7cb0c6a994e310d6387f461bbd55c1cf8d8db74cf914ef91307357967d00372bbf58e2ad5e +"@tamagui/start-transition@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/start-transition@npm:1.126.3" + checksum: 10/cea281f7f1d24a54f4ad2dc939c5c46b1d12b7f022418d2e8bc4227b9fee3441434801801dd52316d2d02ecc435f5c6ead29460466c5690621150514233b5d8e languageName: node linkType: hard -"@tamagui/static@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/static@npm:1.121.12" +"@tamagui/static@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/static@npm:1.126.3" dependencies: "@babel/core": "npm:^7.25.2" "@babel/generator": "npm:^7.25.5" @@ -10342,23 +12452,23 @@ __metadata: "@babel/runtime": "npm:^7.25.4" "@babel/traverse": "npm:^7.25.4" "@babel/types": "npm:^7.25.4" - "@tamagui/build": "npm:1.121.12" - "@tamagui/cli-color": "npm:1.121.12" - "@tamagui/config-default": "npm:1.121.12" - "@tamagui/core": "npm:1.121.12" - "@tamagui/fake-react-native": "npm:1.121.12" - "@tamagui/generate-themes": "npm:1.121.12" - "@tamagui/helpers": "npm:1.121.12" - "@tamagui/helpers-node": "npm:1.121.12" - "@tamagui/proxy-worm": "npm:1.121.12" - "@tamagui/react-native-web-internals": "npm:1.121.12" - "@tamagui/react-native-web-lite": "npm:1.121.12" - "@tamagui/shorthands": "npm:1.121.12" - "@tamagui/types": "npm:1.121.12" + "@tamagui/build": "npm:1.126.3" + "@tamagui/cli-color": "npm:1.126.3" + "@tamagui/config-default": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/fake-react-native": "npm:1.126.3" + "@tamagui/generate-themes": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/helpers-node": "npm:1.126.3" + "@tamagui/proxy-worm": "npm:1.126.3" + "@tamagui/react-native-web-internals": "npm:1.126.3" + "@tamagui/react-native-web-lite": "npm:1.126.3" + "@tamagui/shorthands": "npm:1.126.3" + "@tamagui/types": "npm:1.126.3" babel-literal-to-ast: "npm:^2.1.0" browserslist: "npm:^4.22.2" check-dependency-version-consistency: "npm:^4.1.0" - esbuild: "npm:^0.24.2" + esbuild: "npm:^0.25.0" esbuild-register: "npm:^3.6.0" fast-glob: "npm:^3.2.11" find-cache-dir: "npm:^3.3.2" @@ -10367,435 +12477,359 @@ __metadata: invariant: "npm:^2.2.4" js-yaml: "npm:^4.1.0" lodash: "npm:^4.17.21" - react-native-web: "npm:^0.19.0" + react-native-web: "npm:^0.20.0" peerDependencies: react: "*" - checksum: 10/ee875cefbeea0da2ce418cdc835b7b9511bb7ce98c6639b8849217f63006c98fb20fe5bf0288a90e5b362b0811ce183c601bb97a16e7f1cde07fff68b3806689 + checksum: 10/737a5a37c5d84501575adacb97ad7ecf74882416ebb67777d09b76737f52b59626ec004675a38d98dd4f9b95dc484b793026e26dd321c7d1284b1054df9e6990 languageName: node linkType: hard -"@tamagui/switch-headless@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/switch-headless@npm:1.119.0" +"@tamagui/switch-headless@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/switch-headless@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/label": "npm:1.119.0" - "@tamagui/use-previous": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/label": "npm:1.126.3" + "@tamagui/use-previous": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/4b693e483abbe7f497b7f04fa3c9b8bdc4d1cf002bce34c54c11e9d4d94356c03a3067ff7b828d30fdf2680d3957e4c78a82bb7e9b9649e414f8fe2bfbeadebb + checksum: 10/ad39901ef13d294a3910f9745125a3339f3ffb418dbd93aafb56139bb5ad7b1b616a4b9b6751bab533fded89b19ee302296a0aababa4433c50c78daaca755ea2 languageName: node linkType: hard -"@tamagui/switch@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/switch@npm:1.119.0" +"@tamagui/switch@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/switch@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/focusable": "npm:1.119.0" - "@tamagui/get-token": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/label": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/switch-headless": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" - "@tamagui/use-previous": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/focusable": "npm:1.126.3" + "@tamagui/get-token": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/label": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/switch-headless": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/use-previous": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/d42fe87408ba473f79e7ab97af6e994b553dc008e3435f625a84fb939038ac0644fbce484ecbcf2f41c5a881dae565460be622cee3b33531cb557395d6bf5ce9 + checksum: 10/98b01f070f7ccbd260109abe770374278435aa51fb411eb5e0facb1f63b02252dcb806f1f88cf308194ff54d204f31b62c1fc73a38cb5e2c21209c5b93f85629 languageName: node linkType: hard -"@tamagui/tabs@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/tabs@npm:1.119.0" +"@tamagui/tabs@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/tabs@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/get-button-sized": "npm:1.119.0" - "@tamagui/group": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/roving-focus": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" - "@tamagui/use-direction": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/get-button-sized": "npm:1.126.3" + "@tamagui/group": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/roving-focus": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/use-direction": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/d33778c3828d2de279cad0bc3e0898c83f62123abb526f06efffa837d6928759071971c86d1f876c813fd908e4cce3477d3adc2681396a2680f48819ecc8666b + checksum: 10/3390508f3e501d3d1575146d9b6f1426b06e57995cfd71f26e35359e714c29cc33e70120c6520f293952d447c0b17c3bb2a9df7e811a5994da61058f12856376 languageName: node linkType: hard -"@tamagui/text@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/text@npm:1.119.0" +"@tamagui/text@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/text@npm:1.126.3" dependencies: - "@tamagui/get-font-sized": "npm:1.119.0" - "@tamagui/helpers-tamagui": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/get-font-sized": "npm:1.126.3" + "@tamagui/helpers-tamagui": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/48387fa0129af0393f08f9a1497e206495a4d67ef29f2ef296823f3df5a7bd29bd92d2074850b972c1b2e36fa958df21739e3b0b0587c7bd258ecac50ac9c5c9 - languageName: node - linkType: hard - -"@tamagui/theme-builder@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/theme-builder@npm:1.119.0" - dependencies: - "@tamagui/create-theme": "npm:1.119.0" - color2k: "npm:^2.0.2" - checksum: 10/b222edf0b1f496f19f35fd3789ed4a7feb0eee9c706092cc4d32168636c8a6fd098e2d5f7e510fc7e309d4b52cbc18b50696f4c879d8073bea8705963a5c1de9 + checksum: 10/a1897ec8e59b631553cd47cb722e445dc7129f1ca81889d5b754e221133ec96c15510f46ef12c21ebe5feb2e71a2094476514e46e3f156e8c914126d0e3ef2fc languageName: node linkType: hard -"@tamagui/theme-builder@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/theme-builder@npm:1.121.12" +"@tamagui/theme-builder@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/theme-builder@npm:1.126.3" dependencies: - "@tamagui/create-theme": "npm:1.121.12" + "@tamagui/create-theme": "npm:1.126.3" color2k: "npm:^2.0.2" - checksum: 10/439dc58e23d7cfb3bbf3bd6ff42de65eb82b2af2383f6b5da1fc8766b48833555addb7f206cc3d2f34c3e649726532365a66a56596d3160f46d2c168a39935e8 + checksum: 10/c6b949171ac4695608610d9b7d4dc4e13fea6d4cb4185dda46abfa212a948197d12a21e5ee8f05cfc5f2891b1f886a38bc6261a06899ae5be357099cb435f4d7 languageName: node linkType: hard -"@tamagui/theme@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/theme@npm:1.119.0" +"@tamagui/theme@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/theme@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/eeecdb64c569103de1de30a6132e4a5c67dbcaa5a1263ac9073198f9ad06c92d297b7fa108f89b612b660dacc7e1f00b565a84ea0623cc387277bd84ea475e98 + checksum: 10/d43c81f90501bc2350fa9c73caa8e50ae5c106330be4c05b2be89fccad50fa75723abf4187353d0e2192b84c0c505bebbfb8a79ca76b39dec513830a55f46b18 languageName: node linkType: hard -"@tamagui/themes@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/themes@npm:1.119.0" +"@tamagui/themes@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/themes@npm:1.126.3" dependencies: - "@tamagui/colors": "npm:1.119.0" - "@tamagui/create-theme": "npm:1.119.0" - "@tamagui/theme-builder": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" - checksum: 10/baebb1237d7e788a386942becb68f065ae0ed59e25fe1395bfa1d8c1effad572256f06594440aedae799aba3ff8ae1f7167c8cf4e5eda2bd6ee904f914cc9bdb - languageName: node - linkType: hard - -"@tamagui/timer@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/timer@npm:1.119.0" - checksum: 10/025cf3fede642132e24ea762a826ee697306e86ca15f3b2430fb72f11ddc0b2e126aece8a2208d818b75e7a8a710e1c51219d29231fb33962f73d12088cb07e3 + "@tamagui/colors": "npm:1.126.3" + "@tamagui/create-theme": "npm:1.126.3" + "@tamagui/theme-builder": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" + color2k: "npm:^2.0.2" + checksum: 10/5aaaa9ef4706403cd8de7fd0610dc6acd5320ef1412c7f74b0285a2e46112bc6549f6fa94eb1ae0095f2e40910be0b528f1a64f2cbcad779397f632030f49e22 languageName: node linkType: hard -"@tamagui/timer@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/timer@npm:1.121.12" - checksum: 10/6e4a161d7ce6fcf1f605535a75a7b832c9ee34b85b87e9dcfa2580f474a48f0d7feac621261f149b249a09e20e1fbf84e21faf1beada2350f0cb290e465b3ae3 +"@tamagui/timer@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/timer@npm:1.126.3" + checksum: 10/055bd390ccb4721fdec6f2d1532b1eb3cc3a40dc2a4ac8a83017a032a65ddd7e27acfa5c1a86c41f2a608c1f3bb27d1e991b72235aff2f4a9c79b09979259640 languageName: node linkType: hard -"@tamagui/toast@npm:^1.117.1": - version: 1.119.0 - resolution: "@tamagui/toast@npm:1.119.0" +"@tamagui/toast@npm:^1.126.3": + version: 1.126.3 + resolution: "@tamagui/toast@npm:1.126.3" dependencies: - "@tamagui/animate-presence": "npm:1.119.0" - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/dismissable": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/polyfill-dev": "npm:1.119.0" - "@tamagui/portal": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/start-transition": "npm:1.119.0" - "@tamagui/text": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" - "@tamagui/visually-hidden": "npm:1.119.0" + "@tamagui/animate-presence": "npm:1.126.3" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/dismissable": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/polyfill-dev": "npm:1.126.3" + "@tamagui/portal": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/start-transition": "npm:1.126.3" + "@tamagui/text": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/visually-hidden": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/81690f9b9260a64b4a5834000d1e1d8c334ed316b788b5734ffe1a13b2435b58af8c5cd2c4dd8d562d687cc7a2be3bb93842794b59da955055593c73c6f82eda + checksum: 10/62d4423325c4e9670f1984b4b218f957e8f51c4a8e4a07c6d0e9234c21526a4eebcd4f271807ca879dd2790f31961657f5a29e34d4328cf92764577cbc7c2bfb languageName: node linkType: hard -"@tamagui/toggle-group@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/toggle-group@npm:1.119.0" +"@tamagui/toggle-group@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/toggle-group@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/focusable": "npm:1.119.0" - "@tamagui/font-size": "npm:1.119.0" - "@tamagui/get-token": "npm:1.119.0" - "@tamagui/group": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/helpers-tamagui": "npm:1.119.0" - "@tamagui/roving-focus": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" - "@tamagui/use-direction": "npm:1.119.0" - "@tamagui/web": "npm:1.119.0" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/focusable": "npm:1.126.3" + "@tamagui/font-size": "npm:1.126.3" + "@tamagui/get-token": "npm:1.126.3" + "@tamagui/group": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/helpers-tamagui": "npm:1.126.3" + "@tamagui/roving-focus": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/use-direction": "npm:1.126.3" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/6713b477ee705399de4bfbe40e3e86fd93406621e0e2e0963ec8321c39ae198e29d1acb4b3d27272b880b1b6a80abc3d82f570c70856ba28fba0b7643773db7d + checksum: 10/b73023918f2d6031613a9258ed28b77919ba4005fd1f9e0c509cb6086f47c5a65f6fec89bc3bc3b5756bc19722e0a28d4db4e9c88cce7af4ec21843f2ef02939 languageName: node linkType: hard -"@tamagui/tooltip@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/tooltip@npm:1.119.0" +"@tamagui/tooltip@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/tooltip@npm:1.126.3" dependencies: - "@floating-ui/react": "npm:^0.26.24" - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/floating": "npm:1.119.0" - "@tamagui/get-token": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/polyfill-dev": "npm:1.119.0" - "@tamagui/popover": "npm:1.119.0" - "@tamagui/popper": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/text": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" + "@floating-ui/react": "npm:^0.27.4" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/floating": "npm:1.126.3" + "@tamagui/get-token": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/polyfill-dev": "npm:1.126.3" + "@tamagui/popover": "npm:1.126.3" + "@tamagui/popper": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/text": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/add94016046ea72b618b283ead2aab1d9629a55c17ccc59d069def206b92bb7a29c3a98cd42cfbfa477f5dc40ad588fec7c561bdb84712fb9983f41625e528b3 - languageName: node - linkType: hard - -"@tamagui/types@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/types@npm:1.119.0" - checksum: 10/5b30e5adc046b042f294530158e5605b0294d02770bf2be86b1292da66e011e575f57057cb500a30474c33cfceb2bc85a136b7c31c774582798b542e51da0343 + checksum: 10/431e7c2df94545d17dd32b1607d10f9093e904e76c905364f3fa39a57ef507981717e3f0fa83a5d82492895ded704508f4957d5ac34307b405a4444e62850497 languageName: node linkType: hard -"@tamagui/types@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/types@npm:1.121.12" - checksum: 10/1e730ee3a2b69656e50d86223ae63c508e1f9ab50b3dbb8a2c40d4a675f8ef9f9eae485457e51806330afa8ab428e469fb3ff5626a9a96c3bfd72f38884cca46 +"@tamagui/types@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/types@npm:1.126.3" + checksum: 10/c9f15f4c9535450edde27bc09b2b47aac5d4b936ae83935506b41e439b650fd73f3bf69b54e92720133fc2bfe7a05ddc1e2f19ed065f01191e4698a4d09859b1 languageName: node linkType: hard -"@tamagui/use-callback-ref@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/use-callback-ref@npm:1.119.0" - checksum: 10/0688bc80254c94d75c4cf1a5b7f70a1af102cccb25dbe1a41439b0520d1a88e120e2d3f3488947252b0f7561f6c2c7a0e22c08804a67fab627f3b87c2394e8aa +"@tamagui/use-callback-ref@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/use-callback-ref@npm:1.126.3" + checksum: 10/dbfed4fcc073f116c7ae942475273d909d0cd12dd3c3d839268e1fb63e957c59e70e5f3aee9e4c79d0b351a2123f52e2e2816cedcb3b2d56ae3b5cc0f231975a languageName: node linkType: hard -"@tamagui/use-constant@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/use-constant@npm:1.119.0" +"@tamagui/use-constant@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/use-constant@npm:1.126.3" peerDependencies: react: "*" - checksum: 10/7d5d740c7ee293c1cef28b7c2da780201e0ed253336ab6f65c634748f495a5f93529770bfb07a8d8fb61654902abb897578447669338ca790bd19b29416bae58 + checksum: 10/f6ab3f963810aa9248f1a1ac77c7f4bd1c814e8ee232fe03efb2100e79ad903d80776b582fb821670bc74bf07b00be982ba63b6092c56b03712d41dad0d0b5c4 languageName: node linkType: hard -"@tamagui/use-controllable-state@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/use-controllable-state@npm:1.119.0" +"@tamagui/use-controllable-state@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/use-controllable-state@npm:1.126.3" dependencies: - "@tamagui/start-transition": "npm:1.119.0" - "@tamagui/use-event": "npm:1.119.0" + "@tamagui/start-transition": "npm:1.126.3" + "@tamagui/use-event": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/c87da9b0ee1cc21a89c60b492e12fffd0dc68c2c09852e6e704a6ecf0e749cbff833e412021e520e473bff24795a8f800d8496fda4005bf78b1fe2a5a49fb3d0 + checksum: 10/bf2567c6721b9326350c470deef42d79ac3c8295cdcb1a84df9097176b801c328c5a913dbf13f9c0b31236bb151881fdb4db2a958cc3a0f55a3c48490bf72c43 languageName: node linkType: hard -"@tamagui/use-debounce@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/use-debounce@npm:1.119.0" +"@tamagui/use-debounce@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/use-debounce@npm:1.126.3" peerDependencies: react: "*" - checksum: 10/2cd536023459f0627dde323ffe61ca73d0f024e0b25012e8a596d3d8787840c91975349adad0866f3b5884b85dfed8cfed1803a11794a27c32426638f5f73a0d + checksum: 10/8690126a910af9579cb0599fba0dc1892b2852ca95f31c1bf4ca7fd3015c6823f71ed0ca83d6f8a268ebfd29dc9d243a5f827a5c75e0ba4225b29d591b19c0dc languageName: node linkType: hard -"@tamagui/use-did-finish-ssr@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/use-did-finish-ssr@npm:1.119.0" +"@tamagui/use-did-finish-ssr@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/use-did-finish-ssr@npm:1.126.3" peerDependencies: react: "*" - checksum: 10/66cb9a231709eb706b8dc298326198fb3f59369ca148cf11c0b02a3c02170abf476b60257bb2531a4905ccc254f9ab874591cb5980d92e688a86f0ffcabe9ae3 + checksum: 10/ca3508f34acbbda2f4dbd873faca9a03ec3f51e3da711cd1646dee623e18ff68ef878e0c0d2afe078a5c63f19bc941b5a4f7ed97bac27916908e1013dddba021 languageName: node linkType: hard -"@tamagui/use-did-finish-ssr@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/use-did-finish-ssr@npm:1.121.12" +"@tamagui/use-direction@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/use-direction@npm:1.126.3" peerDependencies: react: "*" - checksum: 10/1d9ecc6bc34f7931929d40b24bbdb3ffeed3d346927ce774d39f9dd5134f2bbe5436d0b28888be9036f315567a01078e92537c9831f578921b86ede7fae0919f + checksum: 10/a347300cbf4412351a6ab24a8977a61b81b56b052cce5fd0bb9c269f917c321fd54d80a28964e5491aa705822e9379bb2276ebee488d805b6060396f633c4085 languageName: node linkType: hard -"@tamagui/use-direction@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/use-direction@npm:1.119.0" - peerDependencies: - react: "*" - checksum: 10/35925ce078a71e4b33c8787a1ce74a1b018617f18f175ede3db341e1fa055d3a15c00167717159fe0f66c7ee1c887b74ea0678a6762bc7b04c0e4c51ce6e56aa - languageName: node - linkType: hard - -"@tamagui/use-escape-keydown@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/use-escape-keydown@npm:1.119.0" - dependencies: - "@tamagui/use-callback-ref": "npm:1.119.0" - checksum: 10/a0426be7c4bcf7687853cd8ed04a66e24eadc1c9a57277393c77db8d0713b7e71fcb1b28a568ce20c5afab76becb28d4ea2ef455b65c3a9f4bd31e2aae80c2d0 - languageName: node - linkType: hard - -"@tamagui/use-event@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/use-event@npm:1.119.0" +"@tamagui/use-escape-keydown@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/use-escape-keydown@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.119.0" - peerDependencies: - react: "*" - checksum: 10/91cb8faf6c537f14aedebcab8044877b4500cd8f7f101b2cffaabe9b9c43a58b25ed04f854efbef6c91b76b68d0195d1514f7b296a3eade7bc613c33a152a4e2 + "@tamagui/use-callback-ref": "npm:1.126.3" + checksum: 10/de271c2089d392a08ea167aa4ca00000a82f09e5669ca0a65827d2ef3d3d02cafdcf0ea7994a3774e053bb0cdda2c006607ca1250496308cd41b6867ab4d1a54 languageName: node linkType: hard -"@tamagui/use-event@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/use-event@npm:1.121.12" +"@tamagui/use-event@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/use-event@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.121.12" - peerDependencies: - react: "*" - checksum: 10/47f25974dc58ec926510b8001b18db9549c918e564503e917ef960a7a609582a4e57a26d9db94d18c7776c914d0ee6d70272395fc11f691c14ef38cf8c87e5cf - languageName: node - linkType: hard - -"@tamagui/use-force-update@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/use-force-update@npm:1.119.0" - peerDependencies: - react: "*" - checksum: 10/25ed55c391f1a0842da79e49fd2d2faf34d4c29ad66bb07074b12ba4d2c1be988d4571fd2f9ee671f8b591f0d80220e5fea1660f3269a081d912e1305cfb64ce - languageName: node - linkType: hard - -"@tamagui/use-force-update@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/use-force-update@npm:1.121.12" + "@tamagui/constants": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/1d6c590f20b6a54c58e2bf6f656940b57ebb43fb0739136a0feeb0641315e3158bdbf8e8a284ffeae36fe8dd33bf3f098120f0a204d5e6df5b7dd764bfe15dde + checksum: 10/50b5bf26531a01c65e859aae2ac969a9a2d55652e1afc798e7c3e6991617afbce5cc785c0852e1f8ceb6a5f9b19b6cf8a1522ff4f508d469c5c03bab6df35fc8 languageName: node linkType: hard -"@tamagui/use-keyboard-visible@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/use-keyboard-visible@npm:1.119.0" +"@tamagui/use-force-update@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/use-force-update@npm:1.126.3" peerDependencies: react: "*" - checksum: 10/3cd6d6675ee013003c1e66176ba5cbbfe7c79779c6e49645dca5a073b4b3f0115272ee41a5f4b2fafa99e80ce70c3676d0061f5243d27cd3864ec7095e33c1a8 + checksum: 10/6c9174c955ff1f0c6e5854e61245f7d636b1982f0a981bb8167366320245be8f25fcbd4f028666769b96a67064faf67bfc29bbde3091425e080aedb2c181af69 languageName: node linkType: hard -"@tamagui/use-presence@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/use-presence@npm:1.119.0" - dependencies: - "@tamagui/web": "npm:1.119.0" +"@tamagui/use-keyboard-visible@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/use-keyboard-visible@npm:1.126.3" peerDependencies: react: "*" - checksum: 10/82c2e1c8ac4f7eca47fcbe79b02ec62965b88f3604f187503e4ba2a882e5ec8b7d0a18a919a5f70610ef59e8d8c5ca5e7ee4f2da746ed56700eeaf3c5c18e21a + checksum: 10/93fe05e9c56dbf02d72f5fe5866473ba41d9324c8748ba874722f661a2957a1c91245474e9560dde6668d8d1b5a73fdd5dfafd8dce064882fdbd01ebd8bde069 languageName: node linkType: hard -"@tamagui/use-presence@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/use-presence@npm:1.121.12" +"@tamagui/use-presence@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/use-presence@npm:1.126.3" dependencies: - "@tamagui/web": "npm:1.121.12" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/b9617d6ee43c26ef0d32c6080bfe95c4ce5ccc13f7168d9d9dbc0adf361be46f9a221b6c0042c805d5b2b123b085b68d34857f85c0768cc623ba1a1f71994159 + checksum: 10/267857ecaadc36ec24a872b96e152e8d3cc07286f11c53288464fd432c772364518895057dcce37ee860802fe4221289c62ad3db20e7b0e985cd75544a07d89c languageName: node linkType: hard -"@tamagui/use-previous@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/use-previous@npm:1.119.0" - checksum: 10/7ada11baf20e189a8a1a169cbd994c899a67399ea7bac53badc6662d7a3f591b9d298575f41c82f98a76113ee610805624e387fc6afd1ae0b4d96744ee4bcd90 +"@tamagui/use-previous@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/use-previous@npm:1.126.3" + checksum: 10/6d10a202435de5c7018f735e5ea250d77913d1c0619741e65972b1ccae5232faf8ff6108c277980aab8779d7dbd51b6668a6054ab94d83b28c5f845bc74f5129 languageName: node linkType: hard -"@tamagui/use-window-dimensions@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/use-window-dimensions@npm:1.119.0" +"@tamagui/use-window-dimensions@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/use-window-dimensions@npm:1.126.3" dependencies: - "@tamagui/constants": "npm:1.119.0" + "@tamagui/constants": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/688095a773ae7effb1264edd7e3474115afc0f696aa118c7e0863638c380a14839be11240bf6657cac0b3d1eb8920f86c07ff52365235dcb79ed4afb547cd147 + checksum: 10/71e7af10bb170efb9064aec2bf59e0b357ddfe73189fb16fc31a47096dff3c7bda24bc71b1c7a99021005f8d48ec3080707bd36627bf9f6e1d6c320182ad49c3 languageName: node linkType: hard -"@tamagui/visually-hidden@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/visually-hidden@npm:1.119.0" +"@tamagui/visually-hidden@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/visually-hidden@npm:1.126.3" dependencies: - "@tamagui/web": "npm:1.119.0" + "@tamagui/web": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/8ec517c55a23dafeefc43ecfa960a5d66bf6b55055cae49004decee972ef14c9c4ff6a4c028a3aa05a3d01e68038728beedc3dd158b0f6e342b496c2e59d2630 + checksum: 10/a50e6e488057b5c0e963c0de7ef94e6a736f6328638e0ba99bf4f7db6b80978a0bfd938259fb4cfa885944d5235e2a246290a721ef2fd5afd7f66c6ca966072e languageName: node linkType: hard -"@tamagui/web@npm:1.119.0": - version: 1.119.0 - resolution: "@tamagui/web@npm:1.119.0" +"@tamagui/web@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/web@npm:1.126.3" dependencies: - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/helpers": "npm:1.119.0" - "@tamagui/normalize-css-color": "npm:1.119.0" - "@tamagui/timer": "npm:1.119.0" - "@tamagui/types": "npm:1.119.0" - "@tamagui/use-did-finish-ssr": "npm:1.119.0" - "@tamagui/use-event": "npm:1.119.0" - "@tamagui/use-force-update": "npm:1.119.0" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/helpers": "npm:1.126.3" + "@tamagui/normalize-css-color": "npm:1.126.3" + "@tamagui/timer": "npm:1.126.3" + "@tamagui/types": "npm:1.126.3" + "@tamagui/use-did-finish-ssr": "npm:1.126.3" + "@tamagui/use-event": "npm:1.126.3" + "@tamagui/use-force-update": "npm:1.126.3" peerDependencies: react: "*" react-dom: "*" - checksum: 10/715e7d88dfa0449054ed390628494cd85c828ad8317914f265ec9b5c47230f5c88fcdc393c4d738d9f116e422c6fa086c30bedb2a95f883c18d1684f7a812c4e + checksum: 10/45f4dd34a36e5a038d99cfcb147c02436aac4c588c975ac9722a92224860912fc2850d306b2645756b0f8c88788fa6e5c6f5e6cfe22e6fdf2b5612a17cf8f898 languageName: node linkType: hard -"@tamagui/web@npm:1.121.12": - version: 1.121.12 - resolution: "@tamagui/web@npm:1.121.12" - dependencies: - "@tamagui/compose-refs": "npm:1.121.12" - "@tamagui/constants": "npm:1.121.12" - "@tamagui/helpers": "npm:1.121.12" - "@tamagui/normalize-css-color": "npm:1.121.12" - "@tamagui/timer": "npm:1.121.12" - "@tamagui/types": "npm:1.121.12" - "@tamagui/use-did-finish-ssr": "npm:1.121.12" - "@tamagui/use-event": "npm:1.121.12" - "@tamagui/use-force-update": "npm:1.121.12" - peerDependencies: - react: "*" - react-dom: "*" - checksum: 10/105f059cafa3fcd9057aaaaf2d4667bf9f4ac2a36cddd2ee315e1f3f4a98636de552f95849baeca99853acb92ba54ee162cc8edfee697803d5641f71c87f6d1d +"@tamagui/z-index-stack@npm:1.126.3": + version: 1.126.3 + resolution: "@tamagui/z-index-stack@npm:1.126.3" + checksum: 10/1d1aa6b46316cfd9f332d27e21b5f5fabcbe516bcecc653a77b3a9035d5efc0c80f1b2da8759ba2fe5c750962bdf28b7e9153bfc2293f43124637dd66b321c42 languageName: node linkType: hard @@ -10857,22 +12891,23 @@ __metadata: languageName: node linkType: hard -"@testing-library/react-native@npm:^12.7.2": - version: 12.9.0 - resolution: "@testing-library/react-native@npm:12.9.0" +"@testing-library/react-native@npm:^13.1.0, @testing-library/react-native@npm:^13.2.0": + version: 13.2.0 + resolution: "@testing-library/react-native@npm:13.2.0" dependencies: + chalk: "npm:^4.1.2" jest-matcher-utils: "npm:^29.7.0" pretty-format: "npm:^29.7.0" redent: "npm:^3.0.0" peerDependencies: - jest: ">=28.0.0" - react: ">=16.8.0" - react-native: ">=0.59" - react-test-renderer: ">=16.8.0" + jest: ">=29.0.0" + react: ">=18.2.0" + react-native: ">=0.71" + react-test-renderer: ">=18.2.0" peerDependenciesMeta: jest: optional: true - checksum: 10/dcee1d836e76198a2c397fbcb7db24a40e2c45b2dcbca266a4a5d8a802a859a1e8c50755336a2d70f9eec478de964951673b78acb2e03c007b2bee5b8d8766d1 + checksum: 10/b5c4bc61ed61ceb835224e223a528bb1a43c82776f964511f53559c8bd606feb11b85654abfe7ef5bdebd25fa5855682ebcf50afdab9aaffce45d46decdcf29e languageName: node linkType: hard @@ -10959,6 +12994,13 @@ __metadata: languageName: node linkType: hard +"@tsconfig/node18@npm:^18.2.2": + version: 18.2.4 + resolution: "@tsconfig/node18@npm:18.2.4" + checksum: 10/80623cb9c129c78d51fe6c4a256ba986f12f02ff02dc2a1e5b33dd13a7983f767b6792cfcd51b3dd1c8256ea105f1fea31f64a2070564e37787ab3d9a1a1e7e3 + languageName: node + linkType: hard + "@typechain/ethers-v6@npm:^0.5.1": version: 0.5.1 resolution: "@typechain/ethers-v6@npm:0.5.1" @@ -11046,6 +13088,13 @@ __metadata: languageName: node linkType: hard +"@types/css-font-loading-module@npm:0.0.7": + version: 0.0.7 + resolution: "@types/css-font-loading-module@npm:0.0.7" + checksum: 10/f70b9098ee3b2e006f5f6d5cecc627dcc7b898f266bfc594e73a8720636f1a3bc5f8c38fa0e8f7e5b7878038b46fd70da0c797c3288e072af984097210f4c056 + languageName: node + linkType: hard + "@types/debug@npm:^4.0.0": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" @@ -11187,7 +13236,7 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^29.5.12, @types/jest@npm:^29.5.14, @types/jest@npm:^29.5.4": +"@types/jest@npm:^29.2.1, @types/jest@npm:^29.5.14": version: 29.5.14 resolution: "@types/jest@npm:29.5.14" dependencies: @@ -11222,7 +13271,7 @@ __metadata: languageName: node linkType: hard -"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9": +"@types/json-schema@npm:*, @types/json-schema@npm:7.0.15, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" checksum: 10/1a3c3e06236e4c4aab89499c428d585527ce50c24fe8259e8b3926d3df4cfbbbcf306cfc73ddfb66cbafc973116efd15967020b0f738f63e09e64c7d260519e7 @@ -11280,28 +13329,21 @@ __metadata: languageName: node linkType: hard -"@types/node-forge@npm:^1.3.0": - version: 1.3.11 - resolution: "@types/node-forge@npm:1.3.11" +"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:^22.0.0": + version: 22.10.1 + resolution: "@types/node@npm:22.10.1" dependencies: - "@types/node": "npm:*" - checksum: 10/670c9b377c48189186ec415e3c8ed371f141ecc1a79ab71b213b20816adeffecba44dae4f8406cc0d09e6349a4db14eb8c5893f643d8e00fa19fc035cf49dee0 + undici-types: "npm:~6.20.0" + checksum: 10/c802a526da2f3fa3ccefd00a71244e7cb825329951719e79e8fec62b1dbc2855388c830489770611584665ce10be23c05ed585982038b24924e1ba2c2cce03fd languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=12.12.47, @types/node@npm:>=13.7.0, @types/node@npm:^22.0.0, @types/node@npm:^22.9.1": - version: 22.10.2 - resolution: "@types/node@npm:22.10.2" +"@types/node@npm:22.13.1, @types/node@npm:^22.13.1": + version: 22.13.1 + resolution: "@types/node@npm:22.13.1" dependencies: undici-types: "npm:~6.20.0" - checksum: 10/451adfefed4add58b069407173e616220fd4aaa3307cdde1bb701aa053b65b54ced8483db2f870dcedec7a58cb3b06101fbc19d85852716672ec1fd3660947fa - languageName: node - linkType: hard - -"@types/node@npm:18.11.18": - version: 18.11.18 - resolution: "@types/node@npm:18.11.18" - checksum: 10/da05cf3a0036ef05cd695ac4cb265948593acbe723ba818f0ca0ce466b13ba99e1aac3a363086d6b8c7ea8f30c9233478e0293ac878a6f4b1d5515b10c392257 + checksum: 10/d8ba7068b0445643c0fa6e4917cdb7a90e8756a9daff8c8a332689cd5b2eaa01e4cd07de42e3cd7e6a6f465eeda803d5a1363d00b5ab3f6cea7950350a159497 languageName: node linkType: hard @@ -11314,6 +13356,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=12.12.47": + version: 22.10.2 + resolution: "@types/node@npm:22.10.2" + dependencies: + undici-types: "npm:~6.20.0" + checksum: 10/451adfefed4add58b069407173e616220fd4aaa3307cdde1bb701aa053b65b54ced8483db2f870dcedec7a58cb3b06101fbc19d85852716672ec1fd3660947fa + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -11371,7 +13422,7 @@ __metadata: languageName: node linkType: hard -"@types/prop-types@npm:*, @types/prop-types@npm:^15.7.14": +"@types/prop-types@npm:^15.7.14": version: 15.7.14 resolution: "@types/prop-types@npm:15.7.14" checksum: 10/d0c5407b9ccc3dd5fae0ccf9b1007e7622ba5e6f1c18399b4f24dff33619d469da4b9fa918a374f19dc0d9fe6a013362aab0b844b606cfc10676efba3f5f736d @@ -11396,13 +13447,6 @@ __metadata: languageName: node linkType: hard -"@types/react-gtm-module@npm:^2.0.3": - version: 2.0.4 - resolution: "@types/react-gtm-module@npm:2.0.4" - checksum: 10/635699a2d85f958ba92126cda851f9a1b46df75d3846bdc89ce2345524f73d889a266110d00492f6c23e9e42e70ec2246449a93c9cc2c6367bd643a7d2f0e88a - languageName: node - linkType: hard - "@types/react-transition-group@npm:^4.4.11, @types/react-transition-group@npm:^4.4.12": version: 4.4.12 resolution: "@types/react-transition-group@npm:4.4.12" @@ -11421,13 +13465,12 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:~18.3.12": - version: 18.3.18 - resolution: "@types/react@npm:18.3.18" +"@types/react@npm:~19.0.10": + version: 19.0.14 + resolution: "@types/react@npm:19.0.14" dependencies: - "@types/prop-types": "npm:*" csstype: "npm:^3.0.2" - checksum: 10/7fdd8b853e0d291d4138133f93f8d5c333da918e5804afcea61a923aab4bdfc9bb15eb21a5640959b452972b8715ddf10ffb12b3bd071898b9e37738636463f2 + checksum: 10/caf4572aae4f7efd049138bc4917f465a8e00abb362fcf6e6f82c9ed88fc42555109f0a3be7a738ed5efbdd51b0f0a4256f7d5141545f29fb7dc31b8e872aa4b languageName: node linkType: hard @@ -11556,26 +13599,24 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.17.0": - version: 8.17.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.17.0" +"@typescript-eslint/eslint-plugin@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/eslint-plugin@npm:8.31.1" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.17.0" - "@typescript-eslint/type-utils": "npm:8.17.0" - "@typescript-eslint/utils": "npm:8.17.0" - "@typescript-eslint/visitor-keys": "npm:8.17.0" + "@typescript-eslint/scope-manager": "npm:8.31.1" + "@typescript-eslint/type-utils": "npm:8.31.1" + "@typescript-eslint/utils": "npm:8.31.1" + "@typescript-eslint/visitor-keys": "npm:8.31.1" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^1.3.0" + ts-api-utils: "npm:^2.0.1" peerDependencies: "@typescript-eslint/parser": ^8.0.0 || ^8.0.0-alpha.0 eslint: ^8.57.0 || ^9.0.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/753630802632dc1bb15d4b86afe60b5280d080cc6715648f6b717093a81d31f8aa621f823d0aa5632d1cf05f3c8f4ea356eff49efd11d9f7c154cb16d2ac2d79 + typescript: ">=4.8.4 <5.9.0" + checksum: 10/be72838653e1f31da11b3f6515fa3b750f0c5c130bae315079920ee6b78e9c4d131790e2473ff7930e8d50dee45d623c9c90478fd78befb976c1736d369078e9 languageName: node linkType: hard @@ -11623,21 +13664,40 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.17.0": - version: 8.17.0 - resolution: "@typescript-eslint/parser@npm:8.17.0" +"@typescript-eslint/eslint-plugin@npm:^8.29.1": + version: 8.33.1 + resolution: "@typescript-eslint/eslint-plugin@npm:8.33.1" + dependencies: + "@eslint-community/regexpp": "npm:^4.10.0" + "@typescript-eslint/scope-manager": "npm:8.33.1" + "@typescript-eslint/type-utils": "npm:8.33.1" + "@typescript-eslint/utils": "npm:8.33.1" + "@typescript-eslint/visitor-keys": "npm:8.33.1" + graphemer: "npm:^1.4.0" + ignore: "npm:^7.0.0" + natural-compare: "npm:^1.4.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + "@typescript-eslint/parser": ^8.33.1 + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.9.0" + checksum: 10/569a950381b242ad90c2c4020ea9fdd1cd6e157cdd8cd48cc7ca3f73a82eb61fec702be9fd3655871d983f38a7ed4783ce9a50cc5a2a1fd1db1525d8dc0d7fa6 + languageName: node + linkType: hard + +"@typescript-eslint/parser@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/parser@npm:8.31.1" dependencies: - "@typescript-eslint/scope-manager": "npm:8.17.0" - "@typescript-eslint/types": "npm:8.17.0" - "@typescript-eslint/typescript-estree": "npm:8.17.0" - "@typescript-eslint/visitor-keys": "npm:8.17.0" + "@typescript-eslint/scope-manager": "npm:8.31.1" + "@typescript-eslint/types": "npm:8.31.1" + "@typescript-eslint/typescript-estree": "npm:8.31.1" + "@typescript-eslint/visitor-keys": "npm:8.31.1" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/464981e1424e4a7849ca7253b54092a67d33130d28ecf492efd56d5ce69e640a876b7f84e59f1e368e763125432c34e7de28d78c0eef1bda4e9a9d52de0ccac5 + typescript: ">=4.8.4 <5.9.0" + checksum: 10/506c98b9c265faea4376f02e4d19b4722cd11c40f8e08e4c1b456eeb47bc59b70abaa604e633f1070eaf7c785cf7b39f30346f001d3e5257f8074f07a113eeff languageName: node linkType: hard @@ -11657,6 +13717,35 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/parser@npm:^8.29.1": + version: 8.33.1 + resolution: "@typescript-eslint/parser@npm:8.33.1" + dependencies: + "@typescript-eslint/scope-manager": "npm:8.33.1" + "@typescript-eslint/types": "npm:8.33.1" + "@typescript-eslint/typescript-estree": "npm:8.33.1" + "@typescript-eslint/visitor-keys": "npm:8.33.1" + debug: "npm:^4.3.4" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.9.0" + checksum: 10/d55a35a6693ec73de17bc37f1c0644638347d7d807985a9ce062cebcc92d54b78f20c1c443918e105de220ca64418f2a2493e1fb6bd609fc4a7c742a90049f13 + languageName: node + linkType: hard + +"@typescript-eslint/project-service@npm:8.33.1": + version: 8.33.1 + resolution: "@typescript-eslint/project-service@npm:8.33.1" + dependencies: + "@typescript-eslint/tsconfig-utils": "npm:^8.33.1" + "@typescript-eslint/types": "npm:^8.33.1" + debug: "npm:^4.3.4" + peerDependencies: + typescript: ">=4.8.4 <5.9.0" + checksum: 10/c166e21f305eb798ce135477a1ce78e7fac040b597e0acb2e3ecca84380af2aeb4242944dc201bc611aa14f04dd4af0d123d9cf252f7fe39b0a7e158326f705d + languageName: node + linkType: hard + "@typescript-eslint/scope-manager@npm:7.18.0": version: 7.18.0 resolution: "@typescript-eslint/scope-manager@npm:7.18.0" @@ -11667,16 +13756,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.17.0": - version: 8.17.0 - resolution: "@typescript-eslint/scope-manager@npm:8.17.0" - dependencies: - "@typescript-eslint/types": "npm:8.17.0" - "@typescript-eslint/visitor-keys": "npm:8.17.0" - checksum: 10/fa934d9fd88070833c57a3e79c0f933d0b68884c00293a1d571889b882e5c9680ccfdc5c77a7160d5a4b8b46657f93db2468a4726a517fce4d3bc764b66f1995 - languageName: node - linkType: hard - "@typescript-eslint/scope-manager@npm:8.18.1": version: 8.18.1 resolution: "@typescript-eslint/scope-manager@npm:8.18.1" @@ -11687,6 +13766,35 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/scope-manager@npm:8.31.1" + dependencies: + "@typescript-eslint/types": "npm:8.31.1" + "@typescript-eslint/visitor-keys": "npm:8.31.1" + checksum: 10/936aa866ba3564e9e41051e7ff811e08c9b01cbb7420ba2cd8fe8f0f466e5d59a8124b686744cfdecad211cdeefefaaf7d704aecddaa212f267d72e077dc7e44 + languageName: node + linkType: hard + +"@typescript-eslint/scope-manager@npm:8.33.1": + version: 8.33.1 + resolution: "@typescript-eslint/scope-manager@npm:8.33.1" + dependencies: + "@typescript-eslint/types": "npm:8.33.1" + "@typescript-eslint/visitor-keys": "npm:8.33.1" + checksum: 10/a513fa79eef269a4f3d031cd1fddbb15c793e3efd6b6b64a6ae85ea0fea03570feffdc071e2bbe9241680d23d8a4ca6d63a998e34c2a24dbe185cd509ee2f9cb + languageName: node + linkType: hard + +"@typescript-eslint/tsconfig-utils@npm:8.33.1, @typescript-eslint/tsconfig-utils@npm:^8.33.1": + version: 8.33.1 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.33.1" + peerDependencies: + typescript: ">=4.8.4 <5.9.0" + checksum: 10/9e0e348af4d54b7e42a015bf36e08965c8fa389ac7b179c2ccf2bec5fce4b31e951397394dcbaea6fb0ddf782f4f2f2ee71df108173742f756705f82d653cc9d + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:7.18.0": version: 7.18.0 resolution: "@typescript-eslint/type-utils@npm:7.18.0" @@ -11704,23 +13812,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.17.0": - version: 8.17.0 - resolution: "@typescript-eslint/type-utils@npm:8.17.0" - dependencies: - "@typescript-eslint/typescript-estree": "npm:8.17.0" - "@typescript-eslint/utils": "npm:8.17.0" - debug: "npm:^4.3.4" - ts-api-utils: "npm:^1.3.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/a755935a0c16d6b9456effb1cad5e9605936e60799b57394cd143620dd862a90bcac12f720a009e32cafdae315fda343252117db4dc83520e0d21f1c28889792 - languageName: node - linkType: hard - "@typescript-eslint/type-utils@npm:8.18.1": version: 8.18.1 resolution: "@typescript-eslint/type-utils@npm:8.18.1" @@ -11736,6 +13827,36 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/type-utils@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/type-utils@npm:8.31.1" + dependencies: + "@typescript-eslint/typescript-estree": "npm:8.31.1" + "@typescript-eslint/utils": "npm:8.31.1" + debug: "npm:^4.3.4" + ts-api-utils: "npm:^2.0.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.9.0" + checksum: 10/d4c31837c1beb55f7037b0d94c2d73a855c7d921b3b07d68f6d7b476c475765f7707c7375f6190c1863e98e1bc98b8ce444806ab6a02759811eff1c710ab82a6 + languageName: node + linkType: hard + +"@typescript-eslint/type-utils@npm:8.33.1": + version: 8.33.1 + resolution: "@typescript-eslint/type-utils@npm:8.33.1" + dependencies: + "@typescript-eslint/typescript-estree": "npm:8.33.1" + "@typescript-eslint/utils": "npm:8.33.1" + debug: "npm:^4.3.4" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.9.0" + checksum: 10/10a21366e06562a39c08baf2ae1521bfab0c431f446428dcd61c3554effe6307f448acd2293b2dc2aa194abbef52aa7732d663b3b410f1aee9131357b651523a + languageName: node + linkType: hard + "@typescript-eslint/types@npm:7.18.0": version: 7.18.0 resolution: "@typescript-eslint/types@npm:7.18.0" @@ -11743,13 +13864,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:8.17.0": - version: 8.17.0 - resolution: "@typescript-eslint/types@npm:8.17.0" - checksum: 10/46baf69ab30dd814a390590b94ca64c407ac725cb0143590ddcaf72fa43c940cec180539752ce4af26ac7e0ae2f5f921cfd0d07b088ca680f8a28800d4d33a5f - languageName: node - linkType: hard - "@typescript-eslint/types@npm:8.18.1": version: 8.18.1 resolution: "@typescript-eslint/types@npm:8.18.1" @@ -11757,6 +13871,20 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/types@npm:8.31.1" + checksum: 10/18f534beb408398b8ed7f36fb9b9b8ff7e41fe6e5e8a7d0f6b16cc5ad0e55bb5920eeb28c71367e5cc7e0b9675ec961bc6a41bf58b4338c2264c0146322d83f5 + languageName: node + linkType: hard + +"@typescript-eslint/types@npm:8.33.1, @typescript-eslint/types@npm:^8.33.1": + version: 8.33.1 + resolution: "@typescript-eslint/types@npm:8.33.1" + checksum: 10/27bee01122366438ada7919f91f479fec75e23e2f8033e7e74a12dcfeaa2c28a72e83c545bb676be4369c983b3da5eec101cffb431c52abcc2b6664b48255147 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:7.18.0": version: 7.18.0 resolution: "@typescript-eslint/typescript-estree@npm:7.18.0" @@ -11776,40 +13904,59 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.17.0": - version: 8.17.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.17.0" +"@typescript-eslint/typescript-estree@npm:8.18.1": + version: 8.18.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.18.1" dependencies: - "@typescript-eslint/types": "npm:8.17.0" - "@typescript-eslint/visitor-keys": "npm:8.17.0" + "@typescript-eslint/types": "npm:8.18.1" + "@typescript-eslint/visitor-keys": "npm:8.18.1" debug: "npm:^4.3.4" fast-glob: "npm:^3.3.2" is-glob: "npm:^4.0.3" minimatch: "npm:^9.0.4" semver: "npm:^7.6.0" ts-api-utils: "npm:^1.3.0" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/8a1f8be767b82e75d41eedda7fdb5135787ceaab480671b6d9891b5f92ee3a13f19ad6f48d5abf5e4f2afc4dd3317c621c1935505ef098f22b67be2f9d01ab7b + peerDependencies: + typescript: ">=4.8.4 <5.8.0" + checksum: 10/8ecc1b50b9fc32116eee1b3b00f3fb29cf18026c0bbb50ab5f6e01db58ef62b8ac01824f2950f132479be6e1b82466a2bfd1e2cb4525aa8dbce4c27fc2494cfc languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.18.1": - version: 8.18.1 - resolution: "@typescript-eslint/typescript-estree@npm:8.18.1" +"@typescript-eslint/typescript-estree@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.31.1" dependencies: - "@typescript-eslint/types": "npm:8.18.1" - "@typescript-eslint/visitor-keys": "npm:8.18.1" + "@typescript-eslint/types": "npm:8.31.1" + "@typescript-eslint/visitor-keys": "npm:8.31.1" debug: "npm:^4.3.4" fast-glob: "npm:^3.3.2" is-glob: "npm:^4.0.3" minimatch: "npm:^9.0.4" semver: "npm:^7.6.0" - ts-api-utils: "npm:^1.3.0" + ts-api-utils: "npm:^2.0.1" peerDependencies: - typescript: ">=4.8.4 <5.8.0" - checksum: 10/8ecc1b50b9fc32116eee1b3b00f3fb29cf18026c0bbb50ab5f6e01db58ef62b8ac01824f2950f132479be6e1b82466a2bfd1e2cb4525aa8dbce4c27fc2494cfc + typescript: ">=4.8.4 <5.9.0" + checksum: 10/0314ef90f277c76485182721662cf826c31c817acad04e9502b48352eb70bb109a6b7fad02a29d516413b0b1d1fc809c91016ccd5fcd6202ec1b282c6f848ff8 + languageName: node + linkType: hard + +"@typescript-eslint/typescript-estree@npm:8.33.1": + version: 8.33.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.33.1" + dependencies: + "@typescript-eslint/project-service": "npm:8.33.1" + "@typescript-eslint/tsconfig-utils": "npm:8.33.1" + "@typescript-eslint/types": "npm:8.33.1" + "@typescript-eslint/visitor-keys": "npm:8.33.1" + debug: "npm:^4.3.4" + fast-glob: "npm:^3.3.2" + is-glob: "npm:^4.0.3" + minimatch: "npm:^9.0.4" + semver: "npm:^7.6.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + typescript: ">=4.8.4 <5.9.0" + checksum: 10/2876b66acd72f8a1093f8671e05015554fea736d492dbc83084f74faff71d40d66a96452396272252f5e36c1dab2082679d10c7b8aca9f3e2f1b6084afeef807 languageName: node linkType: hard @@ -11827,23 +13974,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.17.0": - version: 8.17.0 - resolution: "@typescript-eslint/utils@npm:8.17.0" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:8.17.0" - "@typescript-eslint/types": "npm:8.17.0" - "@typescript-eslint/typescript-estree": "npm:8.17.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/e82934468bece55ccf633be9f3fe6cae26791fa6488b5af08ea22566f6b32e1296917e46cb1fe39bba7717ebdf0dca49935112760c4439a11af36b3b7925917a - languageName: node - linkType: hard - "@typescript-eslint/utils@npm:8.18.1, @typescript-eslint/utils@npm:^8.8.1": version: 8.18.1 resolution: "@typescript-eslint/utils@npm:8.18.1" @@ -11859,6 +13989,36 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/utils@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/utils@npm:8.31.1" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.4.0" + "@typescript-eslint/scope-manager": "npm:8.31.1" + "@typescript-eslint/types": "npm:8.31.1" + "@typescript-eslint/typescript-estree": "npm:8.31.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.9.0" + checksum: 10/c96253140f5a456b2dc4c9664d4527a88d8f537d9694e1f165665de20aea3936624cf93075f08f5cc2f5080e76021b9b2404a536d1b7980e38e3d1776c7c888c + languageName: node + linkType: hard + +"@typescript-eslint/utils@npm:8.33.1, @typescript-eslint/utils@npm:^8.26.1": + version: 8.33.1 + resolution: "@typescript-eslint/utils@npm:8.33.1" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/scope-manager": "npm:8.33.1" + "@typescript-eslint/types": "npm:8.33.1" + "@typescript-eslint/typescript-estree": "npm:8.33.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.9.0" + checksum: 10/ba76f25b178e2b3280d7aad8becf6a1edd3c57f76f36e6a22d951207282ecc8bfce831ed2f2dd95a70637dcfea2932b3d0a39ac19dd8f9881502738464c6179a + languageName: node + linkType: hard + "@typescript-eslint/visitor-keys@npm:7.18.0": version: 7.18.0 resolution: "@typescript-eslint/visitor-keys@npm:7.18.0" @@ -11869,16 +14029,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.17.0": - version: 8.17.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.17.0" - dependencies: - "@typescript-eslint/types": "npm:8.17.0" - eslint-visitor-keys: "npm:^4.2.0" - checksum: 10/e7a3c3b9430ecefb8e720f735f8a94f87901f055c75dc8eec60052dfdf90cc28dd33f03c11cd8244551dc988bf98d1db9bd09ef8fd3c51236912cab3680b9c6b - languageName: node - linkType: hard - "@typescript-eslint/visitor-keys@npm:8.18.1": version: 8.18.1 resolution: "@typescript-eslint/visitor-keys@npm:8.18.1" @@ -11889,6 +14039,26 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.31.1" + dependencies: + "@typescript-eslint/types": "npm:8.31.1" + eslint-visitor-keys: "npm:^4.2.0" + checksum: 10/14eb7e7ccb5fc29df979427b23729878d10deaa914d4eae1991c75c8ba88f3a2148889f03eeec09d1425a3b14fe37a10c2eafda60874c7020f620fd42d31fd5a + languageName: node + linkType: hard + +"@typescript-eslint/visitor-keys@npm:8.33.1": + version: 8.33.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.33.1" + dependencies: + "@typescript-eslint/types": "npm:8.33.1" + eslint-visitor-keys: "npm:^4.2.0" + checksum: 10/8c1ba7373d8d47d2f2183355db278d4042ecdf268a898c5f4736d84aaa716c6b6faf354247eff8dc1337a7f64168f02e34f3781994d11e9fa8c2b0e35c652c49 + languageName: node + linkType: hard + "@ungap/structured-clone@npm:^1.0.0": version: 1.2.1 resolution: "@ungap/structured-clone@npm:1.2.1" @@ -11980,9 +14150,9 @@ __metadata: languageName: node linkType: hard -"@walletconnect/core@npm:2.17.3, @walletconnect/core@npm:^2.17.2": - version: 2.17.3 - resolution: "@walletconnect/core@npm:2.17.3" +"@walletconnect/core@npm:2.18.1": + version: 2.18.1 + resolution: "@walletconnect/core@npm:2.18.1" dependencies: "@walletconnect/heartbeat": "npm:1.2.2" "@walletconnect/jsonrpc-provider": "npm:1.0.14" @@ -11992,16 +14162,66 @@ __metadata: "@walletconnect/keyvaluestorage": "npm:1.1.1" "@walletconnect/logger": "npm:2.1.2" "@walletconnect/relay-api": "npm:1.0.11" - "@walletconnect/relay-auth": "npm:1.0.4" + "@walletconnect/relay-auth": "npm:1.1.0" "@walletconnect/safe-json": "npm:1.0.2" "@walletconnect/time": "npm:1.0.2" - "@walletconnect/types": "npm:2.17.3" - "@walletconnect/utils": "npm:2.17.3" + "@walletconnect/types": "npm:2.18.1" + "@walletconnect/utils": "npm:2.18.1" "@walletconnect/window-getters": "npm:1.0.1" events: "npm:3.3.0" lodash.isequal: "npm:4.5.0" uint8arrays: "npm:3.1.0" - checksum: 10/bb7f400820fdbb79b1fb4837c99b2e0a4defec751bb2d59d36957a5e6efb54b5ff70f8219558e25a98ecc5b99c5a93e4aad997c45ad6150d4c3bb27e9d98a8a3 + checksum: 10/65d739b12a07e1c8946408f8be997f6fee5c8f2624f8fdc04471b42084a4d78f5f5dba5bd1c5e2fc2950a458d9f77b78537bea9d21d50fa246cc19d989ee066d + languageName: node + linkType: hard + +"@walletconnect/core@npm:2.21.3": + version: 2.21.3 + resolution: "@walletconnect/core@npm:2.21.3" + dependencies: + "@walletconnect/heartbeat": "npm:1.2.2" + "@walletconnect/jsonrpc-provider": "npm:1.0.14" + "@walletconnect/jsonrpc-types": "npm:1.0.4" + "@walletconnect/jsonrpc-utils": "npm:1.0.8" + "@walletconnect/jsonrpc-ws-connection": "npm:1.0.16" + "@walletconnect/keyvaluestorage": "npm:1.1.1" + "@walletconnect/logger": "npm:2.1.2" + "@walletconnect/relay-api": "npm:1.0.11" + "@walletconnect/relay-auth": "npm:1.1.0" + "@walletconnect/safe-json": "npm:1.0.2" + "@walletconnect/time": "npm:1.0.2" + "@walletconnect/types": "npm:2.21.3" + "@walletconnect/utils": "npm:2.21.3" + "@walletconnect/window-getters": "npm:1.0.1" + es-toolkit: "npm:1.39.3" + events: "npm:3.3.0" + uint8arrays: "npm:3.1.1" + checksum: 10/bfc4fe2ba8459ab5b505c392eb33cbfb64f8fcaf1bf7dbbbe91df71e1ab533d06dc8942fcf9022bd98452bdccaa4fae99e97b58af6524bc2cc573554382bf6d4 + languageName: node + linkType: hard + +"@walletconnect/core@npm:^2.20.2": + version: 2.20.2 + resolution: "@walletconnect/core@npm:2.20.2" + dependencies: + "@walletconnect/heartbeat": "npm:1.2.2" + "@walletconnect/jsonrpc-provider": "npm:1.0.14" + "@walletconnect/jsonrpc-types": "npm:1.0.4" + "@walletconnect/jsonrpc-utils": "npm:1.0.8" + "@walletconnect/jsonrpc-ws-connection": "npm:1.0.16" + "@walletconnect/keyvaluestorage": "npm:1.1.1" + "@walletconnect/logger": "npm:2.1.2" + "@walletconnect/relay-api": "npm:1.0.11" + "@walletconnect/relay-auth": "npm:1.1.0" + "@walletconnect/safe-json": "npm:1.0.2" + "@walletconnect/time": "npm:1.0.2" + "@walletconnect/types": "npm:2.20.2" + "@walletconnect/utils": "npm:2.20.2" + "@walletconnect/window-getters": "npm:1.0.1" + es-toolkit: "npm:1.33.0" + events: "npm:3.3.0" + uint8arrays: "npm:3.1.0" + checksum: 10/d835afff418cbd9aeb19ad77b5a47ad18a0e01f1d6304e211f3fc63262d93329e7affee4d75a5923ed146612b29d914db3626e4c6c1d16f38148d8f6d2f478c8 languageName: node linkType: hard @@ -12014,9 +14234,9 @@ __metadata: languageName: node linkType: hard -"@walletconnect/ethereum-provider@npm:^2.13.0": - version: 2.17.3 - resolution: "@walletconnect/ethereum-provider@npm:2.17.3" +"@walletconnect/ethereum-provider@npm:^2.17.3": + version: 2.18.1 + resolution: "@walletconnect/ethereum-provider@npm:2.18.1" dependencies: "@walletconnect/jsonrpc-http-connection": "npm:1.0.8" "@walletconnect/jsonrpc-provider": "npm:1.0.14" @@ -12024,12 +14244,12 @@ __metadata: "@walletconnect/jsonrpc-utils": "npm:1.0.8" "@walletconnect/keyvaluestorage": "npm:1.1.1" "@walletconnect/modal": "npm:2.7.0" - "@walletconnect/sign-client": "npm:2.17.3" - "@walletconnect/types": "npm:2.17.3" - "@walletconnect/universal-provider": "npm:2.17.3" - "@walletconnect/utils": "npm:2.17.3" + "@walletconnect/sign-client": "npm:2.18.1" + "@walletconnect/types": "npm:2.18.1" + "@walletconnect/universal-provider": "npm:2.18.1" + "@walletconnect/utils": "npm:2.18.1" events: "npm:3.3.0" - checksum: 10/0896df1ae6e4f6005900341d516546e4c3c8782377e59176364d86b7eb65b51a3746d395299c68a3287e5f6b91c88aff1d23f0b52f3049b9d3e35bf4c4647db4 + checksum: 10/8a2ba6b0a6d776a78402dcfe230f51c4aca5842b3072e114e4cb598047883fb3c9b5df49e983cea1550ba84abd1b9b825bc4e86fc06356eec471f39a4d27ca62 languageName: node linkType: hard @@ -12190,6 +14410,19 @@ __metadata: languageName: node linkType: hard +"@walletconnect/relay-auth@npm:1.1.0": + version: 1.1.0 + resolution: "@walletconnect/relay-auth@npm:1.1.0" + dependencies: + "@noble/curves": "npm:1.8.0" + "@noble/hashes": "npm:1.7.0" + "@walletconnect/safe-json": "npm:^1.0.1" + "@walletconnect/time": "npm:^1.0.2" + uint8arrays: "npm:^3.0.0" + checksum: 10/0fd6c2e05ced76fbc8e6a84c0a8e73458779662aea55568f51cd9066c337d8a12f2869f0bd717024bbe5955cc605241e68505ebac40406ed2a1bdacba42431b1 + languageName: node + linkType: hard + "@walletconnect/safe-json@npm:1.0.2, @walletconnect/safe-json@npm:^1.0.1, @walletconnect/safe-json@npm:^1.0.2": version: 1.0.2 resolution: "@walletconnect/safe-json@npm:1.0.2" @@ -12199,20 +14432,37 @@ __metadata: languageName: node linkType: hard -"@walletconnect/sign-client@npm:2.17.3": - version: 2.17.3 - resolution: "@walletconnect/sign-client@npm:2.17.3" +"@walletconnect/sign-client@npm:2.18.1": + version: 2.18.1 + resolution: "@walletconnect/sign-client@npm:2.18.1" dependencies: - "@walletconnect/core": "npm:2.17.3" + "@walletconnect/core": "npm:2.18.1" "@walletconnect/events": "npm:1.0.1" "@walletconnect/heartbeat": "npm:1.2.2" "@walletconnect/jsonrpc-utils": "npm:1.0.8" "@walletconnect/logger": "npm:2.1.2" "@walletconnect/time": "npm:1.0.2" - "@walletconnect/types": "npm:2.17.3" - "@walletconnect/utils": "npm:2.17.3" + "@walletconnect/types": "npm:2.18.1" + "@walletconnect/utils": "npm:2.18.1" + events: "npm:3.3.0" + checksum: 10/3fe2b294f827b96bbebc9bf3847c07bfb134f10dc7c65ebb95ea8d52fc84b1ed6fcb33612239d0546e65f1aa000fcb189ae2e3540b5a8c66e01b186064d8011d + languageName: node + linkType: hard + +"@walletconnect/sign-client@npm:2.21.3": + version: 2.21.3 + resolution: "@walletconnect/sign-client@npm:2.21.3" + dependencies: + "@walletconnect/core": "npm:2.21.3" + "@walletconnect/events": "npm:1.0.1" + "@walletconnect/heartbeat": "npm:1.2.2" + "@walletconnect/jsonrpc-utils": "npm:1.0.8" + "@walletconnect/logger": "npm:2.1.2" + "@walletconnect/time": "npm:1.0.2" + "@walletconnect/types": "npm:2.21.3" + "@walletconnect/utils": "npm:2.21.3" events: "npm:3.3.0" - checksum: 10/bcea67582113a4bcd68096a7166870ef74174187b23787ffc2166f26c605d364857d09df92c0f2a58a4de8b2e399c68e8339bc82e96482e76a6f9867b536461c + checksum: 10/eaa46618078d887934a368615a41cd68f9d36723b8ba0be9f4ea718e96ef2982efada9abad8ed5194ac7e8e52ba58bdc0bbb1dd3c899ff69b9af82eb199256f1 languageName: node linkType: hard @@ -12225,41 +14475,161 @@ __metadata: languageName: node linkType: hard -"@walletconnect/types@npm:2.17.3": - version: 2.17.3 - resolution: "@walletconnect/types@npm:2.17.3" +"@walletconnect/types@npm:2.17.3": + version: 2.17.3 + resolution: "@walletconnect/types@npm:2.17.3" + dependencies: + "@walletconnect/events": "npm:1.0.1" + "@walletconnect/heartbeat": "npm:1.2.2" + "@walletconnect/jsonrpc-types": "npm:1.0.4" + "@walletconnect/keyvaluestorage": "npm:1.1.1" + "@walletconnect/logger": "npm:2.1.2" + events: "npm:3.3.0" + checksum: 10/11209cca804db1e4f58490d19f11d02ce97360ec1cd54efcd8074ca93361caf92841551b4ba376f949e62ebc6667e325757c3e3007d19a5c31af0265ce3eeb05 + languageName: node + linkType: hard + +"@walletconnect/types@npm:2.18.1": + version: 2.18.1 + resolution: "@walletconnect/types@npm:2.18.1" + dependencies: + "@walletconnect/events": "npm:1.0.1" + "@walletconnect/heartbeat": "npm:1.2.2" + "@walletconnect/jsonrpc-types": "npm:1.0.4" + "@walletconnect/keyvaluestorage": "npm:1.1.1" + "@walletconnect/logger": "npm:2.1.2" + events: "npm:3.3.0" + checksum: 10/f5c7a7aadc1241305fec822cbfb0edc7c6aeff3e9abee6b7a60279f87525211e7dc36ecdc86427cc86e99144559cfc97b39979beff91fabddf5b81720d25647a + languageName: node + linkType: hard + +"@walletconnect/types@npm:2.20.2": + version: 2.20.2 + resolution: "@walletconnect/types@npm:2.20.2" + dependencies: + "@walletconnect/events": "npm:1.0.1" + "@walletconnect/heartbeat": "npm:1.2.2" + "@walletconnect/jsonrpc-types": "npm:1.0.4" + "@walletconnect/keyvaluestorage": "npm:1.1.1" + "@walletconnect/logger": "npm:2.1.2" + events: "npm:3.3.0" + checksum: 10/841581d479579097517e2cce4bdeea9c57027cfbf51176197a395187a9284bcb97401fd9b8fbf06cfcafad28ce937776a661c632b7ba6e4be0b0ba4633250b36 + languageName: node + linkType: hard + +"@walletconnect/types@npm:2.21.3": + version: 2.21.3 + resolution: "@walletconnect/types@npm:2.21.3" + dependencies: + "@walletconnect/events": "npm:1.0.1" + "@walletconnect/heartbeat": "npm:1.2.2" + "@walletconnect/jsonrpc-types": "npm:1.0.4" + "@walletconnect/keyvaluestorage": "npm:1.1.1" + "@walletconnect/logger": "npm:2.1.2" + events: "npm:3.3.0" + checksum: 10/947cb8b74b0e80fc65b80b5170401a6d69b67f41afef49e07e2e62a88508a96ceea9c709057ed411cf48e12442125b9e7717fd18709ff553709ce7e941f403ef + languageName: node + linkType: hard + +"@walletconnect/universal-provider@npm:2.18.1": + version: 2.18.1 + resolution: "@walletconnect/universal-provider@npm:2.18.1" + dependencies: + "@walletconnect/events": "npm:1.0.1" + "@walletconnect/jsonrpc-http-connection": "npm:1.0.8" + "@walletconnect/jsonrpc-provider": "npm:1.0.14" + "@walletconnect/jsonrpc-types": "npm:1.0.4" + "@walletconnect/jsonrpc-utils": "npm:1.0.8" + "@walletconnect/keyvaluestorage": "npm:1.1.1" + "@walletconnect/logger": "npm:2.1.2" + "@walletconnect/sign-client": "npm:2.18.1" + "@walletconnect/types": "npm:2.18.1" + "@walletconnect/utils": "npm:2.18.1" + events: "npm:3.3.0" + lodash: "npm:4.17.21" + checksum: 10/df7acec514c6eb73b2e0c2fb775996c5e7834c8c4b7e39501753eb6931ac3647c4f1575c3f1620a2ffc6055841844a3ae544eb7a6852f3155b45f42dc39776c3 + languageName: node + linkType: hard + +"@walletconnect/utils@npm:2.18.1": + version: 2.18.1 + resolution: "@walletconnect/utils@npm:2.18.1" + dependencies: + "@ethersproject/transactions": "npm:5.7.0" + "@noble/ciphers": "npm:1.2.1" + "@noble/curves": "npm:1.8.1" + "@noble/hashes": "npm:1.7.1" + "@walletconnect/jsonrpc-utils": "npm:1.0.8" + "@walletconnect/keyvaluestorage": "npm:1.1.1" + "@walletconnect/relay-api": "npm:1.0.11" + "@walletconnect/relay-auth": "npm:1.1.0" + "@walletconnect/safe-json": "npm:1.0.2" + "@walletconnect/time": "npm:1.0.2" + "@walletconnect/types": "npm:2.18.1" + "@walletconnect/window-getters": "npm:1.0.1" + "@walletconnect/window-metadata": "npm:1.0.1" + detect-browser: "npm:5.3.0" + elliptic: "npm:6.6.1" + query-string: "npm:7.1.3" + uint8arrays: "npm:3.1.0" + checksum: 10/103c5d6b17cdf258f3e8ac85a8873c9174e804648c6844c72bf40b9eba56a8d3069b9e5dd825c27992194204314b7ebe66a81a935b0080e9e61c1e219c8c25bc + languageName: node + linkType: hard + +"@walletconnect/utils@npm:2.20.2": + version: 2.20.2 + resolution: "@walletconnect/utils@npm:2.20.2" dependencies: - "@walletconnect/events": "npm:1.0.1" - "@walletconnect/heartbeat": "npm:1.2.2" - "@walletconnect/jsonrpc-types": "npm:1.0.4" + "@noble/ciphers": "npm:1.2.1" + "@noble/curves": "npm:1.8.1" + "@noble/hashes": "npm:1.7.1" + "@walletconnect/jsonrpc-utils": "npm:1.0.8" "@walletconnect/keyvaluestorage": "npm:1.1.1" - "@walletconnect/logger": "npm:2.1.2" - events: "npm:3.3.0" - checksum: 10/11209cca804db1e4f58490d19f11d02ce97360ec1cd54efcd8074ca93361caf92841551b4ba376f949e62ebc6667e325757c3e3007d19a5c31af0265ce3eeb05 + "@walletconnect/relay-api": "npm:1.0.11" + "@walletconnect/relay-auth": "npm:1.1.0" + "@walletconnect/safe-json": "npm:1.0.2" + "@walletconnect/time": "npm:1.0.2" + "@walletconnect/types": "npm:2.20.2" + "@walletconnect/window-getters": "npm:1.0.1" + "@walletconnect/window-metadata": "npm:1.0.1" + bs58: "npm:6.0.0" + detect-browser: "npm:5.3.0" + query-string: "npm:7.1.3" + uint8arrays: "npm:3.1.0" + viem: "npm:2.23.2" + checksum: 10/22952dfe585d9f1fe0a3824236a97eb8ef3bd831e6560a15ea4df7935df13595b9fec3071dcf682b7b9e23882abf189f6f6b85d592339adbf0aeb48834435912 languageName: node linkType: hard -"@walletconnect/universal-provider@npm:2.17.3": - version: 2.17.3 - resolution: "@walletconnect/universal-provider@npm:2.17.3" +"@walletconnect/utils@npm:2.21.3": + version: 2.21.3 + resolution: "@walletconnect/utils@npm:2.21.3" dependencies: - "@walletconnect/events": "npm:1.0.1" - "@walletconnect/jsonrpc-http-connection": "npm:1.0.8" - "@walletconnect/jsonrpc-provider": "npm:1.0.14" - "@walletconnect/jsonrpc-types": "npm:1.0.4" + "@msgpack/msgpack": "npm:3.1.2" + "@noble/ciphers": "npm:1.3.0" + "@noble/curves": "npm:1.9.2" + "@noble/hashes": "npm:1.8.0" + "@scure/base": "npm:1.2.6" "@walletconnect/jsonrpc-utils": "npm:1.0.8" "@walletconnect/keyvaluestorage": "npm:1.1.1" - "@walletconnect/logger": "npm:2.1.2" - "@walletconnect/sign-client": "npm:2.17.3" - "@walletconnect/types": "npm:2.17.3" - "@walletconnect/utils": "npm:2.17.3" - events: "npm:3.3.0" - lodash: "npm:4.17.21" - checksum: 10/2ffeaccb358a652bbb16aace909cb5e3a5e830eed6b1bcecbf631c3be6d5d0bb3bb64b38f3d7bc15fb91c5497fc409fa505b9821ee30bd7d00147960501d6150 + "@walletconnect/relay-api": "npm:1.0.11" + "@walletconnect/relay-auth": "npm:1.1.0" + "@walletconnect/safe-json": "npm:1.0.2" + "@walletconnect/time": "npm:1.0.2" + "@walletconnect/types": "npm:2.21.3" + "@walletconnect/window-getters": "npm:1.0.1" + "@walletconnect/window-metadata": "npm:1.0.1" + blakejs: "npm:1.2.1" + bs58: "npm:6.0.0" + detect-browser: "npm:5.3.0" + query-string: "npm:7.1.3" + uint8arrays: "npm:3.1.1" + viem: "npm:2.31.0" + checksum: 10/5c980757150222eb360dc8d1d28e9c8e64621524f9dda272514d57ad4dc30344ecd4dc91f0028f2eb5f468d2536dc8cfd8811981bcf80f74abb0645571a4c304 languageName: node linkType: hard -"@walletconnect/utils@npm:2.17.3, @walletconnect/utils@npm:^2.17.3": +"@walletconnect/utils@npm:^2.17.3": version: 2.17.3 resolution: "@walletconnect/utils@npm:2.17.3" dependencies: @@ -12316,7 +14686,7 @@ __metadata: languageName: node linkType: hard -"@web3-onboard/common@npm:^2.3.4, @web3-onboard/common@npm:^2.4.1": +"@web3-onboard/common@npm:^2.4.1": version: 2.4.2 resolution: "@web3-onboard/common@npm:2.4.2" dependencies: @@ -12326,15 +14696,13 @@ __metadata: languageName: node linkType: hard -"@web3-onboard/core@npm:2.21.4": - version: 2.21.4 - resolution: "@web3-onboard/core@npm:2.21.4" +"@web3-onboard/core@npm:2.24.0": + version: 2.24.0 + resolution: "@web3-onboard/core@npm:2.24.0" dependencies: - "@web3-onboard/common": "npm:^2.3.4" - bignumber.js: "npm:^9.0.0" + "@web3-onboard/common": "npm:^2.4.1" bnc-sdk: "npm:^4.6.7" bowser: "npm:^2.11.0" - ethers: "npm:5.5.3" eventemitter3: "npm:^4.0.7" joi: "npm:17.9.1" lodash.merge: "npm:^4.6.2" @@ -12342,38 +14710,46 @@ __metadata: nanoid: "npm:^4.0.0" rxjs: "npm:^7.5.5" svelte: "npm:^3.49.0" - svelte-i18n: "npm:^3.3.13" - checksum: 10/77011b7b7b365f2c743b537b42359d66d24776e62238348c795b2c84512fda8f525dcfa0d8802a131e5071d6d99bab4adc330edb0834affa32d3c15c6bcc8805 + svelte-i18n: "npm:^4.0.1" + viem: "npm:2.12.0" + checksum: 10/8ec7a8652c4bde3c33b00819a4efdda0508acf81efe4eb2fda6e1a3ca17bccf33b50f71fa084aa11b3ee5c0d894755f8bdbae2c1b8fe1a67db07d5bd8992948c languageName: node linkType: hard -"@web3-onboard/injected-wallets@npm:^2.11.2": - version: 2.11.2 - resolution: "@web3-onboard/injected-wallets@npm:2.11.2" +"@web3-onboard/hw-common@npm:^2.3.3": + version: 2.3.3 + resolution: "@web3-onboard/hw-common@npm:2.3.3" dependencies: + "@ethereumjs/common": "npm:2.6.2" "@web3-onboard/common": "npm:^2.4.1" + bignumber.js: "npm:^9.1.0" + ethers: "npm:5.5.4" joi: "npm:17.9.1" - lodash.uniqby: "npm:^4.7.0" - checksum: 10/3dd84ac8c4c71b628ac5327603ab18e801460ff655d7724c7dbebb7d7e8595f0b1d822dde903633ed991fd7d293e930ee5178476edd0fbf7f1c80a790eaff878 + rxjs: "npm:^7.5.2" + checksum: 10/eccb0e49a1213dcf6e8e84a86c2ad5029ef78d64a65d874dc3b440bc876a2154e39b82f5c791d88502d13b89e2e598aaf11b8d5614cd0f88930464dd7739687d languageName: node linkType: hard -"@web3-onboard/walletconnect@npm:^2.6.1": - version: 2.6.1 - resolution: "@web3-onboard/walletconnect@npm:2.6.1" +"@web3-onboard/injected-wallets@npm:^2.11.3": + version: 2.11.3 + resolution: "@web3-onboard/injected-wallets@npm:2.11.3" dependencies: - "@walletconnect/ethereum-provider": "npm:^2.13.0" "@web3-onboard/common": "npm:^2.4.1" joi: "npm:17.9.1" - rxjs: "npm:^7.5.2" - checksum: 10/4f1c45df43e64afcc85b7e7e2e6819c98f025cb6f65e46080ed98efaa3dcf8afe33723c647553e20ae103d10f6df03635c08d1df9f160cff6023461d8ebc949d + lodash.uniqby: "npm:^4.7.0" + checksum: 10/513c447852b6467b45ea7b470d66aba77d2957a093c8b2b8a870274fca0bd2d0e01da3864188740e02bf1bd8bb0a63f3a4821fd2f73450ec2907decc34f6ca53 languageName: node linkType: hard -"@web3-storage/multipart-parser@npm:^1.0.0": - version: 1.0.0 - resolution: "@web3-storage/multipart-parser@npm:1.0.0" - checksum: 10/20d7a4330392d83f727586477fc6e709f8fca3b3664365b0d9d3041abe2aeb13f53030a3997a28a39cc74930863ebb625878b8c90789e5c990e4b64d9f22a93d +"@web3-onboard/walletconnect@npm:^2.6.1": + version: 2.6.2 + resolution: "@web3-onboard/walletconnect@npm:2.6.2" + dependencies: + "@walletconnect/ethereum-provider": "npm:^2.17.3" + "@web3-onboard/common": "npm:^2.4.1" + joi: "npm:17.9.1" + rxjs: "npm:^7.5.2" + checksum: 10/1e8f6fd6437ccd914fd001cf21fccdd8b0e9e320d6c340de2b39664bb4f465dfed0228eb40ff96b9b00a6bb032473755d3fcca3211e412f1c85f0cb371a63154 languageName: node linkType: hard @@ -12535,10 +14911,10 @@ __metadata: languageName: node linkType: hard -"@xmldom/xmldom@npm:~0.7.7": - version: 0.7.13 - resolution: "@xmldom/xmldom@npm:0.7.13" - checksum: 10/a359d15fe3c24fe85a1e1b3bc4cfd23d4f014fb8aa382aa445cccaac545e42958b75e386dd4853c76d82036401400b8d5e33cbcbfb6af7cdadeba769eae6122a +"@xstate/fsm@npm:^1.4.0": + version: 1.6.5 + resolution: "@xstate/fsm@npm:1.6.5" + checksum: 10/deae1501169d41d5395ce1581d7b08bde17911b7dec1533eacd5bee17060d22273055d6d6bc7e8f32222a502e4dcf510cd29064a5c9c5fa5aa2ced0ad60b2512 languageName: node linkType: hard @@ -12556,10 +14932,12 @@ __metadata: languageName: node linkType: hard -"@zxing/text-encoding@npm:0.9.0": - version: 0.9.0 - resolution: "@zxing/text-encoding@npm:0.9.0" - checksum: 10/268e4ef64b8eaa32b990240bdfd1f7b3e2b501a6ed866a565f7c9747f04ac884fbe0537fe12bb05d9241b98fb111270c0fd0023ef0a02d23a6619b4589e98f6b +"@yarnpkg/types@npm:^4.0.1": + version: 4.0.1 + resolution: "@yarnpkg/types@npm:4.0.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/f391763cd955356e9aad551b29e8de7bbf68a6c8992af7cdc950ccf53f8aff6695ad81aa4c8a8e7c582786a840a4f30617732e2cb49f4109b971a9242c31c9fc languageName: node linkType: hard @@ -12607,6 +14985,21 @@ __metadata: languageName: node linkType: hard +"abitype@npm:1.0.8": + version: 1.0.8 + resolution: "abitype@npm:1.0.8" + peerDependencies: + typescript: ">=5.0.4" + zod: ^3 >=3.22.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + checksum: 10/878e74fbac6a971953649b6216950437aa5834a604e9fa833a5b275a6967cff59857c7e43594ae906387d2fb7cad9370138dec4298eb8814815a3ffb6365902c + languageName: node + linkType: hard + "abort-controller@npm:^3.0.0": version: 3.0.0 resolution: "abort-controller@npm:3.0.0" @@ -12672,6 +15065,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.15.0": + version: 8.15.0 + resolution: "acorn@npm:8.15.0" + bin: + acorn: bin/acorn + checksum: 10/77f2de5051a631cf1729c090e5759148459cdb76b5f5c70f890503d629cf5052357b0ce783c0f976dd8a93c5150f59f6d18df1def3f502396a20f81282482fa4 + languageName: node + linkType: hard + "adjust-sourcemap-loader@npm:^4.0.0": version: 4.0.0 resolution: "adjust-sourcemap-loader@npm:4.0.0" @@ -12882,7 +15284,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^3.2.1": +"ansi-styles@npm:^3.1.0, ansi-styles@npm:^3.2.1": version: 3.2.1 resolution: "ansi-styles@npm:3.2.1" dependencies: @@ -12931,6 +15333,13 @@ __metadata: languageName: node linkType: hard +"apg-js@npm:^4.4.0": + version: 4.4.0 + resolution: "apg-js@npm:4.4.0" + checksum: 10/425f19096026742f5f156f26542b68f55602aa60f0c4ae2d72a0a888cf15fe9622223191202262dd8979d76a6125de9d8fd164d56c95fb113f49099f405eb08c + languageName: node + linkType: hard + "application-config-path@npm:^0.1.0": version: 0.1.1 resolution: "application-config-path@npm:0.1.1" @@ -13024,6 +15433,16 @@ __metadata: languageName: node linkType: hard +"array-buffer-byte-length@npm:^1.0.2": + version: 1.0.2 + resolution: "array-buffer-byte-length@npm:1.0.2" + dependencies: + call-bound: "npm:^1.0.3" + is-array-buffer: "npm:^3.0.5" + checksum: 10/0ae3786195c3211b423e5be8dd93357870e6fb66357d81da968c2c39ef43583ef6eece1f9cb1caccdae4806739c65dea832b44b8593414313cd76a89795fca63 + languageName: node + linkType: hard + "array-includes@npm:^3.1.6, array-includes@npm:^3.1.8": version: 3.1.8 resolution: "array-includes@npm:3.1.8" @@ -13085,7 +15504,7 @@ __metadata: languageName: node linkType: hard -"array.prototype.flatmap@npm:^1.3.2": +"array.prototype.flatmap@npm:^1.3.2, array.prototype.flatmap@npm:^1.3.3": version: 1.3.3 resolution: "array.prototype.flatmap@npm:1.3.3" dependencies: @@ -13159,6 +15578,17 @@ __metadata: languageName: node linkType: hard +"asn1js@npm:^3.0.6": + version: 3.0.6 + resolution: "asn1js@npm:3.0.6" + dependencies: + pvtsutils: "npm:^1.3.6" + pvutils: "npm:^1.1.3" + tslib: "npm:^2.8.1" + checksum: 10/2b283dd87662b3276ccc3e68db041c1062f629d9454b24fc2c141ad07c400ae50e02ee78f8c8a67043aa7d430e949d4616b8921178243932167bc2c9e861b972 + languageName: node + linkType: hard + "assert-plus@npm:1.0.0, assert-plus@npm:^1.0.0": version: 1.0.0 resolution: "assert-plus@npm:1.0.0" @@ -13299,6 +15729,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.8.2": + version: 1.8.3 + resolution: "axios@npm:1.8.3" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: 10/050f911cadd6d47a38ddbf91d2f8da2c34661dda8077e7ad6546e8178701125366fddbba07211a648b6815cf6c2c3c91c0a65d8b968e3d1a6054a21141ff9c01 + languageName: node + linkType: hard + "axobject-query@npm:^4.1.0": version: 4.1.0 resolution: "axobject-query@npm:4.1.0" @@ -13345,18 +15786,15 @@ __metadata: languageName: node linkType: hard -"babel-loader@npm:^8.4.1": - version: 8.4.1 - resolution: "babel-loader@npm:8.4.1" +"babel-loader@npm:^10.0.0": + version: 10.0.0 + resolution: "babel-loader@npm:10.0.0" dependencies: - find-cache-dir: "npm:^3.3.1" - loader-utils: "npm:^2.0.4" - make-dir: "npm:^3.1.0" - schema-utils: "npm:^2.6.5" + find-up: "npm:^5.0.0" peerDependencies: - "@babel/core": ^7.0.0 - webpack: ">=2" - checksum: 10/b54ae3796a351e5b5186cd7a8d53a902b679a33a187424c2858e385850142139a4343524c9fddd0eb4356250e51e67e47a9efeef79d01e132976c406212cba1d + "@babel/core": ^7.12.0 + webpack: ">=5.61.0" + checksum: 10/f22dc803e38a6b29cc61fbc3482f1f42a8787df2a43706dc937d328103ba6b947a223f67706b07af765d415664ad56e9fed00f85b524fe223f3ac3f00b03770b languageName: node linkType: hard @@ -13373,6 +15811,15 @@ __metadata: languageName: node linkType: hard +"babel-plugin-dynamic-import-node@npm:^2.3.3": + version: 2.3.3 + resolution: "babel-plugin-dynamic-import-node@npm:2.3.3" + dependencies: + object.assign: "npm:^4.1.0" + checksum: 10/c9d24415bcc608d0db7d4c8540d8002ac2f94e2573d2eadced137a29d9eab7e25d2cbb4bc6b9db65cf6ee7430f7dd011d19c911a9a778f0533b4a05ce8292c9b + languageName: node + linkType: hard + "babel-plugin-istanbul@npm:^6.1.1": version: 6.1.1 resolution: "babel-plugin-istanbul@npm:6.1.1" @@ -13434,6 +15881,18 @@ __metadata: languageName: node linkType: hard +"babel-plugin-polyfill-corejs3@npm:^0.11.0": + version: 0.11.1 + resolution: "babel-plugin-polyfill-corejs3@npm:0.11.1" + dependencies: + "@babel/helper-define-polyfill-provider": "npm:^0.6.3" + core-js-compat: "npm:^3.40.0" + peerDependencies: + "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 + checksum: 10/19a2978ee3462cc3b98e7d36e6537bf9fb1fb61f42fd96cb41e9313f2ac6f2c62380d94064366431eff537f342184720fe9bce73eb65fd57c5311d15e8648f62 + languageName: node + linkType: hard + "babel-plugin-polyfill-regenerator@npm:^0.6.1": version: 0.6.3 resolution: "babel-plugin-polyfill-regenerator@npm:0.6.3" @@ -13452,16 +15911,7 @@ __metadata: languageName: node linkType: hard -"babel-plugin-syntax-hermes-parser@npm:^0.23.1": - version: 0.23.1 - resolution: "babel-plugin-syntax-hermes-parser@npm:0.23.1" - dependencies: - hermes-parser: "npm:0.23.1" - checksum: 10/5412008e8e85b08cd0d78168f746ade68b8ed69c0068831ce5e3d028f01c644f546ca0e2b7c9a4a8c6b9d5f14aff84c2453ab44b19cbec55e4366b20bbba9040 - languageName: node - linkType: hard - -"babel-plugin-syntax-hermes-parser@npm:^0.25.1": +"babel-plugin-syntax-hermes-parser@npm:0.25.1, babel-plugin-syntax-hermes-parser@npm:^0.25.1": version: 0.25.1 resolution: "babel-plugin-syntax-hermes-parser@npm:0.25.1" dependencies: @@ -13504,28 +15954,37 @@ __metadata: languageName: node linkType: hard -"babel-preset-expo@npm:~12.0.3": - version: 12.0.3 - resolution: "babel-preset-expo@npm:12.0.3" +"babel-preset-expo@npm:~13.1.11": + version: 13.1.11 + resolution: "babel-preset-expo@npm:13.1.11" dependencies: + "@babel/helper-module-imports": "npm:^7.25.9" "@babel/plugin-proposal-decorators": "npm:^7.12.9" - "@babel/plugin-transform-export-namespace-from": "npm:^7.22.11" - "@babel/plugin-transform-object-rest-spread": "npm:^7.12.13" - "@babel/plugin-transform-parameters": "npm:^7.22.15" + "@babel/plugin-proposal-export-default-from": "npm:^7.24.7" + "@babel/plugin-syntax-export-default-from": "npm:^7.24.7" + "@babel/plugin-transform-export-namespace-from": "npm:^7.25.9" + "@babel/plugin-transform-flow-strip-types": "npm:^7.25.2" + "@babel/plugin-transform-modules-commonjs": "npm:^7.24.8" + "@babel/plugin-transform-object-rest-spread": "npm:^7.24.7" + "@babel/plugin-transform-parameters": "npm:^7.24.7" + "@babel/plugin-transform-private-methods": "npm:^7.24.7" + "@babel/plugin-transform-private-property-in-object": "npm:^7.24.7" + "@babel/plugin-transform-runtime": "npm:^7.24.7" "@babel/preset-react": "npm:^7.22.15" "@babel/preset-typescript": "npm:^7.23.0" - "@react-native/babel-preset": "npm:0.76.3" + "@react-native/babel-preset": "npm:0.79.2" babel-plugin-react-native-web: "npm:~0.19.13" + babel-plugin-syntax-hermes-parser: "npm:^0.25.1" + babel-plugin-transform-flow-enums: "npm:^0.0.2" + debug: "npm:^4.3.4" react-refresh: "npm:^0.14.2" + resolve-from: "npm:^5.0.0" peerDependencies: - babel-plugin-react-compiler: ^19.0.0-beta-9ee70a1-20241017 - react-compiler-runtime: ^19.0.0-beta-8a03594-20241020 + babel-plugin-react-compiler: ^19.0.0-beta-e993439-20250405 peerDependenciesMeta: babel-plugin-react-compiler: optional: true - react-compiler-runtime: - optional: true - checksum: 10/9c3cd95c0ed19d3cc6045be668bd0a630781318e28cdc278abc1604bf77f7918bf25c9e5febe0bf634c2b096dfa177453121afa3aba3f22dc096c6d6d6929fad + checksum: 10/0f9ab899cd0a3ad5098a3e381d21273237a1e1e9122e923e5483411a1584a0cd769d00c6210b860b09fb6d59cf81028b85ef3c69c4a6ca77db2dd52481048dd6 languageName: node linkType: hard @@ -13556,11 +16015,18 @@ __metadata: linkType: hard "base-x@npm:^3.0.2": - version: 3.0.10 - resolution: "base-x@npm:3.0.10" + version: 3.0.11 + resolution: "base-x@npm:3.0.11" dependencies: safe-buffer: "npm:^5.0.1" - checksum: 10/52307739559e81d9980889de2359cb4f816cc0eb9a463028fa3ab239ab913d9044a1b47b4520f98e68453df32a457b8ba58b8d0ee7e757fc3fb971f3fa7a1482 + checksum: 10/c2e3c443fd07cb9b9d3e179a9e9c581daa31881005841fe8d6a834e534505890fedf03465ccf14512da60e3f7be00fe66167806b159ba076d2c03952ae7460c4 + languageName: node + linkType: hard + +"base-x@npm:^5.0.0": + version: 5.0.1 + resolution: "base-x@npm:5.0.1" + checksum: 10/6e4f847ef842e0a71c6b6020a6ec482a2a5e727f5a98534dbfd5d5a4e8afbc0d1bdf1fd57174b3f0455d107f10a932c3c7710bec07e2878f80178607f8f605c8 languageName: node linkType: hard @@ -13571,6 +16037,13 @@ __metadata: languageName: node linkType: hard +"base64-arraybuffer@npm:^1.0.1": + version: 1.0.2 + resolution: "base64-arraybuffer@npm:1.0.2" + checksum: 10/15e6400d2d028bf18be4ed97702b11418f8f8779fb8c743251c863b726638d52f69571d4cc1843224da7838abef0949c670bde46936663c45ad078e89fee5c62 + languageName: node + linkType: hard + "base64-js@npm:^1.2.3, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -13617,7 +16090,7 @@ __metadata: languageName: node linkType: hard -"bignumber.js@npm:^9.0.0": +"bignumber.js@npm:^9.1.0": version: 9.1.2 resolution: "bignumber.js@npm:9.1.2" checksum: 10/d89b8800a987225d2c00dcbf8a69dc08e92aa0880157c851c287b307d31ceb2fc2acb0c62c3e3a3d42b6c5fcae9b004035f13eb4386e56d529d7edac18d5c9d8 @@ -13642,7 +16115,7 @@ __metadata: languageName: node linkType: hard -"blakejs@npm:^1.1.0": +"blakejs@npm:1.2.1, blakejs@npm:^1.1.0": version: 1.2.1 resolution: "blakejs@npm:1.2.1" checksum: 10/0638b1bd058b21892633929c43005aa6a4cc4b2ac5b338a146c3c076622f1b360795bd7a4d1f077c9b01863ed2df0c1504a81c5b520d164179120434847e6cd7 @@ -13710,12 +16183,12 @@ __metadata: languageName: node linkType: hard -"bplist-creator@npm:0.0.7": - version: 0.0.7 - resolution: "bplist-creator@npm:0.0.7" +"bplist-creator@npm:0.1.0": + version: 0.1.0 + resolution: "bplist-creator@npm:0.1.0" dependencies: - stream-buffers: "npm:~2.2.0" - checksum: 10/9fe946e55fe378072592924d2fed16eee80e3027e3637ab2e9ef0e16eef470f11e7d09bf88e6b68016cd8c441213df89df777ca8a3561522a136a26948886eb8 + stream-buffers: "npm:2.2.x" + checksum: 10/347dcaa96fc7383efcf4946dbfb1846691b27195dcfb5c76b39144ca88c13368a72bad33bbffc8869bc5d325a35ff97b5bb15ef9e3fa4702fe9fd30867e988c2 languageName: node linkType: hard @@ -13738,12 +16211,12 @@ __metadata: linkType: hard "brace-expansion@npm:^1.1.7": - version: 1.1.11 - resolution: "brace-expansion@npm:1.1.11" + version: 1.1.12 + resolution: "brace-expansion@npm:1.1.12" dependencies: balanced-match: "npm:^1.0.0" concat-map: "npm:0.0.1" - checksum: 10/faf34a7bb0c3fcf4b59c7808bc5d2a96a40988addf2e7e09dfbb67a2251800e0d14cd2bfc1aa79174f2f5095c54ff27f46fb1289fe2d77dac755b5eb3434cc07 + checksum: 10/12cb6d6310629e3048cadb003e1aca4d8c9bb5c67c3c321bafdd7e7a50155de081f78ea3e0ed92ecc75a9015e784f301efc8132383132f4f7904ad1ac529c562 languageName: node linkType: hard @@ -13854,7 +16327,21 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.22.2, browserslist@npm:^4.24.0, browserslist@npm:^4.24.2": +"browserslist@npm:^4.22.2": + version: 4.24.2 + resolution: "browserslist@npm:4.24.2" + dependencies: + caniuse-lite: "npm:^1.0.30001669" + electron-to-chromium: "npm:^1.5.41" + node-releases: "npm:^2.0.18" + update-browserslist-db: "npm:^1.1.1" + bin: + browserslist: cli.js + checksum: 10/f8a9d78bbabe466c57ffd5c50a9e5582a5df9aa68f43078ca62a9f6d0d6c70ba72eca72d0a574dbf177cf55cdca85a46f7eb474917a47ae5398c66f8b76f7d1c + languageName: node + linkType: hard + +"browserslist@npm:^4.24.0, browserslist@npm:^4.24.2": version: 4.24.3 resolution: "browserslist@npm:4.24.3" dependencies: @@ -13868,7 +16355,21 @@ __metadata: languageName: node linkType: hard -"bs-logger@npm:^0.2.6": +"browserslist@npm:^4.24.4": + version: 4.25.0 + resolution: "browserslist@npm:4.25.0" + dependencies: + caniuse-lite: "npm:^1.0.30001718" + electron-to-chromium: "npm:^1.5.160" + node-releases: "npm:^2.0.19" + update-browserslist-db: "npm:^1.1.3" + bin: + browserslist: cli.js + checksum: 10/4a5442b1a0d09c4c64454f184b8fed17d8c3e202034bf39de28f74497d7bd28dddee121b2bab4e34825fe0ed4c166d84e32a39f576c76fce73c1f8f05e4b6ee6 + languageName: node + linkType: hard + +"bs-logger@npm:0.x, bs-logger@npm:^0.2.6": version: 0.2.6 resolution: "bs-logger@npm:0.2.6" dependencies: @@ -13877,6 +16378,15 @@ __metadata: languageName: node linkType: hard +"bs58@npm:6.0.0": + version: 6.0.0 + resolution: "bs58@npm:6.0.0" + dependencies: + base-x: "npm:^5.0.0" + checksum: 10/7c9bb2b2d93d997a8c652de3510d89772007ac64ee913dc4e16ba7ff47624caad3128dcc7f360763eb6308760c300b3e9fd91b8bcbd489acd1a13278e7949c4e + languageName: node + linkType: hard + "bs58@npm:^4.0.0": version: 4.0.1 resolution: "bs58@npm:4.0.1" @@ -13906,23 +16416,6 @@ __metadata: languageName: node linkType: hard -"buffer-alloc-unsafe@npm:^1.1.0": - version: 1.1.0 - resolution: "buffer-alloc-unsafe@npm:1.1.0" - checksum: 10/c5e18bf51f67754ec843c9af3d4c005051aac5008a3992938dda1344e5cfec77c4b02b4ca303644d1e9a6e281765155ce6356d85c6f5ccc5cd21afc868def396 - languageName: node - linkType: hard - -"buffer-alloc@npm:^1.1.0": - version: 1.2.0 - resolution: "buffer-alloc@npm:1.2.0" - dependencies: - buffer-alloc-unsafe: "npm:^1.1.0" - buffer-fill: "npm:^1.0.0" - checksum: 10/560cd27f3cbe73c614867da373407d4506309c62fe18de45a1ce191f3785ec6ca2488d802ff82065798542422980ca25f903db078c57822218182c37c3576df5 - languageName: node - linkType: hard - "buffer-crc32@npm:~0.2.3": version: 0.2.13 resolution: "buffer-crc32@npm:0.2.13" @@ -13930,13 +16423,6 @@ __metadata: languageName: node linkType: hard -"buffer-fill@npm:^1.0.0": - version: 1.0.0 - resolution: "buffer-fill@npm:1.0.0" - checksum: 10/c29b4723ddeab01e74b5d3b982a0c6828f2ded49cef049ddca3dac661c874ecdbcecb5dd8380cf0f4adbeb8cff90a7de724126750a1f1e5ebd4eb6c59a1315b1 - languageName: node - linkType: hard - "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -14015,26 +16501,6 @@ __metadata: languageName: node linkType: hard -"cacache@npm:^18.0.2": - version: 18.0.4 - resolution: "cacache@npm:18.0.4" - dependencies: - "@npmcli/fs": "npm:^3.1.0" - fs-minipass: "npm:^3.0.0" - glob: "npm:^10.2.2" - lru-cache: "npm:^10.0.1" - minipass: "npm:^7.0.3" - minipass-collect: "npm:^2.0.1" - minipass-flush: "npm:^1.0.5" - minipass-pipeline: "npm:^1.2.4" - p-map: "npm:^4.0.0" - ssri: "npm:^10.0.0" - tar: "npm:^6.1.11" - unique-filename: "npm:^3.0.0" - checksum: 10/ca2f7b2d3003f84d362da9580b5561058ccaecd46cba661cbcff0375c90734b610520d46b472a339fd032d91597ad6ed12dde8af81571197f3c9772b5d35b104 - languageName: node - linkType: hard - "cacache@npm:^19.0.1": version: 19.0.1 resolution: "cacache@npm:19.0.1" @@ -14072,7 +16538,17 @@ __metadata: languageName: node linkType: hard -"call-bind@npm:^1.0.0, call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.6, call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": +"call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10/00482c1f6aa7cfb30fb1dbeb13873edf81cfac7c29ed67a5957d60635a56b2a4a480f1016ddbdb3395cc37900d46037fb965043a51c5c789ffeab4fc535d18b5 + languageName: node + linkType: hard + +"call-bind@npm:^1.0.0, call-bind@npm:^1.0.8": version: 1.0.8 resolution: "call-bind@npm:1.0.8" dependencies: @@ -14084,6 +16560,19 @@ __metadata: languageName: node linkType: hard +"call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.6, call-bind@npm:^1.0.7": + version: 1.0.7 + resolution: "call-bind@npm:1.0.7" + dependencies: + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + get-intrinsic: "npm:^1.2.4" + set-function-length: "npm:^1.2.1" + checksum: 10/cd6fe658e007af80985da5185bff7b55e12ef4c2b6f41829a26ed1eef254b1f1c12e3dfd5b2b068c6ba8b86aba62390842d81752e67dcbaec4f6f76e7113b6b7 + languageName: node + linkType: hard + "call-bound@npm:^1.0.2, call-bound@npm:^1.0.3": version: 1.0.3 resolution: "call-bound@npm:1.0.3" @@ -14094,6 +16583,16 @@ __metadata: languageName: node linkType: hard +"call-bound@npm:^1.0.4": + version: 1.0.4 + resolution: "call-bound@npm:1.0.4" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + get-intrinsic: "npm:^1.3.0" + checksum: 10/ef2b96e126ec0e58a7ff694db43f4d0d44f80e641370c21549ed911fecbdbc2df3ebc9bddad918d6bbdefeafb60bb3337902006d5176d72bcd2da74820991af7 + languageName: node + linkType: hard + "call-me-maybe@npm:^1.0.1": version: 1.0.2 resolution: "call-me-maybe@npm:1.0.2" @@ -14175,6 +16674,20 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001669": + version: 1.0.30001686 + resolution: "caniuse-lite@npm:1.0.30001686" + checksum: 10/dc34d4daa992256b94def2894e478ba4d9786581dff3b180d642d74c7578f7d8958be985d9da5d08f09b81dd9811b653e4980616bae26b1896968cfdf8d535da + languageName: node + linkType: hard + +"caniuse-lite@npm:^1.0.30001718": + version: 1.0.30001720 + resolution: "caniuse-lite@npm:1.0.30001720" + checksum: 10/6557c5052fa17fd531f3e1a8013b5924fb69afcd53d9f3e3b9adc9e31c5a7e436b674c000c53659e097fe1fda1c290d1bd17c7f3f98d13749644386ed722ab5f + languageName: node + linkType: hard + "case-sensitive-paths-webpack-plugin@npm:^2.4.0": version: 2.4.0 resolution: "case-sensitive-paths-webpack-plugin@npm:2.4.0" @@ -14209,6 +16722,17 @@ __metadata: languageName: node linkType: hard +"chalk@npm:2.3.0": + version: 2.3.0 + resolution: "chalk@npm:2.3.0" + dependencies: + ansi-styles: "npm:^3.1.0" + escape-string-regexp: "npm:^1.0.5" + supports-color: "npm:^4.0.0" + checksum: 10/760d2da7c300a23be015c38df0b0dec43cdcb12a12248f045015b5daef48d9e3c0de1fb6f4ab6bb09c4bb320e0b68ec928d7200417af26b03a314a745764ed02 + languageName: node + linkType: hard + "chalk@npm:^2.0.1, chalk@npm:^2.4.2": version: 2.4.2 resolution: "chalk@npm:2.4.2" @@ -14289,13 +16813,6 @@ __metadata: languageName: node linkType: hard -"charenc@npm:0.0.2": - version: 0.0.2 - resolution: "charenc@npm:0.0.2" - checksum: 10/81dcadbe57e861d527faf6dd3855dc857395a1c4d6781f4847288ab23cffb7b3ee80d57c15bba7252ffe3e5e8019db767757ee7975663ad2ca0939bb8fcaf2e5 - languageName: node - linkType: hard - "check-dependency-version-consistency@npm:^4.1.0": version: 4.1.1 resolution: "check-dependency-version-consistency@npm:4.1.1" @@ -14329,6 +16846,39 @@ __metadata: languageName: node linkType: hard +"cheerio-select@npm:^2.1.0": + version: 2.1.0 + resolution: "cheerio-select@npm:2.1.0" + dependencies: + boolbase: "npm:^1.0.0" + css-select: "npm:^5.1.0" + css-what: "npm:^6.1.0" + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.3" + domutils: "npm:^3.0.1" + checksum: 10/b5d89208c23468c3a32d1e04f88b9e8c6e332e3649650c5cd29255e2cebc215071ae18563f58c3dc3f6ef4c234488fc486035490fceb78755572288245e2931a + languageName: node + linkType: hard + +"cheerio@npm:^1.0.0": + version: 1.0.0 + resolution: "cheerio@npm:1.0.0" + dependencies: + cheerio-select: "npm:^2.1.0" + dom-serializer: "npm:^2.0.0" + domhandler: "npm:^5.0.3" + domutils: "npm:^3.1.0" + encoding-sniffer: "npm:^0.2.0" + htmlparser2: "npm:^9.1.0" + parse5: "npm:^7.1.2" + parse5-htmlparser2-tree-adapter: "npm:^7.0.0" + parse5-parser-stream: "npm:^7.1.2" + undici: "npm:^6.19.5" + whatwg-mimetype: "npm:^4.0.0" + checksum: 10/b535070add0f86b0a1f234274ad3ffb2c1c375c05b322d8057e89c3c797b3b4d2f05826c34a04df218bec9abf21b9f0d0bd71974a8dfe28b943fb87ab0170c38 + languageName: node + linkType: hard + "chokidar@npm:^3.5.1, chokidar@npm:^3.5.2, chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" @@ -14357,13 +16907,6 @@ __metadata: languageName: node linkType: hard -"chownr@npm:^2.0.0": - version: 2.0.0 - resolution: "chownr@npm:2.0.0" - checksum: 10/c57cf9dd0791e2f18a5ee9c1a299ae6e801ff58fee96dc8bfd0dcb4738a6ce58dd252a3605b1c93c6418fe4f9d5093b28ffbf4d66648cb2a9c67eaef9679be2f - languageName: node - linkType: hard - "chownr@npm:^3.0.0": version: 3.0.0 resolution: "chownr@npm:3.0.0" @@ -14814,7 +17357,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:^12.0.0, commander@npm:~12.1.0": +"commander@npm:^12.0.0, commander@npm:^12.1.0, commander@npm:~12.1.0": version: 12.1.0 resolution: "commander@npm:12.1.0" checksum: 10/cdaeb672d979816853a4eed7f1310a9319e8b976172485c2a6b437ed0db0a389a44cfb222bfbde772781efa9f215bdd1b936f80d6b249485b465c6cb906e1f93 @@ -14873,14 +17416,7 @@ __metadata: "commondir@npm:^1.0.1": version: 1.0.1 resolution: "commondir@npm:1.0.1" - checksum: 10/4620bc4936a4ef12ce7dfcd272bb23a99f2ad68889a4e4ad766c9f8ad21af982511934d6f7050d4a8bde90011b1c15d56e61a1b4576d9913efbf697a20172d6c - languageName: node - linkType: hard - -"component-type@npm:^1.2.1": - version: 1.2.2 - resolution: "component-type@npm:1.2.2" - checksum: 10/2ba8e2dd3ab0fdfd93010deebc107b45a04d32947bb2524b8ce73cdb0215a5bd0bf709831d60fda43b4058cfc75a3ee6ec5617645b86d0e18644c4bdfcc289da + checksum: 10/4620bc4936a4ef12ce7dfcd272bb23a99f2ad68889a4e4ad766c9f8ad21af982511934d6f7050d4a8bde90011b1c15d56e61a1b4576d9913efbf697a20172d6c languageName: node linkType: hard @@ -14976,20 +17512,6 @@ __metadata: languageName: node linkType: hard -"cookie-signature@npm:^1.1.0": - version: 1.2.2 - resolution: "cookie-signature@npm:1.2.2" - checksum: 10/be44a3c9a56f3771aea3a8bd8ad8f0a8e2679bcb967478267f41a510b4eb5ec55085386ba79c706c4ac21605ca76f4251973444b90283e0eb3eeafe8a92c7708 - languageName: node - linkType: hard - -"cookie@npm:^0.6.0": - version: 0.6.0 - resolution: "cookie@npm:0.6.0" - checksum: 10/c1f8f2ea7d443b9331680598b0ae4e6af18a618c37606d1bbdc75bec8361cce09fe93e727059a673f2ba24467131a9fb5a4eec76bb1b149c1b3e1ccb268dc583 - languageName: node - linkType: hard - "cookie@npm:^0.7.2": version: 0.7.2 resolution: "cookie@npm:0.7.2" @@ -15015,6 +17537,15 @@ __metadata: languageName: node linkType: hard +"core-js-compat@npm:^3.40.0": + version: 3.42.0 + resolution: "core-js-compat@npm:3.42.0" + dependencies: + browserslist: "npm:^4.24.4" + checksum: 10/2052c73e500e95420d948a0595f4055e40ca6a208cc15c7981b7f202efa851bfae3de59a13009dc367cc5fbaeb8ff84a64c7c0bfc37de4b3bd2cf6b0e14290bd + languageName: node + linkType: hard + "core-js-pure@npm:^3.23.3": version: 3.39.0 resolution: "core-js-pure@npm:3.39.0" @@ -15095,6 +17626,15 @@ __metadata: languageName: node linkType: hard +"crc-32@npm:^1.2.0": + version: 1.2.2 + resolution: "crc-32@npm:1.2.2" + bin: + crc32: bin/crc32.njs + checksum: 10/824f696a5baaf617809aa9cd033313c8f94f12d15ebffa69f10202480396be44aef9831d900ab291638a8022ed91c360696dd5b1ba691eb3f34e60be8835b7c3 + languageName: node + linkType: hard + "create-ecdh@npm:^4.0.4": version: 4.0.4 resolution: "create-ecdh@npm:4.0.4" @@ -15177,19 +17717,6 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^6.0.0": - version: 6.0.6 - resolution: "cross-spawn@npm:6.0.6" - dependencies: - nice-try: "npm:^1.0.4" - path-key: "npm:^2.0.1" - semver: "npm:^5.5.0" - shebang-command: "npm:^1.2.0" - which: "npm:^1.2.9" - checksum: 10/7abf6137b23293103a22bfeaf320f2d63faae70d97ddb4b58597237501d2efdd84cdc69a30246977e0c5f68216593894d41a7f122915dd4edf448db14c74171b - languageName: node - linkType: hard - "cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" @@ -15210,13 +17737,6 @@ __metadata: languageName: node linkType: hard -"crypt@npm:0.0.2": - version: 0.0.2 - resolution: "crypt@npm:0.0.2" - checksum: 10/2c72768de3d28278c7c9ffd81a298b26f87ecdfe94415084f339e6632f089b43fe039f2c93f612bcb5ffe447238373d93b2e8c90894cba6cfb0ac7a74616f8b9 - languageName: node - linkType: hard - "crypto-browserify@npm:^3.12.0": version: 3.12.1 resolution: "crypto-browserify@npm:3.12.1" @@ -15244,6 +17764,13 @@ __metadata: languageName: node linkType: hard +"crypto-js@npm:^4.2.0": + version: 4.2.0 + resolution: "crypto-js@npm:4.2.0" + checksum: 10/c7bcc56a6e01c3c397e95aa4a74e4241321f04677f9a618a8f48a63b5781617248afb9adb0629824792e7ec20ca0d4241a49b6b2938ae6f973ec4efc5c53c924 + languageName: node + linkType: hard + "crypto-random-string@npm:^2.0.0": version: 2.0.0 resolution: "crypto-random-string@npm:2.0.0" @@ -15504,13 +18031,6 @@ __metadata: languageName: node linkType: hard -"data-uri-to-buffer@npm:^3.0.1": - version: 3.0.1 - resolution: "data-uri-to-buffer@npm:3.0.1" - checksum: 10/c59c3009686a78c071806b72f4810856ec28222f0f4e252aa495ec027ed9732298ceea99c50328cf59b151dd34cbc3ad6150bbb43e41fc56fa19f48c99e9fc30 - languageName: node - linkType: hard - "data-urls@npm:^3.0.2": version: 3.0.2 resolution: "data-urls@npm:3.0.2" @@ -15533,6 +18053,17 @@ __metadata: languageName: node linkType: hard +"data-view-buffer@npm:^1.0.2": + version: 1.0.2 + resolution: "data-view-buffer@npm:1.0.2" + dependencies: + call-bound: "npm:^1.0.3" + es-errors: "npm:^1.3.0" + is-data-view: "npm:^1.0.2" + checksum: 10/c10b155a4e93999d3a215d08c23eea95f865e1f510b2e7748fcae1882b776df1afe8c99f483ace7fc0e5a3193ab08da138abebc9829d12003746c5a338c4d644 + languageName: node + linkType: hard + "data-view-byte-length@npm:^1.0.1": version: 1.0.1 resolution: "data-view-byte-length@npm:1.0.1" @@ -15544,6 +18075,17 @@ __metadata: languageName: node linkType: hard +"data-view-byte-length@npm:^1.0.2": + version: 1.0.2 + resolution: "data-view-byte-length@npm:1.0.2" + dependencies: + call-bound: "npm:^1.0.3" + es-errors: "npm:^1.3.0" + is-data-view: "npm:^1.0.2" + checksum: 10/2a47055fcf1ab3ec41b00b6f738c6461a841391a643c9ed9befec1117c1765b4d492661d97fb7cc899200c328949dca6ff189d2c6537d96d60e8a02dfe3c95f7 + languageName: node + linkType: hard + "data-view-byte-offset@npm:^1.0.0": version: 1.0.0 resolution: "data-view-byte-offset@npm:1.0.0" @@ -15555,6 +18097,17 @@ __metadata: languageName: node linkType: hard +"data-view-byte-offset@npm:^1.0.1": + version: 1.0.1 + resolution: "data-view-byte-offset@npm:1.0.1" + dependencies: + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + is-data-view: "npm:^1.0.1" + checksum: 10/fa3bdfa0968bea6711ee50375094b39f561bce3f15f9e558df59de9c25f0bdd4cddc002d9c1d70ac7772ebd36854a7e22d1761e7302a934e6f1c2263bcf44aa2 + languageName: node + linkType: hard + "date-fns@npm:^2.30.0": version: 2.30.0 resolution: "date-fns@npm:2.30.0" @@ -15594,15 +18147,15 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.7, debug@npm:~4.4.0": - version: 4.4.0 - resolution: "debug@npm:4.4.0" +"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5": + version: 4.3.7 + resolution: "debug@npm:4.3.7" dependencies: ms: "npm:^2.1.3" peerDependenciesMeta: supports-color: optional: true - checksum: 10/1847944c2e3c2c732514b93d11886575625686056cd765336212dc15de2d2b29612b6cd80e1afba767bb8e1803b778caf9973e98169ef1a24a7a7009e1820367 + checksum: 10/71168908b9a78227ab29d5d25fe03c5867750e31ce24bf2c44a86efc5af041758bb56569b0a3d48a9b5344c00a24a777e6f4100ed6dfd9534a42c1dde285125a languageName: node linkType: hard @@ -15615,6 +18168,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.0.0, debug@npm:^4.3.7, debug@npm:^4.4.0, debug@npm:~4.4.0": + version: 4.4.0 + resolution: "debug@npm:4.4.0" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10/1847944c2e3c2c732514b93d11886575625686056cd765336212dc15de2d2b29612b6cd80e1afba767bb8e1803b778caf9973e98169ef1a24a7a7009e1820367 + languageName: node + linkType: hard + "decamelize-keys@npm:^1.1.0": version: 1.1.1 resolution: "decamelize-keys@npm:1.1.1" @@ -15632,6 +18197,13 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:10, decimal.js@npm:^10.4.3": + version: 10.5.0 + resolution: "decimal.js@npm:10.5.0" + checksum: 10/714d49cf2f2207b268221795ede330e51452b7c451a0c02a770837d2d4faed47d603a729c2aa1d952eb6c4102d999e91c9b952c1aa016db3c5cba9fc8bf4cda2 + languageName: node + linkType: hard + "decimal.js@npm:^10.4.2": version: 10.4.3 resolution: "decimal.js@npm:10.4.3" @@ -15716,16 +18288,6 @@ __metadata: languageName: node linkType: hard -"default-gateway@npm:^4.2.0": - version: 4.2.0 - resolution: "default-gateway@npm:4.2.0" - dependencies: - execa: "npm:^1.0.0" - ip-regex: "npm:^2.1.0" - checksum: 10/1f5be765471689c6bab33e0c8b87363c3e2485cc1ab78904d383a8a8293a79f684da2a3303744b112503f986af4ea87d917c63a468ed913e9b0c31588c02d6a4 - languageName: node - linkType: hard - "defaults@npm:^1.0.3": version: 1.0.4 resolution: "defaults@npm:1.0.4" @@ -15771,22 +18333,6 @@ __metadata: languageName: node linkType: hard -"del@npm:^6.0.0": - version: 6.1.1 - resolution: "del@npm:6.1.1" - dependencies: - globby: "npm:^11.0.1" - graceful-fs: "npm:^4.2.4" - is-glob: "npm:^4.0.1" - is-path-cwd: "npm:^2.2.0" - is-path-inside: "npm:^3.0.2" - p-map: "npm:^4.0.0" - rimraf: "npm:^3.0.2" - slash: "npm:^3.0.0" - checksum: 10/563288b73b8b19a7261c47fd21a330eeab6e2acd7c6208c49790dfd369127120dd7836cdf0c1eca216b77c94782a81507eac6b4734252d3bef2795cb366996b6 - languageName: node - linkType: hard - "delayed-stream@npm:~1.0.0": version: 1.0.0 resolution: "delayed-stream@npm:1.0.0" @@ -15794,13 +18340,6 @@ __metadata: languageName: node linkType: hard -"denodeify@npm:^1.2.1": - version: 1.2.1 - resolution: "denodeify@npm:1.2.1" - checksum: 10/f5371a93051a81b0d8f54ac2972de6ae7cd9ba272174dff58bbf28a545c5b38e1952b3e8860e6b31ead44981bb14e158720fa43501e86252315b25f3ca34a460 - languageName: node - linkType: hard - "depd@npm:2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" @@ -16071,6 +18610,17 @@ __metadata: languageName: node linkType: hard +"domutils@npm:^3.1.0": + version: 3.2.2 + resolution: "domutils@npm:3.2.2" + dependencies: + dom-serializer: "npm:^2.0.0" + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.3" + checksum: 10/2e08842151aa406f50fe5e6d494f4ec73c2373199fa00d1f77b56ec604e566b7f226312ae35ab8160bb7f27a27c7285d574c8044779053e499282ca9198be210 + languageName: node + linkType: hard + "dot-case@npm:^3.0.4": version: 3.0.4 resolution: "dot-case@npm:3.0.4" @@ -16097,7 +18647,7 @@ __metadata: languageName: node linkType: hard -"dunder-proto@npm:^1.0.0": +"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" dependencies: @@ -16184,6 +18734,20 @@ __metadata: languageName: node linkType: hard +"electron-to-chromium@npm:^1.5.160": + version: 1.5.161 + resolution: "electron-to-chromium@npm:1.5.161" + checksum: 10/cac9497669736407a8b156658986b5afb616e7a8fdc9aca41b8709bad541b48823dd5a5f0e07dee4149a1b5f7021e2d0443cdd0c475f7c67c4af171afe43bc9d + languageName: node + linkType: hard + +"electron-to-chromium@npm:^1.5.41": + version: 1.5.71 + resolution: "electron-to-chromium@npm:1.5.71" + checksum: 10/feb1655236b9de715f837e07e4bcb49b199f20ce7b6b8ab8e7e4120220bcb85b96227af72cb5bbed88ff3cb7c09110fe8b04be9504a34a24694fc2e630a64704 + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.5.73": version: 1.5.74 resolution: "electron-to-chromium@npm:1.5.74" @@ -16262,6 +18826,16 @@ __metadata: languageName: node linkType: hard +"encoding-sniffer@npm:^0.2.0": + version: 0.2.0 + resolution: "encoding-sniffer@npm:0.2.0" + dependencies: + iconv-lite: "npm:^0.6.3" + whatwg-encoding: "npm:^3.1.1" + checksum: 10/fe61a759dbef4d94ddc6f4fa645459897f4275eba04f0135d0459099b5f62fbba8a7ae57d23c9ec9b118c4c39ce056b51f1b8e62ad73a8ab365699448d655f4c + languageName: node + linkType: hard + "encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" @@ -16452,6 +19026,68 @@ __metadata: languageName: node linkType: hard +"es-abstract@npm:^1.23.6, es-abstract@npm:^1.23.9": + version: 1.24.0 + resolution: "es-abstract@npm:1.24.0" + dependencies: + array-buffer-byte-length: "npm:^1.0.2" + arraybuffer.prototype.slice: "npm:^1.0.4" + available-typed-arrays: "npm:^1.0.7" + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.4" + data-view-buffer: "npm:^1.0.2" + data-view-byte-length: "npm:^1.0.2" + data-view-byte-offset: "npm:^1.0.1" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" + es-set-tostringtag: "npm:^2.1.0" + es-to-primitive: "npm:^1.3.0" + function.prototype.name: "npm:^1.1.8" + get-intrinsic: "npm:^1.3.0" + get-proto: "npm:^1.0.1" + get-symbol-description: "npm:^1.1.0" + globalthis: "npm:^1.0.4" + gopd: "npm:^1.2.0" + has-property-descriptors: "npm:^1.0.2" + has-proto: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + internal-slot: "npm:^1.1.0" + is-array-buffer: "npm:^3.0.5" + is-callable: "npm:^1.2.7" + is-data-view: "npm:^1.0.2" + is-negative-zero: "npm:^2.0.3" + is-regex: "npm:^1.2.1" + is-set: "npm:^2.0.3" + is-shared-array-buffer: "npm:^1.0.4" + is-string: "npm:^1.1.1" + is-typed-array: "npm:^1.1.15" + is-weakref: "npm:^1.1.1" + math-intrinsics: "npm:^1.1.0" + object-inspect: "npm:^1.13.4" + object-keys: "npm:^1.1.1" + object.assign: "npm:^4.1.7" + own-keys: "npm:^1.0.1" + regexp.prototype.flags: "npm:^1.5.4" + safe-array-concat: "npm:^1.1.3" + safe-push-apply: "npm:^1.0.0" + safe-regex-test: "npm:^1.1.0" + set-proto: "npm:^1.0.0" + stop-iteration-iterator: "npm:^1.1.0" + string.prototype.trim: "npm:^1.2.10" + string.prototype.trimend: "npm:^1.0.9" + string.prototype.trimstart: "npm:^1.0.8" + typed-array-buffer: "npm:^1.0.3" + typed-array-byte-length: "npm:^1.0.3" + typed-array-byte-offset: "npm:^1.0.4" + typed-array-length: "npm:^1.0.7" + unbox-primitive: "npm:^1.1.0" + which-typed-array: "npm:^1.1.19" + checksum: 10/64e07a886f7439cf5ccfc100f9716e6173e10af6071a50a5031afbdde474a3dbc9619d5965da54e55f8908746a9134a46be02af8c732d574b7b81ed3124e2daf + languageName: node + linkType: hard + "es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1": version: 1.0.1 resolution: "es-define-property@npm:1.0.1" @@ -16489,6 +19125,30 @@ __metadata: languageName: node linkType: hard +"es-iterator-helpers@npm:^1.2.1": + version: 1.2.1 + resolution: "es-iterator-helpers@npm:1.2.1" + dependencies: + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.3" + define-properties: "npm:^1.2.1" + es-abstract: "npm:^1.23.6" + es-errors: "npm:^1.3.0" + es-set-tostringtag: "npm:^2.0.3" + function-bind: "npm:^1.1.2" + get-intrinsic: "npm:^1.2.6" + globalthis: "npm:^1.0.4" + gopd: "npm:^1.2.0" + has-property-descriptors: "npm:^1.0.2" + has-proto: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + internal-slot: "npm:^1.1.0" + iterator.prototype: "npm:^1.1.4" + safe-array-concat: "npm:^1.1.3" + checksum: 10/802e0e8427a05ff4a5b0c70c7fdaaeff37cdb81a28694aeb7bfb831c6ab340d8f3deeb67b96732ff9e9699ea240524d5ea8a9a6a335fcd15aa3983b27b06113f + languageName: node + linkType: hard + "es-module-lexer@npm:^1.2.1, es-module-lexer@npm:^1.5.0": version: 1.5.4 resolution: "es-module-lexer@npm:1.5.4" @@ -16505,6 +19165,15 @@ __metadata: languageName: node linkType: hard +"es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" + dependencies: + es-errors: "npm:^1.3.0" + checksum: 10/54fe77de288451dae51c37bfbfe3ec86732dc3778f98f3eb3bdb4bf48063b2c0b8f9c93542656986149d08aa5be3204286e2276053d19582b76753f1a2728867 + languageName: node + linkType: hard + "es-set-tostringtag@npm:^2.0.3": version: 2.0.3 resolution: "es-set-tostringtag@npm:2.0.3" @@ -16516,6 +19185,18 @@ __metadata: languageName: node linkType: hard +"es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" + dependencies: + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.6" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10/86814bf8afbcd8966653f731415888019d4bc4aca6b6c354132a7a75bb87566751e320369654a101d23a91c87a85c79b178bcf40332839bd347aff437c4fb65f + languageName: node + linkType: hard + "es-shim-unscopables@npm:^1.0.2": version: 1.0.2 resolution: "es-shim-unscopables@npm:1.0.2" @@ -16536,6 +19217,30 @@ __metadata: languageName: node linkType: hard +"es-toolkit@npm:1.33.0": + version: 1.33.0 + resolution: "es-toolkit@npm:1.33.0" + dependenciesMeta: + "@trivago/prettier-plugin-sort-imports@4.3.0": + unplugged: true + prettier-plugin-sort-re-exports@0.0.1: + unplugged: true + checksum: 10/db613d885c407dc3b84b3939b8b0c9976f658bfb03fa0f9cd3a3fe8383a60a75e1e4f34584e86c3fbf00def50ea0ca5f1a5264a1014018286dedbed08426b5f0 + languageName: node + linkType: hard + +"es-toolkit@npm:1.39.3": + version: 1.39.3 + resolution: "es-toolkit@npm:1.39.3" + dependenciesMeta: + "@trivago/prettier-plugin-sort-imports@4.3.0": + unplugged: true + prettier-plugin-sort-re-exports@0.0.1: + unplugged: true + checksum: 10/18cf6dee69170f802a50d447cac3af0026c199b501fe9a151633a402ea0523463b73aacbde53072cc610914d68031e2856fbb8a305b020e34bd7f6ac24d37e6d + languageName: node + linkType: hard + "es5-ext@npm:^0.10.35, es5-ext@npm:^0.10.46, es5-ext@npm:^0.10.62, es5-ext@npm:^0.10.64, es5-ext@npm:~0.10.14, es5-ext@npm:~0.10.2": version: 0.10.64 resolution: "es5-ext@npm:0.10.64" @@ -16636,35 +19341,116 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0, esbuild@npm:^0.24.2": - version: 0.24.2 - resolution: "esbuild@npm:0.24.2" - dependencies: - "@esbuild/aix-ppc64": "npm:0.24.2" - "@esbuild/android-arm": "npm:0.24.2" - "@esbuild/android-arm64": "npm:0.24.2" - "@esbuild/android-x64": "npm:0.24.2" - "@esbuild/darwin-arm64": "npm:0.24.2" - "@esbuild/darwin-x64": "npm:0.24.2" - "@esbuild/freebsd-arm64": "npm:0.24.2" - "@esbuild/freebsd-x64": "npm:0.24.2" - "@esbuild/linux-arm": "npm:0.24.2" - "@esbuild/linux-arm64": "npm:0.24.2" - "@esbuild/linux-ia32": "npm:0.24.2" - "@esbuild/linux-loong64": "npm:0.24.2" - "@esbuild/linux-mips64el": "npm:0.24.2" - "@esbuild/linux-ppc64": "npm:0.24.2" - "@esbuild/linux-riscv64": "npm:0.24.2" - "@esbuild/linux-s390x": "npm:0.24.2" - "@esbuild/linux-x64": "npm:0.24.2" - "@esbuild/netbsd-arm64": "npm:0.24.2" - "@esbuild/netbsd-x64": "npm:0.24.2" - "@esbuild/openbsd-arm64": "npm:0.24.2" - "@esbuild/openbsd-x64": "npm:0.24.2" - "@esbuild/sunos-x64": "npm:0.24.2" - "@esbuild/win32-arm64": "npm:0.24.2" - "@esbuild/win32-ia32": "npm:0.24.2" - "@esbuild/win32-x64": "npm:0.24.2" +"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0": + version: 0.24.0 + resolution: "esbuild@npm:0.24.0" + dependencies: + "@esbuild/aix-ppc64": "npm:0.24.0" + "@esbuild/android-arm": "npm:0.24.0" + "@esbuild/android-arm64": "npm:0.24.0" + "@esbuild/android-x64": "npm:0.24.0" + "@esbuild/darwin-arm64": "npm:0.24.0" + "@esbuild/darwin-x64": "npm:0.24.0" + "@esbuild/freebsd-arm64": "npm:0.24.0" + "@esbuild/freebsd-x64": "npm:0.24.0" + "@esbuild/linux-arm": "npm:0.24.0" + "@esbuild/linux-arm64": "npm:0.24.0" + "@esbuild/linux-ia32": "npm:0.24.0" + "@esbuild/linux-loong64": "npm:0.24.0" + "@esbuild/linux-mips64el": "npm:0.24.0" + "@esbuild/linux-ppc64": "npm:0.24.0" + "@esbuild/linux-riscv64": "npm:0.24.0" + "@esbuild/linux-s390x": "npm:0.24.0" + "@esbuild/linux-x64": "npm:0.24.0" + "@esbuild/netbsd-x64": "npm:0.24.0" + "@esbuild/openbsd-arm64": "npm:0.24.0" + "@esbuild/openbsd-x64": "npm:0.24.0" + "@esbuild/sunos-x64": "npm:0.24.0" + "@esbuild/win32-arm64": "npm:0.24.0" + "@esbuild/win32-ia32": "npm:0.24.0" + "@esbuild/win32-x64": "npm:0.24.0" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10/500f83a1216d6548053007b85c070d8293395db344605b17418c6cf1217e5e8d338fa77fc8af27c23faa121c5528e5b0004d46d3a0cdeb87d48f1b5fa0164bc5 + languageName: node + linkType: hard + +"esbuild@npm:^0.19.2": + version: 0.19.12 + resolution: "esbuild@npm:0.19.12" + dependencies: + "@esbuild/aix-ppc64": "npm:0.19.12" + "@esbuild/android-arm": "npm:0.19.12" + "@esbuild/android-arm64": "npm:0.19.12" + "@esbuild/android-x64": "npm:0.19.12" + "@esbuild/darwin-arm64": "npm:0.19.12" + "@esbuild/darwin-x64": "npm:0.19.12" + "@esbuild/freebsd-arm64": "npm:0.19.12" + "@esbuild/freebsd-x64": "npm:0.19.12" + "@esbuild/linux-arm": "npm:0.19.12" + "@esbuild/linux-arm64": "npm:0.19.12" + "@esbuild/linux-ia32": "npm:0.19.12" + "@esbuild/linux-loong64": "npm:0.19.12" + "@esbuild/linux-mips64el": "npm:0.19.12" + "@esbuild/linux-ppc64": "npm:0.19.12" + "@esbuild/linux-riscv64": "npm:0.19.12" + "@esbuild/linux-s390x": "npm:0.19.12" + "@esbuild/linux-x64": "npm:0.19.12" + "@esbuild/netbsd-x64": "npm:0.19.12" + "@esbuild/openbsd-x64": "npm:0.19.12" + "@esbuild/sunos-x64": "npm:0.19.12" + "@esbuild/win32-arm64": "npm:0.19.12" + "@esbuild/win32-ia32": "npm:0.19.12" + "@esbuild/win32-x64": "npm:0.19.12" dependenciesMeta: "@esbuild/aix-ppc64": optional: true @@ -16700,12 +19486,8 @@ __metadata: optional: true "@esbuild/linux-x64": optional: true - "@esbuild/netbsd-arm64": - optional: true "@esbuild/netbsd-x64": optional: true - "@esbuild/openbsd-arm64": - optional: true "@esbuild/openbsd-x64": optional: true "@esbuild/sunos-x64": @@ -16718,37 +19500,39 @@ __metadata: optional: true bin: esbuild: bin/esbuild - checksum: 10/95425071c9f24ff88bf61e0710b636ec0eb24ddf8bd1f7e1edef3044e1221104bbfa7bbb31c18018c8c36fa7902c5c0b843f829b981ebc89160cf5eebdaa58f4 + checksum: 10/861fa8eb2428e8d6521a4b7c7930139e3f45e8d51a86985cc29408172a41f6b18df7b3401e7e5e2d528cdf83742da601ddfdc77043ddc4f1c715a8ddb2d8a255 languageName: node linkType: hard -"esbuild@npm:^0.19.2": - version: 0.19.12 - resolution: "esbuild@npm:0.19.12" - dependencies: - "@esbuild/aix-ppc64": "npm:0.19.12" - "@esbuild/android-arm": "npm:0.19.12" - "@esbuild/android-arm64": "npm:0.19.12" - "@esbuild/android-x64": "npm:0.19.12" - "@esbuild/darwin-arm64": "npm:0.19.12" - "@esbuild/darwin-x64": "npm:0.19.12" - "@esbuild/freebsd-arm64": "npm:0.19.12" - "@esbuild/freebsd-x64": "npm:0.19.12" - "@esbuild/linux-arm": "npm:0.19.12" - "@esbuild/linux-arm64": "npm:0.19.12" - "@esbuild/linux-ia32": "npm:0.19.12" - "@esbuild/linux-loong64": "npm:0.19.12" - "@esbuild/linux-mips64el": "npm:0.19.12" - "@esbuild/linux-ppc64": "npm:0.19.12" - "@esbuild/linux-riscv64": "npm:0.19.12" - "@esbuild/linux-s390x": "npm:0.19.12" - "@esbuild/linux-x64": "npm:0.19.12" - "@esbuild/netbsd-x64": "npm:0.19.12" - "@esbuild/openbsd-x64": "npm:0.19.12" - "@esbuild/sunos-x64": "npm:0.19.12" - "@esbuild/win32-arm64": "npm:0.19.12" - "@esbuild/win32-ia32": "npm:0.19.12" - "@esbuild/win32-x64": "npm:0.19.12" +"esbuild@npm:^0.25.0": + version: 0.25.3 + resolution: "esbuild@npm:0.25.3" + dependencies: + "@esbuild/aix-ppc64": "npm:0.25.3" + "@esbuild/android-arm": "npm:0.25.3" + "@esbuild/android-arm64": "npm:0.25.3" + "@esbuild/android-x64": "npm:0.25.3" + "@esbuild/darwin-arm64": "npm:0.25.3" + "@esbuild/darwin-x64": "npm:0.25.3" + "@esbuild/freebsd-arm64": "npm:0.25.3" + "@esbuild/freebsd-x64": "npm:0.25.3" + "@esbuild/linux-arm": "npm:0.25.3" + "@esbuild/linux-arm64": "npm:0.25.3" + "@esbuild/linux-ia32": "npm:0.25.3" + "@esbuild/linux-loong64": "npm:0.25.3" + "@esbuild/linux-mips64el": "npm:0.25.3" + "@esbuild/linux-ppc64": "npm:0.25.3" + "@esbuild/linux-riscv64": "npm:0.25.3" + "@esbuild/linux-s390x": "npm:0.25.3" + "@esbuild/linux-x64": "npm:0.25.3" + "@esbuild/netbsd-arm64": "npm:0.25.3" + "@esbuild/netbsd-x64": "npm:0.25.3" + "@esbuild/openbsd-arm64": "npm:0.25.3" + "@esbuild/openbsd-x64": "npm:0.25.3" + "@esbuild/sunos-x64": "npm:0.25.3" + "@esbuild/win32-arm64": "npm:0.25.3" + "@esbuild/win32-ia32": "npm:0.25.3" + "@esbuild/win32-x64": "npm:0.25.3" dependenciesMeta: "@esbuild/aix-ppc64": optional: true @@ -16784,8 +19568,12 @@ __metadata: optional: true "@esbuild/linux-x64": optional: true + "@esbuild/netbsd-arm64": + optional: true "@esbuild/netbsd-x64": optional: true + "@esbuild/openbsd-arm64": + optional: true "@esbuild/openbsd-x64": optional: true "@esbuild/sunos-x64": @@ -16798,7 +19586,7 @@ __metadata: optional: true bin: esbuild: bin/esbuild - checksum: 10/861fa8eb2428e8d6521a4b7c7930139e3f45e8d51a86985cc29408172a41f6b18df7b3401e7e5e2d528cdf83742da601ddfdc77043ddc4f1c715a8ddb2d8a255 + checksum: 10/f1ff72289938330312926421f90eea442025cbbac295a7a2e8cfc2abbd9e3a8bc1502883468b0487e4020f1369e4726c851a2fa4b65a7c71331940072c3a1808 languageName: node linkType: hard @@ -16862,6 +19650,17 @@ __metadata: languageName: node linkType: hard +"eslint-compat-utils@npm:^0.5.1": + version: 0.5.1 + resolution: "eslint-compat-utils@npm:0.5.1" + dependencies: + semver: "npm:^7.5.4" + peerDependencies: + eslint: ">=6.0.0" + checksum: 10/ac65ac1c6107cf19f63f5fc17cea361c9cb1336be7356f23dbb0fac10979974b4622e13e950be43cbf431801f2c07f7dab448573181ccf6edc0b86d5b5304511 + languageName: node + linkType: hard + "eslint-config-next@npm:^15.0.4": version: 15.1.1 resolution: "eslint-config-next@npm:15.1.1" @@ -16897,6 +19696,30 @@ __metadata: languageName: node linkType: hard +"eslint-config-universe@npm:^15.0.3": + version: 15.0.3 + resolution: "eslint-config-universe@npm:15.0.3" + dependencies: + "@typescript-eslint/eslint-plugin": "npm:^8.29.1" + "@typescript-eslint/parser": "npm:^8.29.1" + eslint-config-prettier: "npm:^9.1.0" + eslint-plugin-import: "npm:^2.31.0" + eslint-plugin-n: "npm:^17.17.0" + eslint-plugin-node: "npm:^11.1.0" + eslint-plugin-prettier: "npm:^5.2.6" + eslint-plugin-react: "npm:^7.37.5" + eslint-plugin-react-hooks: "npm:^5.2.0" + globals: "npm:^16.0.0" + peerDependencies: + eslint: ">=8.10" + prettier: ">=3" + peerDependenciesMeta: + prettier: + optional: true + checksum: 10/750e335617b95777c86c46a807d3536c4459b077a50a427c2d9cd13459a0ba38bb6f5af1f3227b7bb50be69557f053da4ffaf476eebf9fa60423ac79799e76ef + languageName: node + linkType: hard + "eslint-import-resolver-node@npm:^0.3.6, eslint-import-resolver-node@npm:^0.3.9": version: 0.3.9 resolution: "eslint-import-resolver-node@npm:0.3.9" @@ -16945,6 +19768,31 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-es-x@npm:^7.8.0": + version: 7.8.0 + resolution: "eslint-plugin-es-x@npm:7.8.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.1.2" + "@eslint-community/regexpp": "npm:^4.11.0" + eslint-compat-utils: "npm:^0.5.1" + peerDependencies: + eslint: ">=8" + checksum: 10/1df8d52c4fadc06854ce801af05b05f2642aa2deb918fb7d37738596eabd70b7f21a22b150b78ec9104bac6a1b6b4fb796adea2364ede91b01d20964849ce5f7 + languageName: node + linkType: hard + +"eslint-plugin-es@npm:^3.0.0": + version: 3.0.1 + resolution: "eslint-plugin-es@npm:3.0.1" + dependencies: + eslint-utils: "npm:^2.0.0" + regexpp: "npm:^3.0.0" + peerDependencies: + eslint: ">=4.19.1" + checksum: 10/9814e6305183edfdff7d99cbc0f95f0aed1446045cbd1d4f28e7be0903d0013880f0aaf04486a27de96bfb2f5a746bea97cbb238f9b0035cb378d48d179a0a1b + languageName: node + linkType: hard + "eslint-plugin-import@npm:^2.31.0": version: 2.31.0 resolution: "eslint-plugin-import@npm:2.31.0" @@ -16999,6 +19847,26 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-n@npm:^17.17.0": + version: 17.19.0 + resolution: "eslint-plugin-n@npm:17.19.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.5.0" + "@typescript-eslint/utils": "npm:^8.26.1" + enhanced-resolve: "npm:^5.17.1" + eslint-plugin-es-x: "npm:^7.8.0" + get-tsconfig: "npm:^4.8.1" + globals: "npm:^15.11.0" + ignore: "npm:^5.3.2" + minimatch: "npm:^9.0.5" + semver: "npm:^7.6.3" + ts-declaration-location: "npm:^1.0.6" + peerDependencies: + eslint: ">=8.23.0" + checksum: 10/cc3042d72e99a386311b2d3b4dded25cc612e553760ea07883b4a1bd7b964dfe1dfa30d184b2b6abd74d8532ca1f0fc7f00b6c10808f30166fd9e4641a90f5f6 + languageName: node + linkType: hard + "eslint-plugin-no-only-tests@npm:^3.3.0": version: 3.3.0 resolution: "eslint-plugin-no-only-tests@npm:3.3.0" @@ -17006,6 +19874,22 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-node@npm:^11.1.0": + version: 11.1.0 + resolution: "eslint-plugin-node@npm:11.1.0" + dependencies: + eslint-plugin-es: "npm:^3.0.0" + eslint-utils: "npm:^2.0.0" + ignore: "npm:^5.1.1" + minimatch: "npm:^3.0.4" + resolve: "npm:^1.10.1" + semver: "npm:^6.1.0" + peerDependencies: + eslint: ">=5.16.0" + checksum: 10/bda540f390a84d835989f21f56743f3aa8f41fd9b53359d635c116632c86af92d70d8e6449ddd18860e6241f9cef04fc90c37eb192a9047c3c3a46de6145c30c + languageName: node + linkType: hard + "eslint-plugin-prettier@npm:^5.2.1": version: 5.2.1 resolution: "eslint-plugin-prettier@npm:5.2.1" @@ -17026,6 +19910,26 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-prettier@npm:^5.2.6": + version: 5.4.1 + resolution: "eslint-plugin-prettier@npm:5.4.1" + dependencies: + prettier-linter-helpers: "npm:^1.0.0" + synckit: "npm:^0.11.7" + peerDependencies: + "@types/eslint": ">=8.0.0" + eslint: ">=8.0.0" + eslint-config-prettier: ">= 7.0.0 <10.0.0 || >=10.1.0" + prettier: ">=3.0.0" + peerDependenciesMeta: + "@types/eslint": + optional: true + eslint-config-prettier: + optional: true + checksum: 10/ad7b1efa996cbd419cbb881182c58bce1c6c35d1cf30186c0e330437c4e551da4599b8c8aa7464b27d43fb57990837698ebaa9d238ba084dc1410f6caf31c000 + languageName: node + linkType: hard + "eslint-plugin-react-hooks@npm:^5.0.0": version: 5.1.0 resolution: "eslint-plugin-react-hooks@npm:5.1.0" @@ -17035,6 +19939,15 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-react-hooks@npm:^5.2.0": + version: 5.2.0 + resolution: "eslint-plugin-react-hooks@npm:5.2.0" + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + checksum: 10/ebb79e9cf69ae06e3a7876536653c5e556b5fd8cd9dc49577f10a6e728360e7b6f5ce91f4339b33e93b26e3bb23805418f8b5e75db80baddd617b1dffe73bed1 + languageName: node + linkType: hard + "eslint-plugin-react@npm:^7.37.0, eslint-plugin-react@npm:^7.37.1": version: 7.37.2 resolution: "eslint-plugin-react@npm:7.37.2" @@ -17063,6 +19976,34 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-react@npm:^7.37.5": + version: 7.37.5 + resolution: "eslint-plugin-react@npm:7.37.5" + dependencies: + array-includes: "npm:^3.1.8" + array.prototype.findlast: "npm:^1.2.5" + array.prototype.flatmap: "npm:^1.3.3" + array.prototype.tosorted: "npm:^1.1.4" + doctrine: "npm:^2.1.0" + es-iterator-helpers: "npm:^1.2.1" + estraverse: "npm:^5.3.0" + hasown: "npm:^2.0.2" + jsx-ast-utils: "npm:^2.4.1 || ^3.0.0" + minimatch: "npm:^3.1.2" + object.entries: "npm:^1.1.9" + object.fromentries: "npm:^2.0.8" + object.values: "npm:^1.2.1" + prop-types: "npm:^15.8.1" + resolve: "npm:^2.0.0-next.5" + semver: "npm:^6.3.1" + string.prototype.matchall: "npm:^4.0.12" + string.prototype.repeat: "npm:^1.0.0" + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + checksum: 10/ee1bd4e0ec64f29109d5a625bb703d179c82e0159c86c3f1b52fc1209d2994625a137dae303c333fb308a2e38315e44066d5204998177e31974382f9fda25d5c + languageName: node + linkType: hard + "eslint-plugin-storybook@npm:^0.11.0": version: 0.11.1 resolution: "eslint-plugin-storybook@npm:0.11.1" @@ -17099,13 +20040,29 @@ __metadata: languageName: node linkType: hard -"eslint-scope@npm:^8.2.0": - version: 8.2.0 - resolution: "eslint-scope@npm:8.2.0" +"eslint-scope@npm:^8.4.0": + version: 8.4.0 + resolution: "eslint-scope@npm:8.4.0" dependencies: esrecurse: "npm:^4.3.0" estraverse: "npm:^5.2.0" - checksum: 10/cd9ab60d5a68f3a0fcac04d1cff5a7383d0f331964d5f1c446259123caec5b3ccc542284d07846e4f4d1389da77750821cc9a6e1ce18558c674977351666f9a6 + checksum: 10/e8e611701f65375e034c62123946e628894f0b54aa8cb11abe224816389abe5cd74cf16b62b72baa36504f22d1a958b9b8b0169b82397fe2e7997674c0d09b06 + languageName: node + linkType: hard + +"eslint-utils@npm:^2.0.0": + version: 2.1.0 + resolution: "eslint-utils@npm:2.1.0" + dependencies: + eslint-visitor-keys: "npm:^1.1.0" + checksum: 10/a7e43a5154a16a90c021cabeb160c3668cccbcf6474ccb2a7d7762698582398f3b938c5330909b858ef7c21182edfc9786dbf89ed7b294f51b7659a378bf7cec + languageName: node + linkType: hard + +"eslint-visitor-keys@npm:^1.1.0": + version: 1.3.0 + resolution: "eslint-visitor-keys@npm:1.3.0" + checksum: 10/595ab230e0fcb52f86ba0986a9a473b9fcae120f3729b43f1157f88f27f8addb1e545c4e3d444185f2980e281ca15be5ada6f65b4599eec227cf30e41233b762 languageName: node linkType: hard @@ -17123,20 +20080,28 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.19.0": - version: 9.19.0 - resolution: "eslint@npm:9.19.0" +"eslint-visitor-keys@npm:^4.2.1": + version: 4.2.1 + resolution: "eslint-visitor-keys@npm:4.2.1" + checksum: 10/3ee00fc6a7002d4b0ffd9dc99e13a6a7882c557329e6c25ab254220d71e5c9c4f89dca4695352949ea678eb1f3ba912a18ef8aac0a7fe094196fd92f441bfce2 + languageName: node + linkType: hard + +"eslint@npm:^9.29.0": + version: 9.29.0 + resolution: "eslint@npm:9.29.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.2.0" "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.19.0" - "@eslint/core": "npm:^0.10.0" - "@eslint/eslintrc": "npm:^3.2.0" - "@eslint/js": "npm:9.19.0" - "@eslint/plugin-kit": "npm:^0.2.5" + "@eslint/config-array": "npm:^0.20.1" + "@eslint/config-helpers": "npm:^0.2.1" + "@eslint/core": "npm:^0.14.0" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:9.29.0" + "@eslint/plugin-kit": "npm:^0.3.1" "@humanfs/node": "npm:^0.16.6" "@humanwhocodes/module-importer": "npm:^1.0.1" - "@humanwhocodes/retry": "npm:^0.4.1" + "@humanwhocodes/retry": "npm:^0.4.2" "@types/estree": "npm:^1.0.6" "@types/json-schema": "npm:^7.0.15" ajv: "npm:^6.12.4" @@ -17144,9 +20109,9 @@ __metadata: cross-spawn: "npm:^7.0.6" debug: "npm:^4.3.2" escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^8.2.0" - eslint-visitor-keys: "npm:^4.2.0" - espree: "npm:^10.3.0" + eslint-scope: "npm:^8.4.0" + eslint-visitor-keys: "npm:^4.2.1" + espree: "npm:^10.4.0" esquery: "npm:^1.5.0" esutils: "npm:^2.0.2" fast-deep-equal: "npm:^3.1.3" @@ -17168,7 +20133,7 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 10/850d19fd6a34702d1e3d9bdad6aef84a20a5c2de006a8fa6380843384b13944b180232ddd74b8725ffcdf8f296399037f0e8eb4783d5f7393f13c059112b843d + checksum: 10/be0c8e123207c9d653fb75ddc610b85dfbf295a2bfa1cbecc78f191dcba9c421525b5befd5d499ce561eca607c9c33f455e4fff0b1c2d4202c2896dafe95094a languageName: node linkType: hard @@ -17184,7 +20149,7 @@ __metadata: languageName: node linkType: hard -"espree@npm:^10.0.1, espree@npm:^10.3.0": +"espree@npm:^10.0.1": version: 10.3.0 resolution: "espree@npm:10.3.0" dependencies: @@ -17195,6 +20160,17 @@ __metadata: languageName: node linkType: hard +"espree@npm:^10.4.0": + version: 10.4.0 + resolution: "espree@npm:10.4.0" + dependencies: + acorn: "npm:^8.15.0" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10/9b355b32dbd1cc9f57121d5ee3be258fab87ebeb7c83fc6c02e5af1a74fc8c5ba79fe8c663e69ea112c3e84a1b95e6a2067ac4443ee7813bb85ac7581acb8bf9 + languageName: node + linkType: hard + "esprima@npm:^4.0.0, esprima@npm:^4.0.1, esprima@npm:~4.0.0": version: 4.0.1 resolution: "esprima@npm:4.0.1" @@ -17287,11 +20263,11 @@ __metadata: linkType: hard "estree-util-value-to-estree@npm:^3.0.0": - version: 3.2.1 - resolution: "estree-util-value-to-estree@npm:3.2.1" + version: 3.4.0 + resolution: "estree-util-value-to-estree@npm:3.4.0" dependencies: "@types/estree": "npm:^1.0.0" - checksum: 10/5ab0d7590ca8f3e929b376ecbf9d1ef3a71c01b4625622ef8bcb1fffb4727d0080b115a642f08f74cb98f9852487411064d615719868a564ec7d5a232308311e + checksum: 10/4fdb101cba7e3c8a2aaf1881c0c169218addaea0b6101e3de344c137663e4db8887da7fccd0c96939340aa13679af085a07cca05ee168d61a5fb783054bdffe5 languageName: node linkType: hard @@ -17365,7 +20341,7 @@ __metadata: languageName: node linkType: hard -"ethereumjs-util@npm:^7.1.5": +"ethereumjs-util@npm:^7.1.4": version: 7.1.5 resolution: "ethereumjs-util@npm:7.1.5" dependencies: @@ -17378,9 +20354,9 @@ __metadata: languageName: node linkType: hard -"ethers@npm:5.5.3": - version: 5.5.3 - resolution: "ethers@npm:5.5.3" +"ethers@npm:5.5.4": + version: 5.5.4 + resolution: "ethers@npm:5.5.4" dependencies: "@ethersproject/abi": "npm:5.5.0" "@ethersproject/abstract-provider": "npm:5.5.1" @@ -17400,7 +20376,7 @@ __metadata: "@ethersproject/networks": "npm:5.5.2" "@ethersproject/pbkdf2": "npm:5.5.0" "@ethersproject/properties": "npm:5.5.0" - "@ethersproject/providers": "npm:5.5.2" + "@ethersproject/providers": "npm:5.5.3" "@ethersproject/random": "npm:5.5.1" "@ethersproject/rlp": "npm:5.5.0" "@ethersproject/sha2": "npm:5.5.0" @@ -17412,13 +20388,13 @@ __metadata: "@ethersproject/wallet": "npm:5.5.0" "@ethersproject/web": "npm:5.5.1" "@ethersproject/wordlists": "npm:5.5.0" - checksum: 10/75412f0dd60f0a21c2e8ade37781b6f67a84377d0b5452cf4c4d73596e6c2626966f666d491e53c55e124621ce3b72edf0001c683f2ef9ef08da79b5a3a02ace + checksum: 10/68ff64fe9b5b37362c0d811499262a0ff68dd23cdb2d6f27b48d42c64f713f40ecdc06e3d52da2e6514bce42dc0e7df2b815bfb41a3895fde4fa8c4b9c01cec9 languageName: node linkType: hard -"ethers@npm:6.13.4, ethers@npm:^6.13.4": - version: 6.13.4 - resolution: "ethers@npm:6.13.4" +"ethers@npm:6.14.3, ethers@npm:^6.14.3": + version: 6.14.3 + resolution: "ethers@npm:6.14.3" dependencies: "@adraffy/ens-normalize": "npm:1.10.1" "@noble/curves": "npm:1.2.0" @@ -17427,7 +20403,7 @@ __metadata: aes-js: "npm:4.0.0-beta.5" tslib: "npm:2.7.0" ws: "npm:8.17.1" - checksum: 10/221192fed93f6b0553f3e5e72bfd667d676220577d34ff854f677e955d6f608e60636a9c08b5d54039c532a9b9b7056384f0d7019eb6e111d53175806f896ac6 + checksum: 10/ce68b962f117fd651090bd8096fde708428ce23f0d044c365bc8cbf2e8f3cb70e1661ff0194364848ccac06d607d4a977f2a0c9e370b9712809c0ca6c36131f9 languageName: node linkType: hard @@ -17511,22 +20487,7 @@ __metadata: languageName: node linkType: hard -"execa@npm:^1.0.0": - version: 1.0.0 - resolution: "execa@npm:1.0.0" - dependencies: - cross-spawn: "npm:^6.0.0" - get-stream: "npm:^4.0.0" - is-stream: "npm:^1.1.0" - npm-run-path: "npm:^2.0.0" - p-finally: "npm:^1.0.0" - signal-exit: "npm:^3.0.0" - strip-eof: "npm:^1.0.0" - checksum: 10/9b7a0077ba9d0ecdd41bf2d8644f83abf736e37622e3d1af39dec9d5f2cfa6bf8263301d0df489688dda3873d877f4168c01172cbafed5fffd12c808983515b0 - languageName: node - linkType: hard - -"execa@npm:^5.0.0, execa@npm:^5.1.1": +"execa@npm:^5.0.0": version: 5.1.1 resolution: "execa@npm:5.1.1" dependencies: @@ -17589,134 +20550,209 @@ __metadata: languageName: node linkType: hard -"expo-asset@npm:~11.0.1": - version: 11.0.1 - resolution: "expo-asset@npm:11.0.1" +"expo-application@npm:~6.1.4": + version: 6.1.4 + resolution: "expo-application@npm:6.1.4" + peerDependencies: + expo: "*" + checksum: 10/9f25eceea43cc93c7f1add49c2d9d510b4fe682b7c5669f976bb8061de66090c45fad049a84f5ae098ddcd428222631984c80387e58df2d5fab674d010f96c4c + languageName: node + linkType: hard + +"expo-asset@npm:~11.1.5": + version: 11.1.5 + resolution: "expo-asset@npm:11.1.5" dependencies: - "@expo/image-utils": "npm:^0.6.0" - expo-constants: "npm:~17.0.0" - invariant: "npm:^2.2.4" - md5-file: "npm:^3.2.3" + "@expo/image-utils": "npm:^0.7.4" + expo-constants: "npm:~17.1.5" peerDependencies: expo: "*" react: "*" react-native: "*" - checksum: 10/1b1d28048e224d4899fe27e55e650a0a9a2cafb02afb3c2f1d58f4be032fdbaafebe8157dc734503ee7fc84e8f1aec3481aded9f61eaeeaecb4f4ab06d335524 + checksum: 10/105f2f384362d025e8808f8db384df18a0738d4ddc2f51becb69cdde896c421f773d008f9737997be9a18bc883f4dc5a54f14f0f7e9363383246620cc2e5c37a languageName: node linkType: hard -"expo-blur@npm:~14.0.1": - version: 14.0.1 - resolution: "expo-blur@npm:14.0.1" +"expo-blur@npm:~14.1.4": + version: 14.1.4 + resolution: "expo-blur@npm:14.1.4" peerDependencies: expo: "*" react: "*" react-native: "*" - checksum: 10/263fe270941b8d05792f266ac1101de34f89cd1cf69e54802bc97daf798a89d2c3bf9f72780840fd5ed0519a209f71845f4758b49757be0fdc67942fab7da477 + checksum: 10/62a405ca66a1e42bc76b19eea57d79ec6e6669b359ae3dbebb8219230b2e0afd08d732a8efe9ec05b22f909a8f0c89ed13d0e694d8802f39ee63226d42ab77d9 languageName: node linkType: hard -"expo-build-properties@npm:^0.13.2": - version: 0.13.2 - resolution: "expo-build-properties@npm:0.13.2" +"expo-build-properties@npm:~0.14.6": + version: 0.14.6 + resolution: "expo-build-properties@npm:0.14.6" dependencies: ajv: "npm:^8.11.0" semver: "npm:^7.6.0" peerDependencies: expo: "*" - checksum: 10/88a2b0f491112398314502cc51172d4695d62ed9998fa96d8630450a5451f5f9779e0737c997175502ab16a3eb0194efb1cac44be44d5bfa2025054e4e4ce1c7 + checksum: 10/6ac7be2e2093cce1b88051917dbc490b7cad4561cc3cf0b53644413411467c2739bad5cadfbcb0b44b9b767aa8b273ffe56e86ebdf7f1ee516b5318ee03f0cbf languageName: node linkType: hard -"expo-constants@npm:~17.0.0, expo-constants@npm:~17.0.3, expo-constants@npm:~17.0.4": - version: 17.0.4 - resolution: "expo-constants@npm:17.0.4" +"expo-constants@npm:~17.1.4, expo-constants@npm:~17.1.5": + version: 17.1.5 + resolution: "expo-constants@npm:17.1.5" dependencies: - "@expo/config": "npm:~10.0.8" - "@expo/env": "npm:~0.4.1" + "@expo/config": "npm:~11.0.7" + "@expo/env": "npm:~1.0.5" peerDependencies: expo: "*" react-native: "*" - checksum: 10/fcaa389f82cfcaecb847edae3e3f4f9c6c7cc2a14ed16b7c16b279af4a996fca7f8475afa825076c480a2b8edd574c953dd1c20967e5e5be4d8f580ae50e3893 + checksum: 10/f4367e3807a2f675a7ae7999a7b69d949a8e35be5dd182241b50ed915f80b9e7a457941ab922a1e10a051801583c2ea2112e128e0584709ca6ea93653218f869 languageName: node linkType: hard -"expo-dev-client@npm:~5.0.5": - version: 5.0.5 - resolution: "expo-dev-client@npm:5.0.5" +"expo-dev-client@npm:~5.1.8": + version: 5.1.8 + resolution: "expo-dev-client@npm:5.1.8" + dependencies: + expo-dev-launcher: "npm:5.1.11" + expo-dev-menu: "npm:6.1.10" + expo-dev-menu-interface: "npm:1.10.0" + expo-manifests: "npm:~0.16.4" + expo-updates-interface: "npm:~1.1.0" + peerDependencies: + expo: "*" + checksum: 10/f641260cbccd87e3a495aa1bb9be701a58477347a077d720a5c4a1413eb3e4d4a82bccad0c0f18f4eb2c3f4506e75840d34d6d4462a6b5685e5197e3639168a7 + languageName: node + linkType: hard + +"expo-dev-client@npm:~5.2.1": + version: 5.2.3 + resolution: "expo-dev-client@npm:5.2.3" dependencies: - expo-dev-launcher: "npm:5.0.18" - expo-dev-menu: "npm:6.0.13" - expo-dev-menu-interface: "npm:1.9.2" - expo-manifests: "npm:~0.15.0" - expo-updates-interface: "npm:~1.0.0" + expo-dev-launcher: "npm:5.1.15" + expo-dev-menu: "npm:6.1.13" + expo-dev-menu-interface: "npm:1.10.0" + expo-manifests: "npm:~0.16.5" + expo-updates-interface: "npm:~1.1.0" peerDependencies: expo: "*" - checksum: 10/67ca16139e600f978c49ce63225afceaab51b284c85cf1115dea403df571b399a46a0f9c9fbb4894cbb6c872de518f0c49f1925f9f5e1c7f01516f50fa631ea0 + checksum: 10/d0f89bc1e83169114f519e0bca2aa216897b3af2e39236500a70c507a719fcd4ba3c45645b227687a6dcbac53d4e668ee2ca2a53ee63bfdf382e3bac252f37b2 languageName: node linkType: hard -"expo-dev-launcher@npm:5.0.18": - version: 5.0.18 - resolution: "expo-dev-launcher@npm:5.0.18" +"expo-dev-launcher@npm:5.1.11": + version: 5.1.11 + resolution: "expo-dev-launcher@npm:5.1.11" dependencies: ajv: "npm:8.11.0" - expo-dev-menu: "npm:6.0.13" - expo-manifests: "npm:~0.15.0" + expo-dev-menu: "npm:6.1.10" + expo-manifests: "npm:~0.16.4" resolve-from: "npm:^5.0.0" peerDependencies: expo: "*" - checksum: 10/ef200a3fcbb6e7e7d37c3af56aecafbaeff557100b4f2fa28acd902fce53212fe79d5b7ae7932cca2e4729f5f27e85f4e725d856cbccbec2d72825979f6ba4ac + checksum: 10/eebb6ca137aba96b7fea64838539201452a38a156acf439918c248295f951a01a36051124929d5467fcb9ede4355201d603bac0438676e485fdd86431c957fa5 languageName: node linkType: hard -"expo-dev-menu-interface@npm:1.9.2": - version: 1.9.2 - resolution: "expo-dev-menu-interface@npm:1.9.2" +"expo-dev-launcher@npm:5.1.15": + version: 5.1.15 + resolution: "expo-dev-launcher@npm:5.1.15" + dependencies: + ajv: "npm:8.11.0" + expo-dev-menu: "npm:6.1.13" + expo-manifests: "npm:~0.16.5" + resolve-from: "npm:^5.0.0" + peerDependencies: + expo: "*" + checksum: 10/c28a380c4320c36b3af669750750a78842452362fdd53c1bfec38a4b6373f9973ddba05253ae45068ee29515847aedd87b338d30d9bc894f84a27eec7b30f633 + languageName: node + linkType: hard + +"expo-dev-menu-interface@npm:1.10.0": + version: 1.10.0 + resolution: "expo-dev-menu-interface@npm:1.10.0" + peerDependencies: + expo: "*" + checksum: 10/f4713d4ff8545bfba58b68fa540542822da61ad64523f8f33ff274eb1779cdda86904f746a5c76dbfac99e062e3c0d1b37a306b82aff267567c188e1ff18f072 + languageName: node + linkType: hard + +"expo-dev-menu@npm:6.1.10": + version: 6.1.10 + resolution: "expo-dev-menu@npm:6.1.10" + dependencies: + expo-dev-menu-interface: "npm:1.10.0" peerDependencies: expo: "*" - checksum: 10/a65c95aadf959b0be9a83ba302d9ac3f4bffec6ad947cbeba2351fd1958a6d95470357ccfc659ba519364dd9564e6a8c32f66c3e9bb0239a717326680a35e3e5 + checksum: 10/a0c14d24dce7c87ab380a0ec817f115d709d646697997725e51eebd042955aa0e0085ae809612be87e9a0e0a49215f951587cb967e0fb07c1c599f95c2238a47 languageName: node linkType: hard -"expo-dev-menu@npm:6.0.13": - version: 6.0.13 - resolution: "expo-dev-menu@npm:6.0.13" +"expo-dev-menu@npm:6.1.13": + version: 6.1.13 + resolution: "expo-dev-menu@npm:6.1.13" dependencies: - expo-dev-menu-interface: "npm:1.9.2" + expo-dev-menu-interface: "npm:1.10.0" peerDependencies: expo: "*" - checksum: 10/36e0c1f9f67215eca7b9049778d4f0cc097800266301dcee370db012835be209f1d3b5d158efc9df653ae2d7a2a3fa27b2a46e02d7f89175281b5cd924dc259b + checksum: 10/5b855e9af712ca14a5572bccb677b669e5ccf70e7f7f00ce676ac64d6adffd1a06aeeef65a6ed5c971800dc29661e902e01a9e2ac5fd2324ffe26db8ae91d166 languageName: node linkType: hard -"expo-file-system@npm:~18.0.4": - version: 18.0.4 - resolution: "expo-file-system@npm:18.0.4" +"expo-device@npm:~7.1.4": + version: 7.1.4 + resolution: "expo-device@npm:7.1.4" dependencies: - web-streams-polyfill: "npm:^3.3.2" + ua-parser-js: "npm:^0.7.33" + peerDependencies: + expo: "*" + checksum: 10/7ac5d0af134c2dff036de55e412d89e2158a31fad76ff751c8de8a08c9411e448498b50e5c92c13bb2a9866f0508b2bc072e8f7fc7d974cdd769940744af8a67 + languageName: node + linkType: hard + +"expo-document-picker@npm:^13.1.5": + version: 13.1.5 + resolution: "expo-document-picker@npm:13.1.5" + peerDependencies: + expo: "*" + checksum: 10/a404cd1fbc6f187d94e7ec7bc549beed9231cd7ea91d3cf6cbb16ea202b2c30df9cfb804a834c14cc72d55ff4eec85387dd1647d13557ed1314a52ef204fef24 + languageName: node + linkType: hard + +"expo-file-system@npm:^18.1.10": + version: 18.1.10 + resolution: "expo-file-system@npm:18.1.10" peerDependencies: expo: "*" react-native: "*" - checksum: 10/cd4092f70224ca611936d0225491124d57c32dde9a515bb12f3d396bba1717cd16f0eeda3c7a721b29cf21412bfb3fb8bd8c5c7f78fcca226044d53be17a7fa3 + checksum: 10/b6f848df07ad04791f2ea20438bb9408cebb98a4ffc4e137298d78929df70aa67628a204c52416551359be3179a1e296380e936888a329a39c6f043f02288750 languageName: node linkType: hard -"expo-font@npm:~13.0.1, expo-font@npm:~13.0.3": - version: 13.0.3 - resolution: "expo-font@npm:13.0.3" +"expo-file-system@npm:~18.1.9": + version: 18.1.9 + resolution: "expo-file-system@npm:18.1.9" + peerDependencies: + expo: "*" + react-native: "*" + checksum: 10/753622e96d1be67946b0d01abf0063b126e7386df6833e558a3cecdb845300cb9effce8e527f0950be57b9cd108f2dd054c3354bfdf831d0daf1065d9902b96f + languageName: node + linkType: hard + +"expo-font@npm:~13.3.1": + version: 13.3.1 + resolution: "expo-font@npm:13.3.1" dependencies: fontfaceobserver: "npm:^2.1.0" peerDependencies: expo: "*" react: "*" - checksum: 10/2957b6923b6450f6ac18fa01610351d72376e60469001fb1ffe1f9b8f27a8f4e6753dd7ce6db56cabcee7d6d159f80321f66cc5c46e924f77ae0cefcdb147c7f + checksum: 10/47121c96a9d7fccc178f19fd6e6085c32eae2341c4e385cb3d21669d2bd8c9dfd0f9a0538058a2260ad6a2b3c6032946cfe3ddf8ee005228a452b4efbc61494e languageName: node linkType: hard -"expo-image@npm:~2.0.3": - version: 2.0.3 - resolution: "expo-image@npm:2.0.3" +"expo-image@npm:~2.1.6": + version: 2.1.6 + resolution: "expo-image@npm:2.1.6" peerDependencies: expo: "*" react: "*" @@ -17725,109 +20761,150 @@ __metadata: peerDependenciesMeta: react-native-web: optional: true - checksum: 10/b6fda7c18401f8f0bf5a255e2ebc0d565c31b89075bf6c7ea050ee2590aa8cc4dd20ba7daebeb36e0fdfb9190726717c91e47c185f9ccd43b7962c1744e4de6d + checksum: 10/e9a3607198025c77608a407b8834f84df4be5147f7c7d1ea206a1f116678a1d018fed77e71d42c46d49e23717d7072570e9aae8c32e2c6a77ae2f90d8c4439cb languageName: node linkType: hard -"expo-json-utils@npm:~0.14.0": - version: 0.14.0 - resolution: "expo-json-utils@npm:0.14.0" - checksum: 10/96fbfbe5cbef75dc742982b27eb55ca2e00c488fabc0877e01b707ab12ddf349cf9cda6e7f5459982bb24be5374ce6889ae628eb3041f1367d3672ba561900c9 +"expo-json-utils@npm:~0.15.0": + version: 0.15.0 + resolution: "expo-json-utils@npm:0.15.0" + checksum: 10/f514aef7000dd785b5f8e85cbeb809aef6eeaff4877e25e0ae2770db6a3b41f8813a60f13eb821cbf827407cfa0f20a5a2e8d6f18aff7ee20bd51c003af1c7c7 languageName: node linkType: hard -"expo-keep-awake@npm:~14.0.1": - version: 14.0.1 - resolution: "expo-keep-awake@npm:14.0.1" +"expo-keep-awake@npm:~14.1.4": + version: 14.1.4 + resolution: "expo-keep-awake@npm:14.1.4" peerDependencies: expo: "*" react: "*" - checksum: 10/a4b72ab4c6c41f44c04f4146f3eda955941f2c7ee500a43da45baff6ca74d40ada8e253b38b4925802a4a003642b0dc0af15501712367b90ae606d5355f7f5f0 + checksum: 10/d90781fa940f2cac235608e87f52ab4922bf3b3be0b827dc17a2e33a93daad5471445abbe338f76e38561cec2841cbf50e5b78183a0c4819d2462e81e40c6f69 languageName: node linkType: hard -"expo-linear-gradient@npm:^14.0.1": - version: 14.0.1 - resolution: "expo-linear-gradient@npm:14.0.1" +"expo-linear-gradient@npm:~14.1.4": + version: 14.1.4 + resolution: "expo-linear-gradient@npm:14.1.4" peerDependencies: expo: "*" react: "*" react-native: "*" - checksum: 10/d0ff7b10e20b35c09ab7d3f8e8ba205b4274813160bf3eec81c5d4b601985831cf87cb403081c5ed30cf20264245494d29576f8ea7879340f5241b58358136f3 + checksum: 10/a76c479d3ecbe00d653e6128d3e9ab0eb9688e213354299b3748a13fa3383eae8fd8ccaa85bf8232309bb64c9a24700c6a3451775f4c8a39e22b29c5b38a378b languageName: node linkType: hard -"expo-linking@npm:~7.0.3": - version: 7.0.3 - resolution: "expo-linking@npm:7.0.3" +"expo-linking@npm:~7.1.4": + version: 7.1.4 + resolution: "expo-linking@npm:7.1.4" dependencies: - expo-constants: "npm:~17.0.0" + expo-constants: "npm:~17.1.4" invariant: "npm:^2.2.4" peerDependencies: react: "*" react-native: "*" - checksum: 10/c2d3e04c5cde99616357182b9b9303814473eebb2dabacc58e7dd91d90bc590618091259d19b8e761cee973f8dece7619660318c347e047f0aaae99163a7e5ae + checksum: 10/4f00514d4d5a466b35c02eb4882c155485ca68f71f1a0c41a9f7013278fcd828dc89af90964fddb881ee01b4ce888531ea6baed7830b5d390bec4dfc534baea7 languageName: node linkType: hard -"expo-manifests@npm:~0.15.0": - version: 0.15.4 - resolution: "expo-manifests@npm:0.15.4" +"expo-manifests@npm:~0.16.4": + version: 0.16.4 + resolution: "expo-manifests@npm:0.16.4" dependencies: - "@expo/config": "npm:~10.0.4" - expo-json-utils: "npm:~0.14.0" + "@expo/config": "npm:~11.0.6" + expo-json-utils: "npm:~0.15.0" peerDependencies: expo: "*" - checksum: 10/aed4e1277cd52d4a20ff853c84d431b7ec9f45cea57e28bd765e3a9278099a8633dc28d1d244bec6bd43e1bb5d6c244747645bef6362088f379c13900e9122f0 + checksum: 10/90abe4ae9b1e61d868871f79a6c32c6d27ff2f3c9a2d244c277ccb123f166f97180dc7c0ba63781ccc20fe920cd07da0fb51687a5f35c5d9bf0d8f9c5c1dfeee languageName: node linkType: hard -"expo-modules-autolinking@npm:2.0.3": - version: 2.0.3 - resolution: "expo-modules-autolinking@npm:2.0.3" +"expo-manifests@npm:~0.16.5": + version: 0.16.6 + resolution: "expo-manifests@npm:0.16.6" + dependencies: + "@expo/config": "npm:~11.0.12" + expo-json-utils: "npm:~0.15.0" + peerDependencies: + expo: "*" + checksum: 10/a18d32c8aed2cdc9aa4c562e27061fbb22e40995a29978f133eb3211a7c89e6f44a4c1cf5881edd166f5a377fce8279284647795ff7da64f123de9ad7c991bdf + languageName: node + linkType: hard + +"expo-module-scripts@npm:^4.1.7": + version: 4.1.7 + resolution: "expo-module-scripts@npm:4.1.7" + dependencies: + "@babel/cli": "npm:^7.23.4" + "@babel/plugin-transform-export-namespace-from": "npm:^7.23.4" + "@babel/preset-env": "npm:^7.23.8" + "@babel/preset-typescript": "npm:^7.23.3" + "@expo/npm-proofread": "npm:^1.0.1" + "@testing-library/react-native": "npm:^13.1.0" + "@tsconfig/node18": "npm:^18.2.2" + "@types/jest": "npm:^29.2.1" + babel-plugin-dynamic-import-node: "npm:^2.3.3" + babel-preset-expo: "npm:~13.1.11" + commander: "npm:^12.1.0" + eslint-config-universe: "npm:^15.0.3" + glob: "npm:^10.4.2" + jest-expo: "npm:~53.0.5" + jest-snapshot-prettier: "npm:prettier@^2" + jest-watch-typeahead: "npm:2.2.1" + resolve-workspace-root: "npm:^2.0.0" + ts-jest: "npm:~29.0.4" + typescript: "npm:^5.8.3" + bin: + expo-module: bin/expo-module.js + checksum: 10/cb5e0a1d235165998d18f17622eee736bdfeff4bb51c6d584b75458f190ece8a85951d5fef7de637476ce28369ff727182b15824adb6ed2e7dc8d55051fcfc5f + languageName: node + linkType: hard + +"expo-modules-autolinking@npm:2.1.9": + version: 2.1.9 + resolution: "expo-modules-autolinking@npm:2.1.9" dependencies: "@expo/spawn-async": "npm:^1.7.2" chalk: "npm:^4.1.0" commander: "npm:^7.2.0" - fast-glob: "npm:^3.2.5" find-up: "npm:^5.0.0" - fs-extra: "npm:^9.1.0" + glob: "npm:^10.4.2" require-from-string: "npm:^2.0.2" resolve-from: "npm:^5.0.0" bin: expo-modules-autolinking: bin/expo-modules-autolinking.js - checksum: 10/22e7a8df3884213791f09bd41972ca463404d84a94c34fb756188112196e447a1f30429732ece35cfdcd46d3de2214cb0d1197fdc611bfea76dd1e50ff233bf6 + checksum: 10/4c0665cc8b8078e36da68470be2cbf56d73ad90d82c493ad28544c56d617f82ae5a353f0db43f8cf4706b5643fb3ac0501fde0736cd5625c882ee500b09951cb languageName: node linkType: hard -"expo-modules-core@npm:2.1.1": - version: 2.1.1 - resolution: "expo-modules-core@npm:2.1.1" +"expo-modules-core@npm:2.3.12": + version: 2.3.12 + resolution: "expo-modules-core@npm:2.3.12" dependencies: invariant: "npm:^2.2.4" - checksum: 10/add4f7774e1bb66c4217f3c4d5e977c7b8b1281f91d8278e12af242d7e42ee12474f1f5bca8c38249ef5aa9d073dcc1d2ad8c3c35b571a1328b4d32906771384 + checksum: 10/0baf2e26c106f73bd586df125dd6cd94cdaac7977c473176507aebb0e0132cc62cda0501588d245186e9f9d861b3e4622b788302ef87e157b4054e95132107ef languageName: node linkType: hard -"expo-router@npm:~4.0.15": - version: 4.0.15 - resolution: "expo-router@npm:4.0.15" - dependencies: - "@expo/metro-runtime": "npm:4.0.0" - "@expo/server": "npm:^0.5.0" - "@radix-ui/react-slot": "npm:1.0.1" - "@react-navigation/bottom-tabs": "npm:^7.2.0" - "@react-navigation/native": "npm:^7.0.14" - "@react-navigation/native-stack": "npm:^7.2.0" +"expo-router@npm:~5.0.5": + version: 5.0.5 + resolution: "expo-router@npm:5.0.5" + dependencies: + "@expo/metro-runtime": "npm:5.0.4" + "@expo/server": "npm:^0.6.2" + "@radix-ui/react-slot": "npm:1.2.0" + "@react-navigation/bottom-tabs": "npm:^7.3.10" + "@react-navigation/native": "npm:^7.1.6" + "@react-navigation/native-stack": "npm:^7.3.10" client-only: "npm:^0.0.1" - react-helmet-async: "npm:^1.3.0" - react-native-helmet-async: "npm:2.0.4" + invariant: "npm:^2.2.4" + react-fast-compare: "npm:^3.2.2" react-native-is-edge-to-edge: "npm:^1.1.6" schema-utils: "npm:^4.0.1" semver: "npm:~7.6.3" server-only: "npm:^0.0.1" + shallowequal: "npm:^1.1.0" peerDependencies: - "@react-navigation/drawer": ^7.1.1 + "@react-navigation/drawer": ^7.3.9 expo: "*" expo-constants: "*" expo-linking: "*" @@ -17841,36 +20918,39 @@ __metadata: optional: true react-native-reanimated: optional: true - checksum: 10/8979a61e09099ea01fc0f8335856bcb0b6a04bbb3d611745b6ca3ce17e7821df30ff44b22cd3b050876eceb85619779d8313bca48f039a8b59f456cb1b4b9d62 + checksum: 10/06e7ec21019bcc4c6594019391fa84aee3b8220800100af84c058a121c64382eb854d89132b28c436873dc6f769ebc4cb1e123e474c3a426fb40ffb2793cd6ef languageName: node linkType: hard -"expo-splash-screen@npm:^0.29.16": - version: 0.29.16 - resolution: "expo-splash-screen@npm:0.29.16" +"expo-splash-screen@npm:~0.30.8": + version: 0.30.8 + resolution: "expo-splash-screen@npm:0.30.8" dependencies: - "@expo/prebuild-config": "npm:^8.0.22" + "@expo/prebuild-config": "npm:^9.0.5" peerDependencies: expo: "*" - checksum: 10/7a54a5398f31f1cd26f3d6bfb5fa6a6ee6f0ceb33c166ce4bfd7bf0ef11de0b893f05e5edc8fc1e04d855721b8eab0279c5143bd9c6099697d3ee769974c6e6f + checksum: 10/9d3c13ead8e9cad16e02ac46bc3ba96b40711909d7307090ce42a2271d6506533dbcfea6713703fc4ea1c1720b4ec648b8563d2e0ae7d0da5f1b8bdd2aad7210 languageName: node linkType: hard -"expo-status-bar@npm:~2.0.0": - version: 2.0.0 - resolution: "expo-status-bar@npm:2.0.0" +"expo-status-bar@npm:~2.2.3": + version: 2.2.3 + resolution: "expo-status-bar@npm:2.2.3" + dependencies: + react-native-edge-to-edge: "npm:1.6.0" + react-native-is-edge-to-edge: "npm:^1.1.6" peerDependencies: react: "*" react-native: "*" - checksum: 10/c965a617a67831f76645997ac3129061a2a906c7c19d60242fd3a21a63441d9d939e62686b949b57f77d6065643d508e6a5faefec3edc4f29cb8232571934c8a + checksum: 10/d82e4155f7a549bbb010e7b5c13386fd8b5b513c6e219823ab99633a942bb88192cb4a3db6e3f0f1eddb3ce891439ada79a1ff03073e219041bfcaa67e69c1ef languageName: node linkType: hard -"expo-system-ui@npm:~4.0.6": - version: 4.0.6 - resolution: "expo-system-ui@npm:4.0.6" +"expo-system-ui@npm:~5.0.7": + version: 5.0.7 + resolution: "expo-system-ui@npm:5.0.7" dependencies: - "@react-native/normalize-colors": "npm:0.76.5" + "@react-native/normalize-colors": "npm:0.79.2" debug: "npm:^4.3.2" peerDependencies: expo: "*" @@ -17879,50 +20959,61 @@ __metadata: peerDependenciesMeta: react-native-web: optional: true - checksum: 10/aeb5353e3177cf411102bd52f024ff41740d0441f47cd6610ade24067a67f8fc2a624547f0b1fd61bcb2cd73cdb241d58b03b92e77b571a561be8cb4b02efe52 + checksum: 10/1a6668779e240070e49229502999b333c3d95b0cd815b3469f632a3adf5d2529832dcdf19c2d0fb4f0f3263df437f1c77c2a6a7de8cd24e727a2f3f62731db9b languageName: node linkType: hard -"expo-updates-interface@npm:~1.0.0": - version: 1.0.0 - resolution: "expo-updates-interface@npm:1.0.0" +"expo-task-manager@npm:~13.1.5": + version: 13.1.5 + resolution: "expo-task-manager@npm:13.1.5" + dependencies: + unimodules-app-loader: "npm:~5.1.3" peerDependencies: expo: "*" - checksum: 10/d22fa90eff9d6c6d96c1a4323dc3ac8329ef42fbc0fd21442cead135c46da54e1fa402eceda41bdfdb206da6fe98e28576a7243de7d209ea5d6c45785edf2939 + react-native: "*" + checksum: 10/32597e5fc2a3160271ccb5e7d05a210eb8d99a088fa4309f1bdb40c3b0f92ec8f0082af450f7b8c3ca7427940f246440524a14702b17c08da76384b80dd13875 languageName: node linkType: hard -"expo-web-browser@npm:~14.0.1": - version: 14.0.1 - resolution: "expo-web-browser@npm:14.0.1" +"expo-updates-interface@npm:~1.1.0": + version: 1.1.0 + resolution: "expo-updates-interface@npm:1.1.0" + peerDependencies: + expo: "*" + checksum: 10/9fdbcc04129d614c20eebc0dd880684c0afaf0b9d4f2ce128d0b8ceb368793e86dd630e92dd8a26ba162afbf4e420e49827919785d83cdec78fab8f76abd23f1 + languageName: node + linkType: hard + +"expo-web-browser@npm:~14.1.6": + version: 14.1.6 + resolution: "expo-web-browser@npm:14.1.6" peerDependencies: expo: "*" react-native: "*" - checksum: 10/fd25d8f3ca063f6f0c75a2af606b0407c2ffc1bb0da61c50749e535d9f27172a4e496c5dbb3fb217edf89c049c8cd28baba03e5a5d00657c7e1a86c4a5237137 + checksum: 10/f20863c5b87ee04973fcc231e9663013c002d884800f6fbf54875a490f86a16177b99b69105f2dca15ed428bb8499f49bbf0df28d38d65be922cfa545e3c44fe languageName: node linkType: hard -"expo@npm:~52.0.14": - version: 52.0.17 - resolution: "expo@npm:52.0.17" +"expo@npm:^53.0.7": + version: 53.0.7 + resolution: "expo@npm:53.0.7" dependencies: "@babel/runtime": "npm:^7.20.0" - "@expo/cli": "npm:0.22.3" - "@expo/config": "npm:~10.0.6" - "@expo/config-plugins": "npm:~9.0.12" - "@expo/fingerprint": "npm:0.11.3" - "@expo/metro-config": "npm:0.19.6" + "@expo/cli": "npm:0.24.11" + "@expo/config": "npm:~11.0.8" + "@expo/config-plugins": "npm:~10.0.2" + "@expo/fingerprint": "npm:0.12.4" + "@expo/metro-config": "npm:0.20.13" "@expo/vector-icons": "npm:^14.0.0" - babel-preset-expo: "npm:~12.0.3" - expo-asset: "npm:~11.0.1" - expo-constants: "npm:~17.0.3" - expo-file-system: "npm:~18.0.4" - expo-font: "npm:~13.0.1" - expo-keep-awake: "npm:~14.0.1" - expo-modules-autolinking: "npm:2.0.3" - expo-modules-core: "npm:2.1.1" - fbemitter: "npm:^3.0.0" - web-streams-polyfill: "npm:^3.3.2" + babel-preset-expo: "npm:~13.1.11" + expo-asset: "npm:~11.1.5" + expo-constants: "npm:~17.1.5" + expo-file-system: "npm:~18.1.9" + expo-font: "npm:~13.3.1" + expo-keep-awake: "npm:~14.1.4" + expo-modules-autolinking: "npm:2.1.9" + expo-modules-core: "npm:2.3.12" + react-native-edge-to-edge: "npm:1.6.0" whatwg-url-without-unicode: "npm:8.0.0-3" peerDependencies: "@expo/dom-webview": "*" @@ -17939,7 +21030,9 @@ __metadata: optional: true bin: expo: bin/cli - checksum: 10/f418b4106f2a9b0433398dfcdbe26c5a9e4d107d80052907ab85015450dd7d601310d42fcf480af45fbf3f3331c2ede7da78f633a940c6cf1ed3e47294e5801c + expo-modules-autolinking: bin/autolinking + fingerprint: bin/fingerprint + checksum: 10/cb89760659cde24e8f838e3f912b78b0142a3c79aab4d48d798f7086d0339a99f9851aabacc5f4a75988b732cf9fd2149381f7b4c551822452dcf8530ed61535 languageName: node linkType: hard @@ -17959,15 +21052,6 @@ __metadata: languageName: node linkType: hard -"extend-shallow@npm:^2.0.1": - version: 2.0.1 - resolution: "extend-shallow@npm:2.0.1" - dependencies: - is-extendable: "npm:^0.1.0" - checksum: 10/8fb58d9d7a511f4baf78d383e637bd7d2e80843bd9cd0853649108ea835208fb614da502a553acc30208e1325240bb7cc4a68473021612496bb89725483656d8 - languageName: node - linkType: hard - "extend@npm:^3.0.0, extend@npm:~3.0.2": version: 3.0.2 resolution: "extend@npm:3.0.2" @@ -18049,7 +21133,7 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:3.3.2, fast-glob@npm:^3.2.11, fast-glob@npm:^3.2.5, fast-glob@npm:^3.2.7, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.0, fast-glob@npm:^3.3.2": +"fast-glob@npm:3.3.2, fast-glob@npm:^3.2.11, fast-glob@npm:^3.2.7, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.0, fast-glob@npm:^3.3.2": version: 3.3.2 resolution: "fast-glob@npm:3.3.2" dependencies: @@ -18083,13 +21167,6 @@ __metadata: languageName: node linkType: hard -"fast-loops@npm:^1.1.3": - version: 1.1.4 - resolution: "fast-loops@npm:1.1.4" - checksum: 10/52516fc8bb95a60e512271e731c4dc7b7672af90c5e54681004ee2f509d6ccc8e62d5222e731377dafd48a31218f915fd6d0d02efe602b1b822e1ff93994d2a6 - languageName: node - linkType: hard - "fast-redact@npm:^3.0.0": version: 3.5.0 resolution: "fast-redact@npm:3.5.0" @@ -18147,15 +21224,6 @@ __metadata: languageName: node linkType: hard -"fbemitter@npm:^3.0.0": - version: 3.0.0 - resolution: "fbemitter@npm:3.0.0" - dependencies: - fbjs: "npm:^3.0.0" - checksum: 10/a3d1c922d1523da3a66aac2fc0c4687d2573326838172157cc602d53a5d436bb8388f42f5fed5dbbad775509fc8104f02d90f44440c5f820753f4e86905a71be - languageName: node - linkType: hard - "fbjs-css-vars@npm:^1.0.0": version: 1.0.2 resolution: "fbjs-css-vars@npm:1.0.2" @@ -18163,7 +21231,7 @@ __metadata: languageName: node linkType: hard -"fbjs@npm:^3.0.0, fbjs@npm:^3.0.4": +"fbjs@npm:^3.0.4": version: 3.0.5 resolution: "fbjs@npm:3.0.5" dependencies: @@ -18187,13 +21255,6 @@ __metadata: languageName: node linkType: hard -"fetch-retry@npm:^4.1.1": - version: 4.1.1 - resolution: "fetch-retry@npm:4.1.1" - checksum: 10/c97006c2b604a817cbd4e35085965d07a8f1c51b475bf037f305b98d5748ee742ec98aba119b4df6bf727e61f2f0ee05c5fa714701c4234b91c4b43e0f119bd9 - languageName: node - linkType: hard - "figures@npm:^3.2.0": version: 3.2.0 resolution: "figures@npm:3.2.0" @@ -18369,38 +21430,39 @@ __metadata: languageName: node linkType: hard -"firebase@npm:10.13.2": - version: 10.13.2 - resolution: "firebase@npm:10.13.2" - dependencies: - "@firebase/analytics": "npm:0.10.8" - "@firebase/analytics-compat": "npm:0.2.14" - "@firebase/app": "npm:0.10.11" - "@firebase/app-check": "npm:0.8.8" - "@firebase/app-check-compat": "npm:0.3.15" - "@firebase/app-compat": "npm:0.2.41" - "@firebase/app-types": "npm:0.9.2" - "@firebase/auth": "npm:1.7.9" - "@firebase/auth-compat": "npm:0.5.14" - "@firebase/database": "npm:1.0.8" - "@firebase/database-compat": "npm:1.0.8" - "@firebase/firestore": "npm:4.7.2" - "@firebase/firestore-compat": "npm:0.3.37" - "@firebase/functions": "npm:0.11.8" - "@firebase/functions-compat": "npm:0.3.14" - "@firebase/installations": "npm:0.6.9" - "@firebase/installations-compat": "npm:0.2.9" - "@firebase/messaging": "npm:0.12.11" - "@firebase/messaging-compat": "npm:0.2.11" - "@firebase/performance": "npm:0.6.9" - "@firebase/performance-compat": "npm:0.2.9" - "@firebase/remote-config": "npm:0.4.9" - "@firebase/remote-config-compat": "npm:0.2.9" - "@firebase/storage": "npm:0.13.2" - "@firebase/storage-compat": "npm:0.3.12" - "@firebase/util": "npm:1.10.0" - "@firebase/vertexai-preview": "npm:0.0.4" - checksum: 10/c91a047b34f3e2a0b0f563a4b9b4aca4887c0052f82819384acc482c1523c83c108d47eb8a96aa2adce94e07d0f9eeabbd7fd4d2b4fde1e2706fb90a6aea2db1 +"firebase@npm:11.3.1": + version: 11.3.1 + resolution: "firebase@npm:11.3.1" + dependencies: + "@firebase/analytics": "npm:0.10.11" + "@firebase/analytics-compat": "npm:0.2.17" + "@firebase/app": "npm:0.11.1" + "@firebase/app-check": "npm:0.8.11" + "@firebase/app-check-compat": "npm:0.3.18" + "@firebase/app-compat": "npm:0.2.50" + "@firebase/app-types": "npm:0.9.3" + "@firebase/auth": "npm:1.9.0" + "@firebase/auth-compat": "npm:0.5.18" + "@firebase/data-connect": "npm:0.3.0" + "@firebase/database": "npm:1.0.12" + "@firebase/database-compat": "npm:2.0.3" + "@firebase/firestore": "npm:4.7.8" + "@firebase/firestore-compat": "npm:0.3.43" + "@firebase/functions": "npm:0.12.2" + "@firebase/functions-compat": "npm:0.3.19" + "@firebase/installations": "npm:0.6.12" + "@firebase/installations-compat": "npm:0.2.12" + "@firebase/messaging": "npm:0.12.16" + "@firebase/messaging-compat": "npm:0.2.16" + "@firebase/performance": "npm:0.7.0" + "@firebase/performance-compat": "npm:0.2.13" + "@firebase/remote-config": "npm:0.5.0" + "@firebase/remote-config-compat": "npm:0.2.12" + "@firebase/storage": "npm:0.13.6" + "@firebase/storage-compat": "npm:0.3.16" + "@firebase/util": "npm:1.10.3" + "@firebase/vertexai": "npm:1.0.4" + checksum: 10/281d2c3f1ea7b4de758879b0f80b8b737ef2908e519adf950c3a680162c107201c2b1ee2a86309c215add0b84043f27df76990fa86e52d4f3652c85cc4b30178 languageName: node linkType: hard @@ -18482,6 +21544,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.6": + version: 1.15.9 + resolution: "follow-redirects@npm:1.15.9" + peerDependenciesMeta: + debug: + optional: true + checksum: 10/e3ab42d1097e90d28b913903841e6779eb969b62a64706a3eb983e894a5db000fbd89296f45f08885a0e54cd558ef62e81be1165da9be25a6c44920da10f424c + languageName: node + linkType: hard + "fontfaceobserver@npm:^2.1.0": version: 2.3.0 resolution: "fontfaceobserver@npm:2.3.0" @@ -18498,6 +21570,15 @@ __metadata: languageName: node linkType: hard +"for-each@npm:^0.3.5": + version: 0.3.5 + resolution: "for-each@npm:0.3.5" + dependencies: + is-callable: "npm:^1.2.7" + checksum: 10/330cc2439f85c94f4609de3ee1d32c5693ae15cdd7fe3d112c4fd9efd4ce7143f2c64ef6c2c9e0cfdb0058437f33ef05b5bdae5b98fcc903fb2143fbaf0fea0f + languageName: node + linkType: hard + "foreground-child@npm:^3.1.0": version: 3.3.0 resolution: "foreground-child@npm:3.3.0" @@ -18538,17 +21619,6 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^3.0.1": - version: 3.0.2 - resolution: "form-data@npm:3.0.2" - dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.8" - mime-types: "npm:^2.1.12" - checksum: 10/b8d71d7149de5881c6c8ac75c03ac2e809b1b729399320cc41f59a63043fa34b95dfef5259212d6d902abb4916af48a7ca60ad5c035806ba8e3c7843dbaf3057 - languageName: node - linkType: hard - "form-data@npm:^4.0.0, form-data@npm:~4.0.0": version: 4.0.1 resolution: "form-data@npm:4.0.1" @@ -18604,6 +21674,20 @@ __metadata: languageName: node linkType: hard +"freerasp-react-native@npm:^4.1.0": + version: 4.1.0 + resolution: "freerasp-react-native@npm:4.1.0" + peerDependencies: + expo: ">=47.0.0" + react: "*" + react-native: "*" + peerDependenciesMeta: + expo: + optional: true + checksum: 10/f1c672b6db9fe07ff469a0b968d1dd4388fe4f62983903c720c4ffc049488f42b0d586ba0eb098fae99ce688aabe08e5da810cf11fae1cb6bf514cbd9821c251 + languageName: node + linkType: hard + "fresh@npm:0.5.2": version: 0.5.2 resolution: "fresh@npm:0.5.2" @@ -18611,18 +21695,6 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:9.0.0": - version: 9.0.0 - resolution: "fs-extra@npm:9.0.0" - dependencies: - at-least-node: "npm:^1.0.0" - graceful-fs: "npm:^4.2.0" - jsonfile: "npm:^6.0.1" - universalify: "npm:^1.0.0" - checksum: 10/0a5044afb8596f9fa950ef84e678f606c29f3cef980b08f161eac5bfdee08183493646f4fca409959f909ae184a673f61e622b73a27487aeaf24ff531193c98f - languageName: node - linkType: hard - "fs-extra@npm:^10.0.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" @@ -18656,7 +21728,7 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:^9.0.0, fs-extra@npm:^9.0.1, fs-extra@npm:^9.1.0": +"fs-extra@npm:^9.0.1, fs-extra@npm:^9.1.0": version: 9.1.0 resolution: "fs-extra@npm:9.1.0" dependencies: @@ -18668,26 +21740,6 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:~8.1.0": - version: 8.1.0 - resolution: "fs-extra@npm:8.1.0" - dependencies: - graceful-fs: "npm:^4.2.0" - jsonfile: "npm:^4.0.0" - universalify: "npm:^0.1.0" - checksum: 10/6fb12449f5349be724a138b4a7b45fe6a317d2972054517f5971959c26fbd17c0e145731a11c7324460262baa33e0a799b183ceace98f7a372c95fbb6f20f5de - languageName: node - linkType: hard - -"fs-minipass@npm:^2.0.0": - version: 2.1.0 - resolution: "fs-minipass@npm:2.1.0" - dependencies: - minipass: "npm:^3.0.0" - checksum: 10/03191781e94bc9a54bd376d3146f90fe8e082627c502185dbf7b9b3032f66b0b142c1115f3b2cc5936575fc1b44845ce903dd4c21bec2a8d69f3bd56f9cee9ec - languageName: node - linkType: hard - "fs-minipass@npm:^3.0.0": version: 3.0.3 resolution: "fs-minipass@npm:3.0.3" @@ -18704,6 +21756,13 @@ __metadata: languageName: node linkType: hard +"fs-readdir-recursive@npm:^1.1.0": + version: 1.1.0 + resolution: "fs-readdir-recursive@npm:1.1.0" + checksum: 10/d5e3fd8456b8e5d57a43f169a9eaf65c70fa82c4a22f1d4361cdba4ea5e61c60c5c2b4ac481ea137a4d43b2b99b3ea2fae95ac2730255c4206d61af645866c3a + languageName: node + linkType: hard + "fs.realpath@npm:^1.0.0": version: 1.0.0 resolution: "fs.realpath@npm:1.0.0" @@ -18750,6 +21809,20 @@ __metadata: languageName: node linkType: hard +"function.prototype.name@npm:^1.1.8": + version: 1.1.8 + resolution: "function.prototype.name@npm:1.1.8" + dependencies: + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.3" + define-properties: "npm:^1.2.1" + functions-have-names: "npm:^1.2.3" + hasown: "npm:^2.0.2" + is-callable: "npm:^1.2.7" + checksum: 10/25b9e5bea936732a6f0c0c08db58cc0d609ac1ed458c6a07ead46b32e7b9bf3fe5887796c3f83d35994efbc4fdde81c08ac64135b2c399b8f2113968d44082bc + languageName: node + linkType: hard + "functions-have-names@npm:^1.2.3": version: 1.2.3 resolution: "functions-have-names@npm:1.2.3" @@ -18764,6 +21837,13 @@ __metadata: languageName: node linkType: hard +"fuse.js@npm:^7.1.0": + version: 7.1.0 + resolution: "fuse.js@npm:7.1.0" + checksum: 10/9f9105e54372897a46cb3e04074f0db5bd0a428320d4618276a57e6142d7502235a556f05cf87aa3c5d6d9c6fdfa06b901b78379c48aa0951672ccbc4a1bfe70 + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -18803,6 +21883,24 @@ __metadata: languageName: node linkType: hard +"get-intrinsic@npm:^1.2.7, get-intrinsic@npm:^1.3.0": + version: 1.3.0 + resolution: "get-intrinsic@npm:1.3.0" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" + function-bind: "npm:^1.1.2" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10/6e9dd920ff054147b6f44cb98104330e87caafae051b6d37b13384a45ba15e71af33c3baeac7cb630a0aaa23142718dcf25b45cfdd86c184c5dcb4e56d953a10 + languageName: node + linkType: hard + "get-nonce@npm:^1.0.0": version: 1.0.1 resolution: "get-nonce@npm:1.0.1" @@ -18845,12 +21943,13 @@ __metadata: languageName: node linkType: hard -"get-stream@npm:^4.0.0": - version: 4.1.0 - resolution: "get-stream@npm:4.1.0" +"get-proto@npm:^1.0.0, get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" dependencies: - pump: "npm:^3.0.0" - checksum: 10/12673e8aebc79767d187b203e5bfabb8266304037815d3bcc63b6f8c67c6d4ad0d98d4d4528bcdc1cbea68f1dd91bcbd87827aa3cdcfa9c5fa4a4644716d72c2 + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10/4fc96afdb58ced9a67558698b91433e6b037aaa6f1493af77498d7c85b141382cf223c0e5946f334fb328ee85dfe6edd06d218eaf09556f4bc4ec6005d7f5f7b languageName: node linkType: hard @@ -18877,7 +21976,7 @@ __metadata: languageName: node linkType: hard -"get-symbol-description@npm:^1.0.2": +"get-symbol-description@npm:^1.0.2, get-symbol-description@npm:^1.1.0": version: 1.1.0 resolution: "get-symbol-description@npm:1.1.0" dependencies: @@ -18897,6 +21996,15 @@ __metadata: languageName: node linkType: hard +"get-tsconfig@npm:^4.8.1": + version: 4.10.1 + resolution: "get-tsconfig@npm:4.10.1" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10/04d63f47fdecaefbd1f73ec02949be4ec4db7d6d9fbc8d4e81f9a4bb1c6f876e48943712f2f9236643d3e4d61d9a7b06da08564d08b034631ebe3f5605bef237 + languageName: node + linkType: hard + "getenv@npm:^1.0.0": version: 1.0.0 resolution: "getenv@npm:1.0.0" @@ -18904,6 +22012,13 @@ __metadata: languageName: node linkType: hard +"getenv@npm:^2.0.0": + version: 2.0.0 + resolution: "getenv@npm:2.0.0" + checksum: 10/ba25153e26c0960199b5de1a0c7bdfc661226c00e27bb194f829ed129843510ce230f9daa3b4d06f10056298a9c4e9afbbd358fc7632a545f299e370772b047a + languageName: node + linkType: hard + "getos@npm:^3.2.1": version: 3.2.1 resolution: "getos@npm:3.2.1" @@ -18961,7 +22076,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7, glob@npm:^10.4.2": +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7, glob@npm:^10.4.2, glob@npm:^10.4.5": version: 10.4.5 resolution: "glob@npm:10.4.5" dependencies: @@ -18977,7 +22092,23 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.1, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.1.7": +"glob@npm:^11.0.1": + version: 11.0.1 + resolution: "glob@npm:11.0.1" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^4.0.1" + minimatch: "npm:^10.0.0" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^2.0.0" + bin: + glob: dist/esm/bin.mjs + checksum: 10/57b12a05cc25f1c38f3b24cf6ea7a8bacef11e782c4b9a8c5b0bef3e6c5bcb8c4548cb31eb4115592e0490a024c1bde7359c470565608dd061d3b21179740457 + languageName: node + linkType: hard + +"glob@npm:^7.1.1, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.1.7, glob@npm:^7.2.0": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -19014,6 +22145,13 @@ __metadata: languageName: node linkType: hard +"globals@npm:^15.11.0": + version: 15.15.0 + resolution: "globals@npm:15.15.0" + checksum: 10/7f561c87b2fd381b27fc2db7df8a4ea7a9bb378667b8a7193e61fd2ca3a876479174e2a303a74345fbea6e1242e16db48915c1fd3bf35adcf4060a795b425e18 + languageName: node + linkType: hard + "globals@npm:^15.14.0": version: 15.14.0 resolution: "globals@npm:15.14.0" @@ -19021,6 +22159,13 @@ __metadata: languageName: node linkType: hard +"globals@npm:^16.0.0": + version: 16.2.0 + resolution: "globals@npm:16.2.0" + checksum: 10/37fc33502973ebbee5a44b58939aa8574abc00ca1fc4c1d4ec0571a2c6620843ae647eff8bd082adf6bb5975ad221a887522b9a7961125764f0cb6dfab0b7483 + languageName: node + linkType: hard + "globalthis@npm:^1.0.4": version: 1.0.4 resolution: "globalthis@npm:1.0.4" @@ -19038,7 +22183,7 @@ __metadata: languageName: node linkType: hard -"globby@npm:^11.0.1, globby@npm:^11.1.0": +"globby@npm:^11.1.0": version: 11.1.0 resolution: "globby@npm:11.1.0" dependencies: @@ -19072,7 +22217,7 @@ __metadata: languageName: node linkType: hard -"gopd@npm:^1.0.1, gopd@npm:^1.2.0": +"gopd@npm:^1.0.1, gopd@npm:^1.1.0, gopd@npm:^1.2.0": version: 1.2.0 resolution: "gopd@npm:1.2.0" checksum: 10/94e296d69f92dc1c0768fcfeecfb3855582ab59a7c75e969d5f96ce50c3d201fd86d5a2857c22565764d5bb8a816c7b1e58f133ec318cd56274da36c5e3fb1a1 @@ -19100,18 +22245,6 @@ __metadata: languageName: node linkType: hard -"gray-matter@npm:^4.0.3": - version: 4.0.3 - resolution: "gray-matter@npm:4.0.3" - dependencies: - js-yaml: "npm:^3.13.1" - kind-of: "npm:^6.0.2" - section-matter: "npm:^1.0.0" - strip-bom-string: "npm:^1.0.0" - checksum: 10/9a8f146a7a918d2524d5d60e0b4d45729f5bca54aa41247f971d9e4bc984943fda58159435763d463ec2abc8a0e238e807bd9b05e3a48f4a613a325c9dd5ad0c - languageName: node - linkType: hard - "gzip-size@npm:^6.0.0": version: 6.0.0 resolution: "gzip-size@npm:6.0.0" @@ -19163,6 +22296,13 @@ __metadata: languageName: node linkType: hard +"has-flag@npm:^2.0.0": + version: 2.0.0 + resolution: "has-flag@npm:2.0.0" + checksum: 10/7d060d142ef6740c79991cb99afe5962b267e6e95538bf8b607026b9b1e7451288927bc8e7b4a9484a8b99935c0af023070f91ee49faef791ecd401dc58b2e8d + languageName: node + linkType: hard + "has-flag@npm:^3.0.0": version: 3.0.0 resolution: "has-flag@npm:3.0.0" @@ -19330,13 +22470,6 @@ __metadata: languageName: node linkType: hard -"hermes-estree@npm:0.24.0": - version: 0.24.0 - resolution: "hermes-estree@npm:0.24.0" - checksum: 10/f2c55e06d8a3336efbb19a85974e6a083029a11ea61703d6d626cf7d476deb861189c1ea5f7ecac6055ae4330888100ed0de0343837e01348dec185433b824f4 - languageName: node - linkType: hard - "hermes-estree@npm:0.25.1": version: 0.25.1 resolution: "hermes-estree@npm:0.25.1" @@ -19353,15 +22486,6 @@ __metadata: languageName: node linkType: hard -"hermes-parser@npm:0.24.0": - version: 0.24.0 - resolution: "hermes-parser@npm:0.24.0" - dependencies: - hermes-estree: "npm:0.24.0" - checksum: 10/c473cf2c3a4dd3fa835c52fe67b4554e88da40cecb4cfd12f0860004eea77256c34c8d5881ef3e0f8bd529edb8f71e1296296e8282b6aee2d1399a97e787ad33 - languageName: node - linkType: hard - "hermes-parser@npm:0.25.1": version: 0.25.1 resolution: "hermes-parser@npm:0.25.1" @@ -19518,6 +22642,18 @@ __metadata: languageName: node linkType: hard +"htmlparser2@npm:^9.1.0": + version: 9.1.0 + resolution: "htmlparser2@npm:9.1.0" + dependencies: + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.3" + domutils: "npm:^3.1.0" + entities: "npm:^4.5.0" + checksum: 10/6352fa2a5495781fa9a02c9049908334cd068ff36d753870d30cd13b841e99c19646717567a2f9e9c44075bbe43d364e102f9d013a731ce962226d63746b794f + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -19694,13 +22830,20 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^5.2.0, ignore@npm:^5.2.4, ignore@npm:^5.3.1": +"ignore@npm:^5.1.1, ignore@npm:^5.2.0, ignore@npm:^5.2.4, ignore@npm:^5.3.1, ignore@npm:^5.3.2": version: 5.3.2 resolution: "ignore@npm:5.3.2" checksum: 10/cceb6a457000f8f6a50e1196429750d782afce5680dd878aa4221bd79972d68b3a55b4b1458fc682be978f4d3c6a249046aa0880637367216444ab7b014cfc98 languageName: node linkType: hard +"ignore@npm:^7.0.0": + version: 7.0.5 + resolution: "ignore@npm:7.0.5" + checksum: 10/f134b96a4de0af419196f52c529d5c6120c4456ff8a6b5a14ceaaa399f883e15d58d2ce651c9b69b9388491d4669dda47285d307e827de9304a53a1824801bc6 + languageName: node + linkType: hard + "image-size@npm:^1.0.0, image-size@npm:^1.0.2": version: 1.1.1 resolution: "image-size@npm:1.1.1" @@ -19840,13 +22983,12 @@ __metadata: languageName: node linkType: hard -"inline-style-prefixer@npm:^6.0.1": - version: 6.0.4 - resolution: "inline-style-prefixer@npm:6.0.4" +"inline-style-prefixer@npm:^7.0.1": + version: 7.0.1 + resolution: "inline-style-prefixer@npm:7.0.1" dependencies: css-in-js-utils: "npm:^3.1.0" - fast-loops: "npm:^1.1.3" - checksum: 10/5ee7a082b4d23ac220fabe2353a8452bd50c587ae0d9e20e6c0f4ebc456377c7a3a4ce9d13486e0cfc9032db00d9b0ae33d3944a183340b1b3d34cef2d5df80b + checksum: 10/a430c962693f32a36bcec0124c9798bcf3725bb90468d493108c0242446a9cc92ff1967bdf99b6ce5331e7a9b75e6836bc9ba1b3d4756876b8ef48036acb2509 languageName: node linkType: hard @@ -19861,16 +23003,6 @@ __metadata: languageName: node linkType: hard -"internal-ip@npm:^4.3.0": - version: 4.3.0 - resolution: "internal-ip@npm:4.3.0" - dependencies: - default-gateway: "npm:^4.2.0" - ipaddr.js: "npm:^1.9.0" - checksum: 10/c970433c84d9a6b46e2c9f5ab7785d3105b856d0a566891bf919241b5a884c5c1c9bf8e915aebb822a86c14b1b6867e58c1eaf5cd49eb023368083069d1a4a9a - languageName: node - linkType: hard - "internal-slot@npm:^1.0.7, internal-slot@npm:^1.1.0": version: 1.1.0 resolution: "internal-slot@npm:1.1.0" @@ -19882,15 +23014,15 @@ __metadata: languageName: node linkType: hard -"intl-messageformat@npm:^9.13.0": - version: 9.13.0 - resolution: "intl-messageformat@npm:9.13.0" +"intl-messageformat@npm:^10.5.3": + version: 10.7.15 + resolution: "intl-messageformat@npm:10.7.15" dependencies: - "@formatjs/ecma402-abstract": "npm:1.11.4" - "@formatjs/fast-memoize": "npm:1.2.1" - "@formatjs/icu-messageformat-parser": "npm:2.1.0" - tslib: "npm:^2.1.0" - checksum: 10/54ad97038cf1a31c140ed2557eb76cd0a43a69c9f4b6bea308cb29bd74e8650f5ed979c7e0eb2f468e0992944adbf4c17a4dbcda20ec2f3ace79f3aa58756893 + "@formatjs/ecma402-abstract": "npm:2.3.3" + "@formatjs/fast-memoize": "npm:2.2.6" + "@formatjs/icu-messageformat-parser": "npm:2.11.1" + tslib: "npm:2" + checksum: 10/817630c4997d24556714f785d26a1e8ced510c88c28837fc23add75891096e2d64156534ea126b5e1439fa23659b3e4bf530fb6ca5618c9b5ace067e6a47d486 languageName: node linkType: hard @@ -19903,27 +23035,34 @@ __metadata: languageName: node linkType: hard -"ip-address@npm:^9.0.5": - version: 9.0.5 - resolution: "ip-address@npm:9.0.5" +"inversify-logger-middleware@npm:^3.1.0": + version: 3.1.0 + resolution: "inversify-logger-middleware@npm:3.1.0" dependencies: - jsbn: "npm:1.1.0" - sprintf-js: "npm:^1.1.3" - checksum: 10/1ed81e06721af012306329b31f532b5e24e00cb537be18ddc905a84f19fe8f83a09a1699862bf3a1ec4b9dea93c55a3fa5faf8b5ea380431469df540f38b092c + chalk: "npm:2.3.0" + checksum: 10/2c559e6424a1d55a75e2470d2103b444ce22946c7aded1ca20d53ad47843ea9e8624232490d484a3c5f5b76ae169f207d87fd3b517f6a2ff3667b693bdb6e056 languageName: node linkType: hard -"ip-regex@npm:^2.1.0": - version: 2.1.0 - resolution: "ip-regex@npm:2.1.0" - checksum: 10/331d95052aa53ce245745ea0fc3a6a1e2e3c8d6da65fa8ea52bf73768c1b22a9ac50629d1d2b08c04e7b3ac4c21b536693c149ce2c2615ee4796030e5b3e3cba +"inversify@npm:^6.2.2": + version: 6.2.2 + resolution: "inversify@npm:6.2.2" + dependencies: + "@inversifyjs/common": "npm:1.4.0" + "@inversifyjs/core": "npm:1.3.5" + peerDependencies: + reflect-metadata: ~0.2.2 + checksum: 10/25a1f823550c3765b7f4a11b505d76c70cab6e3f9a08414f76891823c68819ed5067b917ccf61bbfa24c7be6bc26080bdc7fe02c3c9dd5da7cb5b01708b843d3 languageName: node linkType: hard -"ipaddr.js@npm:^1.9.0": - version: 1.9.1 - resolution: "ipaddr.js@npm:1.9.1" - checksum: 10/864d0cced0c0832700e9621913a6429ccdc67f37c1bd78fb8c6789fff35c9d167cb329134acad2290497a53336813ab4798d2794fd675d5eb33b5fdf0982b9ca +"ip-address@npm:^9.0.5": + version: 9.0.5 + resolution: "ip-address@npm:9.0.5" + dependencies: + jsbn: "npm:1.1.0" + sprintf-js: "npm:^1.1.3" + checksum: 10/1ed81e06721af012306329b31f532b5e24e00cb537be18ddc905a84f19fe8f83a09a1699862bf3a1ec4b9dea93c55a3fa5faf8b5ea380431469df540f38b092c languageName: node linkType: hard @@ -20020,7 +23159,7 @@ __metadata: languageName: node linkType: hard -"is-array-buffer@npm:^3.0.4": +"is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" dependencies: @@ -20082,13 +23221,6 @@ __metadata: languageName: node linkType: hard -"is-buffer@npm:~1.1.6": - version: 1.1.6 - resolution: "is-buffer@npm:1.1.6" - checksum: 10/f63da109e74bbe8947036ed529d43e4ae0c5fcd0909921dce4917ad3ea212c6a87c29f525ba1d17c0858c18331cf1046d4fc69ef59ed26896b25c8288a627133 - languageName: node - linkType: hard - "is-bun-module@npm:^1.0.2": version: 1.3.0 resolution: "is-bun-module@npm:1.3.0" @@ -20105,7 +23237,16 @@ __metadata: languageName: node linkType: hard -"is-core-module@npm:^2.13.0, is-core-module@npm:^2.15.1, is-core-module@npm:^2.16.0, is-core-module@npm:^2.5.0": +"is-core-module@npm:^2.13.0, is-core-module@npm:^2.5.0": + version: 2.15.1 + resolution: "is-core-module@npm:2.15.1" + dependencies: + hasown: "npm:^2.0.2" + checksum: 10/77316d5891d5743854bcef2cd2f24c5458fb69fbc9705c12ca17d54a2017a67d0693bbf1ba8c77af376c0eef6bf6d1b27a4ab08e4db4e69914c3789bdf2ceec5 + languageName: node + linkType: hard + +"is-core-module@npm:^2.15.1, is-core-module@npm:^2.16.0": version: 2.16.0 resolution: "is-core-module@npm:2.16.0" dependencies: @@ -20167,13 +23308,6 @@ __metadata: languageName: node linkType: hard -"is-extendable@npm:^0.1.0": - version: 0.1.1 - resolution: "is-extendable@npm:0.1.1" - checksum: 10/3875571d20a7563772ecc7a5f36cb03167e9be31ad259041b4a8f73f33f885441f778cee1f1fe0085eb4bc71679b9d8c923690003a36a6a5fdf8023e6e3f0672 - languageName: node - linkType: hard - "is-extglob@npm:^2.1.1": version: 2.1.1 resolution: "is-extglob@npm:2.1.1" @@ -20328,13 +23462,6 @@ __metadata: languageName: node linkType: hard -"is-path-cwd@npm:^2.2.0": - version: 2.2.0 - resolution: "is-path-cwd@npm:2.2.0" - checksum: 10/46a840921bb8cc0dc7b5b423a14220e7db338072a4495743a8230533ce78812dc152548c86f4b828411fe98c5451959f07cf841c6a19f611e46600bd699e8048 - languageName: node - linkType: hard - "is-path-inside@npm:^3.0.2": version: 3.0.3 resolution: "is-path-inside@npm:3.0.3" @@ -20400,7 +23527,19 @@ __metadata: languageName: node linkType: hard -"is-regex@npm:^1.1.4, is-regex@npm:^1.2.1": +"is-regex@npm:^1.1.4": + version: 1.2.0 + resolution: "is-regex@npm:1.2.0" + dependencies: + call-bind: "npm:^1.0.7" + gopd: "npm:^1.1.0" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10/68df70b5696f865f495551d506c0514e3a221db887d5375c6fb4412389a8ceaf4071e557126fead1bcee21ab38be4548f04e7f6510d793b5150df1e8e2556191 + languageName: node + linkType: hard + +"is-regex@npm:^1.2.1": version: 1.2.1 resolution: "is-regex@npm:1.2.1" dependencies: @@ -20435,10 +23574,12 @@ __metadata: languageName: node linkType: hard -"is-stream@npm:^1.1.0": - version: 1.1.0 - resolution: "is-stream@npm:1.1.0" - checksum: 10/351aa77c543323c4e111204482808cfad68d2e940515949e31ccd0b010fc13d5fba4b9c230e4887fd24284713040f43e542332fbf172f6b9944b7d62e389c0ec +"is-shared-array-buffer@npm:^1.0.4": + version: 1.0.4 + resolution: "is-shared-array-buffer@npm:1.0.4" + dependencies: + call-bound: "npm:^1.0.3" + checksum: 10/0380d7c60cc692856871526ffcd38a8133818a2ee42d47bb8008248a0cd2121d8c8b5f66b6da3cac24bc5784553cacb6faaf678f66bc88c6615b42af2825230e languageName: node linkType: hard @@ -20477,7 +23618,16 @@ __metadata: languageName: node linkType: hard -"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.3": +"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.3": + version: 1.1.13 + resolution: "is-typed-array@npm:1.1.13" + dependencies: + which-typed-array: "npm:^1.1.14" + checksum: 10/f850ba08286358b9a11aee6d93d371a45e3c59b5953549ee1c1a9a55ba5c1dd1bd9952488ae194ad8f32a9cf5e79c8fa5f0cc4d78c00720aa0bbcf238b38062d + languageName: node + linkType: hard + +"is-typed-array@npm:^1.1.14": version: 1.1.14 resolution: "is-typed-array@npm:1.1.14" dependencies: @@ -20486,6 +23636,15 @@ __metadata: languageName: node linkType: hard +"is-typed-array@npm:^1.1.15": + version: 1.1.15 + resolution: "is-typed-array@npm:1.1.15" + dependencies: + which-typed-array: "npm:^1.1.16" + checksum: 10/e8cf60b9ea85667097a6ad68c209c9722cfe8c8edf04d6218366469e51944c5cc25bae45ffb845c23f811d262e4314d3b0168748eb16711aa34d12724cdf0735 + languageName: node + linkType: hard + "is-typedarray@npm:~1.0.0": version: 1.0.0 resolution: "is-typedarray@npm:1.0.0" @@ -20516,6 +23675,15 @@ __metadata: languageName: node linkType: hard +"is-weakref@npm:^1.1.1": + version: 1.1.1 + resolution: "is-weakref@npm:1.1.1" + dependencies: + call-bound: "npm:^1.0.3" + checksum: 10/543506fd8259038b371bb083aac25b16cb4fd8b12fc58053aa3d45ac28dfd001cd5c6dffbba7aeea4213c74732d46b6cb2cfb5b412eed11f2db524f3f97d09a0 + languageName: node + linkType: hard + "is-weakset@npm:^2.0.3": version: 2.0.4 resolution: "is-weakset@npm:2.0.4" @@ -20602,6 +23770,15 @@ __metadata: languageName: node linkType: hard +"isomorphic-ws@npm:^5.0.0": + version: 5.0.0 + resolution: "isomorphic-ws@npm:5.0.0" + peerDependencies: + ws: "*" + checksum: 10/e20eb2aee09ba96247465fda40c6d22c1153394c0144fa34fe6609f341af4c8c564f60ea3ba762335a7a9c306809349f9b863c8beedf2beea09b299834ad5398 + languageName: node + linkType: hard + "isows@npm:1.0.4": version: 1.0.4 resolution: "isows@npm:1.0.4" @@ -20620,6 +23797,15 @@ __metadata: languageName: node linkType: hard +"isows@npm:1.0.7": + version: 1.0.7 + resolution: "isows@npm:1.0.7" + peerDependencies: + ws: "*" + checksum: 10/044b949b369872882af07b60b613b5801ae01b01a23b5b72b78af80c8103bbeed38352c3e8ceff13a7834bc91fd2eb41cf91ec01d59a041d8705680e6b0ec546 + languageName: node + linkType: hard + "isstream@npm:~0.1.2": version: 0.1.2 resolution: "isstream@npm:0.1.2" @@ -20743,6 +23929,20 @@ __metadata: languageName: node linkType: hard +"iterator.prototype@npm:^1.1.4": + version: 1.1.5 + resolution: "iterator.prototype@npm:1.1.5" + dependencies: + define-data-property: "npm:^1.1.4" + es-object-atoms: "npm:^1.0.0" + get-intrinsic: "npm:^1.2.6" + get-proto: "npm:^1.0.0" + has-symbols: "npm:^1.1.0" + set-function-name: "npm:^2.0.2" + checksum: 10/352bcf333f42189e65cc8cb2dcb94a5c47cf0a9110ce12aba788d405a980b5f5f3a06c79bf915377e1d480647169babd842ded0d898bed181bf6686e8e6823f6 + languageName: node + linkType: hard + "jackspeak@npm:^3.1.2": version: 3.4.3 resolution: "jackspeak@npm:3.4.3" @@ -20756,6 +23956,15 @@ __metadata: languageName: node linkType: hard +"jackspeak@npm:^4.0.1": + version: 4.0.2 + resolution: "jackspeak@npm:4.0.2" + dependencies: + "@isaacs/cliui": "npm:^8.0.2" + checksum: 10/d9722f0e55f6c322c57aedf094c405f4201b834204629817187953988075521cfddb23df83e2a7b845723ca7eb0555068c5ce1556732e9c275d32a531881efa8 + languageName: node + linkType: hard + "jake@npm:^10.8.5": version: 10.9.2 resolution: "jake@npm:10.9.2" @@ -20928,7 +24137,7 @@ __metadata: languageName: node linkType: hard -"jest-environment-node@npm:^29.6.3, jest-environment-node@npm:^29.7.0": +"jest-environment-node@npm:^29.7.0": version: 29.7.0 resolution: "jest-environment-node@npm:29.7.0" dependencies: @@ -20942,16 +24151,15 @@ __metadata: languageName: node linkType: hard -"jest-expo@npm:~52.0.2": - version: 52.0.2 - resolution: "jest-expo@npm:52.0.2" +"jest-expo@npm:~53.0.4": + version: 53.0.4 + resolution: "jest-expo@npm:53.0.4" dependencies: - "@expo/config": "npm:~10.0.4" - "@expo/json-file": "npm:^9.0.0" + "@expo/config": "npm:~11.0.7" + "@expo/json-file": "npm:^9.1.4" "@jest/create-cache-key-function": "npm:^29.2.1" "@jest/globals": "npm:^29.2.1" babel-jest: "npm:^29.2.1" - fbemitter: "npm:^3.0.0" find-up: "npm:^5.0.0" jest-environment-jsdom: "npm:^29.2.1" jest-snapshot: "npm:^29.2.1" @@ -20959,8 +24167,8 @@ __metadata: jest-watch-typeahead: "npm:2.2.1" json5: "npm:^2.2.3" lodash: "npm:^4.17.19" - react-server-dom-webpack: "npm:19.0.0-rc-6230622a1a-20240610" - react-test-renderer: "npm:18.3.1" + react-server-dom-webpack: "npm:~19.0.0" + react-test-renderer: "npm:19.0.0" server-only: "npm:^0.0.1" stacktrace-js: "npm:^2.0.2" peerDependencies: @@ -20968,7 +24176,36 @@ __metadata: react-native: "*" bin: jest: bin/jest.js - checksum: 10/772421d867497cd20e73ffc6b56e432156da378213d411f48ce3a864ceef3f7ad783cc954f27c74bd071bec109399d8f965d68ecb0d512f868338a7dc254133e + checksum: 10/a5f3352a6041466ee207dbdc3fd6bc5a489e73d4298a77ab2c803380fc05deec2af1f08e0b74f0a3ba0f5c686b5138d372efcc281ec29ad3788faf44148736b2 + languageName: node + linkType: hard + +"jest-expo@npm:~53.0.5": + version: 53.0.7 + resolution: "jest-expo@npm:53.0.7" + dependencies: + "@expo/config": "npm:~11.0.10" + "@expo/json-file": "npm:^9.1.4" + "@jest/create-cache-key-function": "npm:^29.2.1" + "@jest/globals": "npm:^29.2.1" + babel-jest: "npm:^29.2.1" + find-up: "npm:^5.0.0" + jest-environment-jsdom: "npm:^29.2.1" + jest-snapshot: "npm:^29.2.1" + jest-watch-select-projects: "npm:^2.0.0" + jest-watch-typeahead: "npm:2.2.1" + json5: "npm:^2.2.3" + lodash: "npm:^4.17.19" + react-server-dom-webpack: "npm:~19.0.0" + react-test-renderer: "npm:19.0.0" + server-only: "npm:^0.0.1" + stacktrace-js: "npm:^2.0.2" + peerDependencies: + expo: "*" + react-native: "*" + bin: + jest: bin/jest.js + checksum: 10/115958df9594ba9ebf7815067ee264d4f0ecd0d981878adc36a4044c25c4b1cfd4e8c6a1e4550af3e46331499d0e303ba1d381c3dee359dbee1b4a553953ab40 languageName: node linkType: hard @@ -21166,6 +24403,15 @@ __metadata: languageName: node linkType: hard +"jest-snapshot-prettier@npm:prettier@^2, prettier@npm:^2.3.1, prettier@npm:^2.4.1": + version: 2.8.8 + resolution: "prettier@npm:2.8.8" + bin: + prettier: bin-prettier.js + checksum: 10/00cdb6ab0281f98306cd1847425c24cbaaa48a5ff03633945ab4c701901b8e96ad558eb0777364ffc312f437af9b5a07d0f45346266e8245beaf6247b9c62b24 + languageName: node + linkType: hard + "jest-snapshot@npm:^29.2.1, jest-snapshot@npm:^29.7.0": version: 29.7.0 resolution: "jest-snapshot@npm:29.7.0" @@ -21215,7 +24461,7 @@ __metadata: languageName: node linkType: hard -"jest-validate@npm:^29.6.3, jest-validate@npm:^29.7.0": +"jest-validate@npm:^29.7.0": version: 29.7.0 resolution: "jest-validate@npm:29.7.0" dependencies: @@ -21284,7 +24530,7 @@ __metadata: languageName: node linkType: hard -"jest-worker@npm:^29.6.3, jest-worker@npm:^29.7.0": +"jest-worker@npm:^29.7.0": version: 29.7.0 resolution: "jest-worker@npm:29.7.0" dependencies: @@ -21353,13 +24599,6 @@ __metadata: languageName: node linkType: hard -"join-component@npm:^1.1.0": - version: 1.1.0 - resolution: "join-component@npm:1.1.0" - checksum: 10/b904c2f98549e4195022caca3a7dc837f9706c670ff333f3d617f2aed23bce2841322a999734683b6ab8e202568ad810c11ff79b58a64df66888153f04750239 - languageName: node - linkType: hard - "js-cookie@npm:^3.0.1": version: 3.0.5 resolution: "js-cookie@npm:3.0.5" @@ -21425,13 +24664,6 @@ __metadata: languageName: node linkType: hard -"jsc-android@npm:^250231.0.0": - version: 250231.0.0 - resolution: "jsc-android@npm:250231.0.0" - checksum: 10/aa5cf773f5d6c4c6ecec42bfd9958b5bd5ec33db7ec87f66152fae96f142220b91b84e54b409ca643a9493dd1b0f273819d46aad8c0d7519c444280815ffb68e - languageName: node - linkType: hard - "jsc-safe-url@npm:^0.2.2, jsc-safe-url@npm:^0.2.4": version: 0.2.4 resolution: "jsc-safe-url@npm:0.2.4" @@ -21692,7 +24924,7 @@ __metadata: languageName: node linkType: hard -"kind-of@npm:^6.0.0, kind-of@npm:^6.0.2, kind-of@npm:^6.0.3": +"kind-of@npm:^6.0.2, kind-of@npm:^6.0.3": version: 6.0.3 resolution: "kind-of@npm:6.0.3" checksum: 10/5873d303fb36aad875b7538798867da2ae5c9e328d67194b0162a3659a627d22f742fc9c4ae95cd1704132a24b00cae5041fc00c0f6ef937dc17080dc4dbb962 @@ -21706,6 +24938,15 @@ __metadata: languageName: node linkType: hard +"lan-network@npm:^0.1.4": + version: 0.1.4 + resolution: "lan-network@npm:0.1.4" + bin: + lan-network: dist/lan-network-cli.js + checksum: 10/f30ceeac6a3698baa10dc88d927033d1867e27c1de8ad4947e4d68a5f50b23989d416e61c2204c42ca02925d8e9eee8c6b4eb80e7a50faad2b8134fa170a5a28 + languageName: node + linkType: hard + "language-subtag-registry@npm:^0.3.20": version: 0.3.23 resolution: "language-subtag-registry@npm:0.3.23" @@ -22124,7 +25365,7 @@ __metadata: languageName: node linkType: hard -"lodash.debounce@npm:^4.0.8": +"lodash.debounce@npm:4.0.8, lodash.debounce@npm:^4.0.8": version: 4.0.8 resolution: "lodash.debounce@npm:4.0.8" checksum: 10/cd0b2819786e6e80cb9f5cda26b1a8fc073daaf04e48d4cb462fa4663ec9adb3a5387aa22d7129e48eed1afa05b482e2a6b79bfc99b86886364449500cbb00fd @@ -22145,7 +25386,7 @@ __metadata: languageName: node linkType: hard -"lodash.memoize@npm:^4.1.2": +"lodash.memoize@npm:4.x, lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" checksum: 10/192b2168f310c86f303580b53acf81ab029761b9bd9caa9506a019ffea5f3363ea98d7e39e7e11e6b9917066c9d36a09a11f6fe16f812326390d8f3a54a1a6da @@ -22307,6 +25548,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.0.0": + version: 11.0.2 + resolution: "lru-cache@npm:11.0.2" + checksum: 10/25fcb66e9d91eaf17227c6abfe526a7bed5903de74f93bfde380eb8a13410c5e8d3f14fe447293f3f322a7493adf6f9f015c6f1df7a235ff24ec30f366e1c058 + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -22371,7 +25619,7 @@ __metadata: languageName: node linkType: hard -"make-dir@npm:^3.0.2, make-dir@npm:^3.1.0": +"make-dir@npm:^3.0.2": version: 3.1.0 resolution: "make-dir@npm:3.1.0" dependencies: @@ -22389,7 +25637,7 @@ __metadata: languageName: node linkType: hard -"make-error@npm:^1.1.1, make-error@npm:^1.3.6": +"make-error@npm:1.x, make-error@npm:^1.1.1, make-error@npm:^1.3.6": version: 1.3.6 resolution: "make-error@npm:1.3.6" checksum: 10/b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402 @@ -22473,14 +25721,10 @@ __metadata: languageName: node linkType: hard -"md5-file@npm:^3.2.3": - version: 3.2.3 - resolution: "md5-file@npm:3.2.3" - dependencies: - buffer-alloc: "npm:^1.1.0" - bin: - md5-file: cli.js - checksum: 10/a3738274ee0c5ce21e7c14a4b60e5de6b298740f8a37eeb502bb97a056e3f19ea0871418b4dd45ca9c70d2f1d6c79a19e9a320fba1c129b196cdf671e544c450 +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10/11df2eda46d092a6035479632e1ec865b8134bdfc4bd9e571a656f4191525404f13a283a515938c3a8de934dbfd9c09674d9da9fa831e6eb7e22b50b197d2edd languageName: node linkType: hard @@ -22495,17 +25739,6 @@ __metadata: languageName: node linkType: hard -"md5@npm:^2.2.1": - version: 2.3.0 - resolution: "md5@npm:2.3.0" - dependencies: - charenc: "npm:0.0.2" - crypt: "npm:0.0.2" - is-buffer: "npm:~1.1.6" - checksum: 10/88dce9fb8df1a084c2385726dcc18c7f54e0b64c261b5def7cdfe4928c4ee1cd68695c34108b4fab7ecceb05838c938aa411c6143df9fdc0026c4ddb4e4e72fa - languageName: node - linkType: hard - "mdast-util-find-and-replace@npm:^3.0.0": version: 3.0.1 resolution: "mdast-util-find-and-replace@npm:3.0.1" @@ -22855,155 +26088,148 @@ __metadata: languageName: node linkType: hard -"metro-babel-transformer@npm:0.81.0": - version: 0.81.0 - resolution: "metro-babel-transformer@npm:0.81.0" +"metro-babel-transformer@npm:0.82.1": + version: 0.82.1 + resolution: "metro-babel-transformer@npm:0.82.1" dependencies: "@babel/core": "npm:^7.25.2" flow-enums-runtime: "npm:^0.0.6" - hermes-parser: "npm:0.24.0" + hermes-parser: "npm:0.25.1" nullthrows: "npm:^1.1.1" - checksum: 10/183ccc4890cef88a0bfb8c9be34a471fe27e48bc33465b2ecfcc605475ec1c28ff27d870ce934ca282a199d61ed5a46d62b42553503f07c1f93adc6d2697b5d2 + checksum: 10/b456e2ddb09811d7e6cf8f8f32f690b19c2ef65c49a3c31535b18c07e4aa4cd2b399e4f42f7f98ca60dbee0f4b02a1055e6bad1f54cf6c6158185015561e912b languageName: node linkType: hard -"metro-cache-key@npm:0.81.0": - version: 0.81.0 - resolution: "metro-cache-key@npm:0.81.0" +"metro-cache-key@npm:0.82.1": + version: 0.82.1 + resolution: "metro-cache-key@npm:0.82.1" dependencies: flow-enums-runtime: "npm:^0.0.6" - checksum: 10/a96e4062ac0f4684f1d80c8b8c3da380c9d7be506c2bc14750d46a6850610c6e05cb1907cc5421393299f25f40575335e899667519d5435c95a09b0438619847 + checksum: 10/35b22c1ecd89a47c50e238d75b3d3ae78e6382299713f83a48823fa019214166a843ff7b212d51e1eed16768d081c0bead27f06201acc19e84aec8c7238dc3b7 languageName: node linkType: hard -"metro-cache@npm:0.81.0": - version: 0.81.0 - resolution: "metro-cache@npm:0.81.0" +"metro-cache@npm:0.82.1": + version: 0.82.1 + resolution: "metro-cache@npm:0.82.1" dependencies: exponential-backoff: "npm:^3.1.1" flow-enums-runtime: "npm:^0.0.6" - metro-core: "npm:0.81.0" - checksum: 10/20f01fea29dad35fe76fdb9e50ddc428a849696d2e37262ed80e4a96101f708ab1c3196846df0e7569b057267604cc50ffa51065ab6a1c0adafcdabe0615cc41 + metro-core: "npm:0.82.1" + checksum: 10/6766062e7b96bdd3d6792e2c9c494df7af784ca14061089420a5d09f762df81395815218ccb4bc5e87e4e9b95ca4b7d8aae7b829c6ca8989b3c3f4acf08d0a52 languageName: node linkType: hard -"metro-config@npm:0.81.0, metro-config@npm:^0.81.0": - version: 0.81.0 - resolution: "metro-config@npm:0.81.0" +"metro-config@npm:0.82.1, metro-config@npm:^0.82.0": + version: 0.82.1 + resolution: "metro-config@npm:0.82.1" dependencies: connect: "npm:^3.6.5" cosmiconfig: "npm:^5.0.5" flow-enums-runtime: "npm:^0.0.6" - jest-validate: "npm:^29.6.3" - metro: "npm:0.81.0" - metro-cache: "npm:0.81.0" - metro-core: "npm:0.81.0" - metro-runtime: "npm:0.81.0" - checksum: 10/f331e9b6dbbe9dbde2e34cbfc1f0a5f59ed1a02f0f64a9df5b2a2e8d4d8164264292d98ba5fb8c08e7973814a74609204370f3f488d74c573eb3e77bf06d08cc + jest-validate: "npm:^29.7.0" + metro: "npm:0.82.1" + metro-cache: "npm:0.82.1" + metro-core: "npm:0.82.1" + metro-runtime: "npm:0.82.1" + checksum: 10/09f0575069e21985ecc4377bcff8615c070009fcfa573ac27146fa1f24fce0db3c09f4212e4f3cb4a4cc266b44d70a5570ef87f686bf7d5bd98ed1ec2d1d93de languageName: node linkType: hard -"metro-core@npm:0.81.0, metro-core@npm:^0.81.0": - version: 0.81.0 - resolution: "metro-core@npm:0.81.0" +"metro-core@npm:0.82.1, metro-core@npm:^0.82.0": + version: 0.82.1 + resolution: "metro-core@npm:0.82.1" dependencies: flow-enums-runtime: "npm:^0.0.6" lodash.throttle: "npm:^4.1.1" - metro-resolver: "npm:0.81.0" - checksum: 10/ee6ea1372872949889f45b1f05ef21dc0d49966a7866d2d410b3d4145f5c45f8d3d4de3d3c5348ddcd8e8e6e1bd517971715a5435b6a03ce6ef775abcbb3559f + metro-resolver: "npm:0.82.1" + checksum: 10/43f6e017317cc8c7aae51248bb98e65b1118528caddf6473fb7de488ba1cdb4ca6a583c54811bbd848243bfa7db9ad423f63f414a44e4da88c189c43ca5289ca languageName: node linkType: hard -"metro-file-map@npm:0.81.0": - version: 0.81.0 - resolution: "metro-file-map@npm:0.81.0" +"metro-file-map@npm:0.82.1": + version: 0.82.1 + resolution: "metro-file-map@npm:0.82.1" dependencies: - anymatch: "npm:^3.0.3" - debug: "npm:^2.2.0" + debug: "npm:^4.4.0" fb-watchman: "npm:^2.0.0" flow-enums-runtime: "npm:^0.0.6" - fsevents: "npm:^2.3.2" graceful-fs: "npm:^4.2.4" invariant: "npm:^2.2.4" - jest-worker: "npm:^29.6.3" + jest-worker: "npm:^29.7.0" micromatch: "npm:^4.0.4" - node-abort-controller: "npm:^3.1.1" nullthrows: "npm:^1.1.1" walker: "npm:^1.0.7" - dependenciesMeta: - fsevents: - optional: true - checksum: 10/1bb3b66be5cbb9171674dbf2b635c4ec47cac53cdcb3fbaecba61d5730d6d99bfc1dbdfed8b2b0d745208e29024491138d9058a56ed541a7c774ef6486731bf1 + checksum: 10/9d461d12cdf52f9e056f3ef563dfbe8e39aef12c17e4d990f3d702d3d8f98c6c8e9dd38687e05cc54f852306b7bb84885a49bf527ecddade81efd33f7d9bc33b languageName: node linkType: hard -"metro-minify-terser@npm:0.81.0": - version: 0.81.0 - resolution: "metro-minify-terser@npm:0.81.0" +"metro-minify-terser@npm:0.82.1": + version: 0.82.1 + resolution: "metro-minify-terser@npm:0.82.1" dependencies: flow-enums-runtime: "npm:^0.0.6" terser: "npm:^5.15.0" - checksum: 10/53472e5d476613c652f0e8bdf68429c80c66b71dd9a559c2185d56f41a8463ba3431353d453d2e20615875d070389ec24247ddbce67c4d7783bfc85113af18e0 + checksum: 10/3c1d52cfc98257ae6898f2e9db67eae8ddabba89aff30b815bb2adcea9f4f02c78669c1df7878389d6fc5fc7f8138bcb543d06f68f5e1f1741cc32c504eded20 languageName: node linkType: hard -"metro-resolver@npm:0.81.0": - version: 0.81.0 - resolution: "metro-resolver@npm:0.81.0" +"metro-resolver@npm:0.82.1": + version: 0.82.1 + resolution: "metro-resolver@npm:0.82.1" dependencies: flow-enums-runtime: "npm:^0.0.6" - checksum: 10/b0f81dab785d8d533e1fd103072c173716b88055ff224a277f5a15ac52c151b19e1b95df53cf7854bd751ecf46fff00cea243e2d9986110f46b2f6df45615bf9 + checksum: 10/efb380b270be895e05bb45312d71f2f54fc37bad0724fbddc354c682e3eb6a3f1e0e02ad5c7ea9a7990bb352086ba8cbf35b3023d73fde8a2db3c391226f2eb2 languageName: node linkType: hard -"metro-runtime@npm:0.81.0, metro-runtime@npm:^0.81.0": - version: 0.81.0 - resolution: "metro-runtime@npm:0.81.0" +"metro-runtime@npm:0.82.1, metro-runtime@npm:^0.82.0": + version: 0.82.1 + resolution: "metro-runtime@npm:0.82.1" dependencies: "@babel/runtime": "npm:^7.25.0" flow-enums-runtime: "npm:^0.0.6" - checksum: 10/fdb87c44adc73e217993f2d1f33d7c3ef17d4707747993eb38d5fda5d943e6ffe95e7d82cdc9a9ae7ef56fe56c62865ca3b424e72efa2d7bd2560cd1bb10180c + checksum: 10/3a6ca9b780d031f4b94b35cb1c7bce87c780e1e258f4b6408de60fb49a2510c0b212f4d6828a6866e4abd1ee41543f21a2c71bf0632f7e7adb9afac36b09a765 languageName: node linkType: hard -"metro-source-map@npm:0.81.0, metro-source-map@npm:^0.81.0": - version: 0.81.0 - resolution: "metro-source-map@npm:0.81.0" +"metro-source-map@npm:0.82.1, metro-source-map@npm:^0.82.0": + version: 0.82.1 + resolution: "metro-source-map@npm:0.82.1" dependencies: "@babel/traverse": "npm:^7.25.3" "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3" "@babel/types": "npm:^7.25.2" flow-enums-runtime: "npm:^0.0.6" invariant: "npm:^2.2.4" - metro-symbolicate: "npm:0.81.0" + metro-symbolicate: "npm:0.82.1" nullthrows: "npm:^1.1.1" - ob1: "npm:0.81.0" + ob1: "npm:0.82.1" source-map: "npm:^0.5.6" vlq: "npm:^1.0.0" - checksum: 10/4092f3faa8d56705d77d02a15fcab46eaad68d3225796981235635e300ddf5b34db58a9ebfc3e74c4e95fee9775bf22d482840f08f5c2014befc4d8a12b50f7d + checksum: 10/1ed5cc0a9a90039f992383d66b17aaa8fc2c18c0571140ad3fa4daa214835254d52fb4fb63dae89fbf3be629591c969aa825e055da256abb4a31ba84c010726f languageName: node linkType: hard -"metro-symbolicate@npm:0.81.0": - version: 0.81.0 - resolution: "metro-symbolicate@npm:0.81.0" +"metro-symbolicate@npm:0.82.1": + version: 0.82.1 + resolution: "metro-symbolicate@npm:0.82.1" dependencies: flow-enums-runtime: "npm:^0.0.6" invariant: "npm:^2.2.4" - metro-source-map: "npm:0.81.0" + metro-source-map: "npm:0.82.1" nullthrows: "npm:^1.1.1" source-map: "npm:^0.5.6" - through2: "npm:^2.0.1" vlq: "npm:^1.0.0" bin: metro-symbolicate: src/index.js - checksum: 10/d612994ac2857fae713f6bf84c64c94c8e4c745b4532bfa11263623f2da9d7966709960b374c40726ffd40aabbc689924d4117c5c2fc380e024720bc8164b620 + checksum: 10/58420c12ffad6b4d2c2addf98d965ebe5c2a0abd716d25384ac8a1d10ee5728057537009c73f40e672263b81bb83c5ed292f1076864de1295fdadfe5e4ff1d03 languageName: node linkType: hard -"metro-transform-plugins@npm:0.81.0": - version: 0.81.0 - resolution: "metro-transform-plugins@npm:0.81.0" +"metro-transform-plugins@npm:0.82.1": + version: 0.82.1 + resolution: "metro-transform-plugins@npm:0.82.1" dependencies: "@babel/core": "npm:^7.25.2" "@babel/generator": "npm:^7.25.0" @@ -23011,34 +26237,34 @@ __metadata: "@babel/traverse": "npm:^7.25.3" flow-enums-runtime: "npm:^0.0.6" nullthrows: "npm:^1.1.1" - checksum: 10/acf4e7133c815c39c459ea55b72a6217eb5aaefe7a48e2c6d98ec0ce9c1ac76a2eb1d89d6b50c7f836a942e1a76a722c88eab0ffe51f31f30433a7b20c399ea0 + checksum: 10/f61b7e9629717f9e8455c49f58a25793c26db1ea74770d1c1138be411b00014a656f04c00cf6dc625d95343cb291135083260d5faaa7c5551bb3fd9ec67c4a7a languageName: node linkType: hard -"metro-transform-worker@npm:0.81.0": - version: 0.81.0 - resolution: "metro-transform-worker@npm:0.81.0" +"metro-transform-worker@npm:0.82.1": + version: 0.82.1 + resolution: "metro-transform-worker@npm:0.82.1" dependencies: "@babel/core": "npm:^7.25.2" "@babel/generator": "npm:^7.25.0" "@babel/parser": "npm:^7.25.3" "@babel/types": "npm:^7.25.2" flow-enums-runtime: "npm:^0.0.6" - metro: "npm:0.81.0" - metro-babel-transformer: "npm:0.81.0" - metro-cache: "npm:0.81.0" - metro-cache-key: "npm:0.81.0" - metro-minify-terser: "npm:0.81.0" - metro-source-map: "npm:0.81.0" - metro-transform-plugins: "npm:0.81.0" + metro: "npm:0.82.1" + metro-babel-transformer: "npm:0.82.1" + metro-cache: "npm:0.82.1" + metro-cache-key: "npm:0.82.1" + metro-minify-terser: "npm:0.82.1" + metro-source-map: "npm:0.82.1" + metro-transform-plugins: "npm:0.82.1" nullthrows: "npm:^1.1.1" - checksum: 10/6aca50e38add14aa4cb473938cbce1da5aac822dbc1934d592effc59f14fad891b63aa44b432ccfc5feb79792a186678565e7624ecdea70d139f006006ced5ba + checksum: 10/708877e205ed4c06c1a371b5ad4fa29b5eb94e6b80c4fe48440aa1bf8e74a4b91d0bf0e5dbbde32fde21ed55aed34385ba14f47331308ac77da8fdb381419820 languageName: node linkType: hard -"metro@npm:0.81.0, metro@npm:^0.81.0": - version: 0.81.0 - resolution: "metro@npm:0.81.0" +"metro@npm:0.82.1, metro@npm:^0.82.0": + version: 0.82.1 + resolution: "metro@npm:0.82.1" dependencies: "@babel/code-frame": "npm:^7.24.7" "@babel/core": "npm:^7.25.2" @@ -23051,40 +26277,38 @@ __metadata: chalk: "npm:^4.0.0" ci-info: "npm:^2.0.0" connect: "npm:^3.6.5" - debug: "npm:^2.2.0" - denodeify: "npm:^1.2.1" + debug: "npm:^4.4.0" error-stack-parser: "npm:^2.0.6" flow-enums-runtime: "npm:^0.0.6" graceful-fs: "npm:^4.2.4" - hermes-parser: "npm:0.24.0" + hermes-parser: "npm:0.25.1" image-size: "npm:^1.0.2" invariant: "npm:^2.2.4" - jest-worker: "npm:^29.6.3" + jest-worker: "npm:^29.7.0" jsc-safe-url: "npm:^0.2.2" lodash.throttle: "npm:^4.1.1" - metro-babel-transformer: "npm:0.81.0" - metro-cache: "npm:0.81.0" - metro-cache-key: "npm:0.81.0" - metro-config: "npm:0.81.0" - metro-core: "npm:0.81.0" - metro-file-map: "npm:0.81.0" - metro-resolver: "npm:0.81.0" - metro-runtime: "npm:0.81.0" - metro-source-map: "npm:0.81.0" - metro-symbolicate: "npm:0.81.0" - metro-transform-plugins: "npm:0.81.0" - metro-transform-worker: "npm:0.81.0" + metro-babel-transformer: "npm:0.82.1" + metro-cache: "npm:0.82.1" + metro-cache-key: "npm:0.82.1" + metro-config: "npm:0.82.1" + metro-core: "npm:0.82.1" + metro-file-map: "npm:0.82.1" + metro-resolver: "npm:0.82.1" + metro-runtime: "npm:0.82.1" + metro-source-map: "npm:0.82.1" + metro-symbolicate: "npm:0.82.1" + metro-transform-plugins: "npm:0.82.1" + metro-transform-worker: "npm:0.82.1" mime-types: "npm:^2.1.27" nullthrows: "npm:^1.1.1" serialize-error: "npm:^2.1.0" source-map: "npm:^0.5.6" - strip-ansi: "npm:^6.0.0" throat: "npm:^5.0.0" ws: "npm:^7.5.10" yargs: "npm:^17.6.2" bin: metro: src/cli.js - checksum: 10/56955726fee6da4d6b4666843969f0008ce7c4c43d8c3659a20eac4391d6cac41d6b0568ed6e49f3221fa2d01e60261f07bceafbc606db4519a4000a37c0edaf + checksum: 10/ab9a9ac01fbeed87c20b9bf794cc49080301f60e0004b46334c049b9ba56bcab6105b00207d3b319884ea271ff29fd81d017264cdf4d3f42318793f5ca56f857 languageName: node linkType: hard @@ -23539,7 +26763,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8, micromatch@npm:~4.0.8": +"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:~4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -23651,6 +26875,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.0.0": + version: 10.0.1 + resolution: "minimatch@npm:10.0.1" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10/082e7ccbc090d5f8c4e4e029255d5a1d1e3af37bda837da2b8b0085b1503a1210c91ac90d9ebfe741d8a5f286ece820a1abb4f61dc1f82ce602a055d461d93f3 + languageName: node + linkType: hard + "minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -23669,7 +26902,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.4": +"minimatch@npm:^9.0.0, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": version: 9.0.5 resolution: "minimatch@npm:9.0.5" dependencies: @@ -23756,13 +26989,6 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^5.0.0": - version: 5.0.0 - resolution: "minipass@npm:5.0.0" - checksum: 10/61682162d29f45d3152b78b08bab7fb32ca10899bc5991ffe98afc18c9e9543bd1e3be94f8b8373ba6262497db63607079dc242ea62e43e7b2270837b7347c93 - languageName: node - linkType: hard - "minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": version: 7.1.2 resolution: "minipass@npm:7.1.2" @@ -23770,16 +26996,6 @@ __metadata: languageName: node linkType: hard -"minizlib@npm:^2.1.1": - version: 2.1.2 - resolution: "minizlib@npm:2.1.2" - dependencies: - minipass: "npm:^3.0.0" - yallist: "npm:^4.0.0" - checksum: 10/ae0f45436fb51344dcb87938446a32fbebb540d0e191d63b35e1c773d47512e17307bf54aa88326cc6d176594d00e4423563a091f7266c2f9a6872cdc1e234d1 - languageName: node - linkType: hard - "minizlib@npm:^3.0.1": version: 3.0.1 resolution: "minizlib@npm:3.0.1" @@ -23790,6 +27006,22 @@ __metadata: languageName: node linkType: hard +"mitt@npm:^3.0.0": + version: 3.0.1 + resolution: "mitt@npm:3.0.1" + checksum: 10/287c70d8e73ffc25624261a4989c783768aed95ecb60900f051d180cf83e311e3e59865bfd6e9d029cdb149dc20ba2f128a805e9429c5c4ce33b1416c65bbd14 + languageName: node + linkType: hard + +"mixpanel-browser@npm:^2.66.0": + version: 2.66.0 + resolution: "mixpanel-browser@npm:2.66.0" + dependencies: + rrweb: "npm:2.0.0-alpha.18" + checksum: 10/e968608d9c41e3c8dfdf3ca154e55e483a5f5d4e97533b1698b5153eb247701d8c77825c15a3a7d0dd50e12361828487d8bb61e5b63bce88c4b4c7ad4a39ebc6 + languageName: node + linkType: hard + "mkdirp@npm:^0.5.1": version: 0.5.6 resolution: "mkdirp@npm:0.5.6" @@ -23801,7 +27033,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4": +"mkdirp@npm:^1.0.4": version: 1.0.4 resolution: "mkdirp@npm:1.0.4" bin: @@ -23870,13 +27102,6 @@ __metadata: languageName: node linkType: hard -"mrmime@npm:^1.0.0": - version: 1.0.1 - resolution: "mrmime@npm:1.0.1" - checksum: 10/a157e833ffe76648ab2107319deeff024b80b136ec66c60fae9d339009a1bb72c57ec1feecfd6a905dfd3df29e2299e850bff84b69cad790cc9bd9ab075834d1 - languageName: node - linkType: hard - "mrmime@npm:^2.0.0": version: 2.0.0 resolution: "mrmime@npm:2.0.0" @@ -23898,9 +27123,9 @@ __metadata: languageName: node linkType: hard -"msw@npm:^2.7.0": - version: 2.7.0 - resolution: "msw@npm:2.7.0" +"msw@npm:^2.7.3": + version: 2.7.3 + resolution: "msw@npm:2.7.3" dependencies: "@bundled-es-modules/cookie": "npm:^2.0.1" "@bundled-es-modules/statuses": "npm:^1.0.1" @@ -23927,7 +27152,7 @@ __metadata: optional: true bin: msw: cli/index.js - checksum: 10/165ccf37d90da0d5271fdb8e01f89f48f7a60fb810038ff73d18c0e5e5ddfdb1266002d19cde61b9ae689ef37c39499b10d9d07e0d16662a31630ce9adce1d77 + checksum: 10/f193329a68fc22e477a6f8504aa44a92bd12847f2eeac1dfbd8ec1cc43ff293112ec067de1c7fe312ba02beecb313fb00aeeebf5817432b57af2d796b2dff2fa languageName: node linkType: hard @@ -24035,12 +27260,12 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^5.0.7": - version: 5.0.9 - resolution: "nanoid@npm:5.0.9" +"nanoid@npm:^5.1.2": + version: 5.1.5 + resolution: "nanoid@npm:5.1.5" bin: nanoid: bin/nanoid.js - checksum: 10/8a3f9104f81095e3e4785f58caae47a05755599824b8611b9730cbf73db706b664f100e6189f8303f08764f144d499613d8e4a39e83125c53f4b4986d6576621 + checksum: 10/6de2d006b51c983be385ef7ee285f7f2a57bd96f8c0ca881c4111461644bd81fafc2544f8e07cb834ca0f3e0f3f676c1fe78052183f008b0809efe6e273119f5 languageName: node linkType: hard @@ -24112,7 +27337,7 @@ __metadata: languageName: node linkType: hard -"next@npm:*, next@npm:^15.1.2": +"next@npm:*": version: 15.1.2 resolution: "next@npm:15.1.2" dependencies: @@ -24173,10 +27398,125 @@ __metadata: languageName: node linkType: hard -"nice-try@npm:^1.0.4": - version: 1.0.5 - resolution: "nice-try@npm:1.0.5" - checksum: 10/0b4af3b5bb5d86c289f7a026303d192a7eb4417231fe47245c460baeabae7277bcd8fd9c728fb6bd62c30b3e15cd6620373e2cf33353b095d8b403d3e8a15aff +"next@npm:15.2.4": + version: 15.2.4 + resolution: "next@npm:15.2.4" + dependencies: + "@next/env": "npm:15.2.4" + "@next/swc-darwin-arm64": "npm:15.2.4" + "@next/swc-darwin-x64": "npm:15.2.4" + "@next/swc-linux-arm64-gnu": "npm:15.2.4" + "@next/swc-linux-arm64-musl": "npm:15.2.4" + "@next/swc-linux-x64-gnu": "npm:15.2.4" + "@next/swc-linux-x64-musl": "npm:15.2.4" + "@next/swc-win32-arm64-msvc": "npm:15.2.4" + "@next/swc-win32-x64-msvc": "npm:15.2.4" + "@swc/counter": "npm:0.1.3" + "@swc/helpers": "npm:0.5.15" + busboy: "npm:1.6.0" + caniuse-lite: "npm:^1.0.30001579" + postcss: "npm:8.4.31" + sharp: "npm:^0.33.5" + styled-jsx: "npm:5.1.6" + peerDependencies: + "@opentelemetry/api": ^1.1.0 + "@playwright/test": ^1.41.2 + babel-plugin-react-compiler: "*" + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + dependenciesMeta: + "@next/swc-darwin-arm64": + optional: true + "@next/swc-darwin-x64": + optional: true + "@next/swc-linux-arm64-gnu": + optional: true + "@next/swc-linux-arm64-musl": + optional: true + "@next/swc-linux-x64-gnu": + optional: true + "@next/swc-linux-x64-musl": + optional: true + "@next/swc-win32-arm64-msvc": + optional: true + "@next/swc-win32-x64-msvc": + optional: true + sharp: + optional: true + peerDependenciesMeta: + "@opentelemetry/api": + optional: true + "@playwright/test": + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + bin: + next: dist/bin/next + checksum: 10/83405aec9c12e2c62f062bae0b1089588632031543bc7bbfb3a0d924aecf4a2a15a020d5c26fcd6db88c1ff1762ec94d42ac03c7c047b824719685e0f68d5086 + languageName: node + linkType: hard + +"next@patch:next@15.2.4#../../.yarn/patches/next-npm-15.2.4-06a6671f62.patch::locator=%40safe-global%2Fweb%40workspace%3Aapps%2Fweb": + version: 15.2.4 + resolution: "next@patch:next@npm%3A15.2.4#../../.yarn/patches/next-npm-15.2.4-06a6671f62.patch::version=15.2.4&hash=f475b4&locator=%40safe-global%2Fweb%40workspace%3Aapps%2Fweb" + dependencies: + "@next/env": "npm:15.2.4" + "@next/swc-darwin-arm64": "npm:15.2.4" + "@next/swc-darwin-x64": "npm:15.2.4" + "@next/swc-linux-arm64-gnu": "npm:15.2.4" + "@next/swc-linux-arm64-musl": "npm:15.2.4" + "@next/swc-linux-x64-gnu": "npm:15.2.4" + "@next/swc-linux-x64-musl": "npm:15.2.4" + "@next/swc-win32-arm64-msvc": "npm:15.2.4" + "@next/swc-win32-x64-msvc": "npm:15.2.4" + "@swc/counter": "npm:0.1.3" + "@swc/helpers": "npm:0.5.15" + busboy: "npm:1.6.0" + caniuse-lite: "npm:^1.0.30001579" + postcss: "npm:8.4.31" + sharp: "npm:^0.33.5" + styled-jsx: "npm:5.1.6" + peerDependencies: + "@opentelemetry/api": ^1.1.0 + "@playwright/test": ^1.41.2 + babel-plugin-react-compiler: "*" + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + dependenciesMeta: + "@next/swc-darwin-arm64": + optional: true + "@next/swc-darwin-x64": + optional: true + "@next/swc-linux-arm64-gnu": + optional: true + "@next/swc-linux-arm64-musl": + optional: true + "@next/swc-linux-x64-gnu": + optional: true + "@next/swc-linux-x64-musl": + optional: true + "@next/swc-win32-arm64-msvc": + optional: true + "@next/swc-win32-x64-msvc": + optional: true + sharp: + optional: true + peerDependenciesMeta: + "@opentelemetry/api": + optional: true + "@playwright/test": + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + bin: + next: dist/bin/next + checksum: 10/668ab1de0fb2017b2edd04160d8b6d2d93c74ca43a3fd7ec1ed7362e51cc07908649c15fbfbc8e59e2d8eb0ecdd4f113c01524293a1592eb9b60ffd79db61322 languageName: node linkType: hard @@ -24190,7 +27530,7 @@ __metadata: languageName: node linkType: hard -"node-abort-controller@npm:^3.0.1, node-abort-controller@npm:^3.1.1": +"node-abort-controller@npm:^3.0.1": version: 3.1.1 resolution: "node-abort-controller@npm:3.1.1" checksum: 10/0a2cdb7ec0aeaf3cb31e1ca0e192f5add48f1c5c9c9ed822129f9dddbd9432f69b7425982f94ce803c56a2104884530aa67cd57696e5774b2e5b8ec2f58de042 @@ -24249,7 +27589,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2.2.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12, node-fetch@npm:^2.7.0": +"node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12, node-fetch@npm:^2.7.0": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: @@ -24263,7 +27603,7 @@ __metadata: languageName: node linkType: hard -"node-forge@npm:^1, node-forge@npm:^1.2.1, node-forge@npm:^1.3.1": +"node-forge@npm:^1.2.1, node-forge@npm:^1.3.1": version: 1.3.1 resolution: "node-forge@npm:1.3.1" checksum: 10/05bab6868633bf9ad4c3b1dd50ec501c22ffd69f556cdf169a00998ca1d03e8107a6032ba013852f202035372021b845603aeccd7dfcb58cdb7430013b3daa8d @@ -24352,6 +27692,13 @@ __metadata: languageName: node linkType: hard +"node-releases@npm:^2.0.18": + version: 2.0.18 + resolution: "node-releases@npm:2.0.18" + checksum: 10/241e5fa9556f1c12bafb83c6c3e94f8cf3d8f2f8f904906ecef6e10bcaa1d59aa61212d4651bec70052015fc54bd3fdcdbe7fc0f638a17e6685aa586c076ec4e + languageName: node + linkType: hard + "node-releases@npm:^2.0.19": version: 2.0.19 resolution: "node-releases@npm:2.0.19" @@ -24413,15 +27760,6 @@ __metadata: languageName: node linkType: hard -"npm-run-path@npm:^2.0.0": - version: 2.0.2 - resolution: "npm-run-path@npm:2.0.2" - dependencies: - path-key: "npm:^2.0.0" - checksum: 10/acd5ad81648ba4588ba5a8effb1d98d2b339d31be16826a118d50f182a134ac523172101b82eab1d01cb4c2ba358e857d54cfafd8163a1ffe7bd52100b741125 - languageName: node - linkType: hard - "npm-run-path@npm:^4.0.0, npm-run-path@npm:^4.0.1": version: 4.0.1 resolution: "npm-run-path@npm:4.0.1" @@ -24539,12 +27877,12 @@ __metadata: languageName: node linkType: hard -"ob1@npm:0.81.0": - version: 0.81.0 - resolution: "ob1@npm:0.81.0" +"ob1@npm:0.82.1": + version: 0.82.1 + resolution: "ob1@npm:0.82.1" dependencies: flow-enums-runtime: "npm:^0.0.6" - checksum: 10/f3215ccf72604b4db5f9cfc6c83454a136a035ffd26faffec2c100d5810b87599cc95e167888320f3865959a5f9762c03de20a9e40cf66fc13706886820a9523 + checksum: 10/726b28f8f8aafef91ad3de9111fe5809e07bdde45d59e3f9d3bfdd868c028c6616fb80836df54d0518c3d8ed411b66a0c09f828e2637a355992e5121988d0c99 languageName: node linkType: hard @@ -24562,6 +27900,13 @@ __metadata: languageName: node linkType: hard +"object-inspect@npm:^1.13.4": + version: 1.13.4 + resolution: "object-inspect@npm:1.13.4" + checksum: 10/aa13b1190ad3e366f6c83ad8a16ed37a19ed57d267385aa4bfdccda833d7b90465c057ff6c55d035a6b2e52c1a2295582b294217a0a3a1ae7abdd6877ef781fb + languageName: node + linkType: hard + "object-is@npm:^1.1.5": version: 1.1.6 resolution: "object-is@npm:1.1.6" @@ -24586,6 +27931,20 @@ __metadata: languageName: node linkType: hard +"object.assign@npm:^4.1.0, object.assign@npm:^4.1.7": + version: 4.1.7 + resolution: "object.assign@npm:4.1.7" + dependencies: + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.3" + define-properties: "npm:^1.2.1" + es-object-atoms: "npm:^1.0.0" + has-symbols: "npm:^1.1.0" + object-keys: "npm:^1.1.1" + checksum: 10/3fe28cdd779f2a728a9a66bd688679ba231a2b16646cd1e46b528fe7c947494387dda4bc189eff3417f3717ef4f0a8f2439347cf9a9aa3cef722fbfd9f615587 + languageName: node + linkType: hard + "object.assign@npm:^4.1.4, object.assign@npm:^4.1.5": version: 4.1.5 resolution: "object.assign@npm:4.1.5" @@ -24609,6 +27968,18 @@ __metadata: languageName: node linkType: hard +"object.entries@npm:^1.1.9": + version: 1.1.9 + resolution: "object.entries@npm:1.1.9" + dependencies: + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.4" + define-properties: "npm:^1.2.1" + es-object-atoms: "npm:^1.1.1" + checksum: 10/24163ab1e1e013796693fc5f5d349e8b3ac0b6a34a7edb6c17d3dd45c6a8854145780c57d302a82512c1582f63720f4b4779d6c1cfba12cbb1420b978802d8a3 + languageName: node + linkType: hard + "object.fromentries@npm:^2.0.8": version: 2.0.8 resolution: "object.fromentries@npm:2.0.8" @@ -24643,6 +28014,18 @@ __metadata: languageName: node linkType: hard +"object.values@npm:^1.2.1": + version: 1.2.1 + resolution: "object.values@npm:1.2.1" + dependencies: + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.3" + define-properties: "npm:^1.2.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10/f5ec9eccdefeaaa834b089c525663436812a65ff13de7964a1c3a9110f32054f2d58aa476a645bb14f75a79f3fe1154fb3e7bfdae7ac1e80affe171b2ef74bce + languageName: node + linkType: hard + "objectorarray@npm:^1.0.5": version: 1.0.5 resolution: "objectorarray@npm:1.0.5" @@ -24840,18 +28223,70 @@ __metadata: languageName: node linkType: hard -"outvariant@npm:^1.4.0, outvariant@npm:^1.4.3": - version: 1.4.3 - resolution: "outvariant@npm:1.4.3" - checksum: 10/3a7582745850cb344d49641867a4c080858c54f4091afd91b9c0765ba6e471c2bc841348f0fff344845ddd0a4db42fd5d68c6f7ebaf32d4b676a3a9987b2488a +"outvariant@npm:^1.4.0, outvariant@npm:^1.4.3": + version: 1.4.3 + resolution: "outvariant@npm:1.4.3" + checksum: 10/3a7582745850cb344d49641867a4c080858c54f4091afd91b9c0765ba6e471c2bc841348f0fff344845ddd0a4db42fd5d68c6f7ebaf32d4b676a3a9987b2488a + languageName: node + linkType: hard + +"own-keys@npm:^1.0.1": + version: 1.0.1 + resolution: "own-keys@npm:1.0.1" + dependencies: + get-intrinsic: "npm:^1.2.6" + object-keys: "npm:^1.1.1" + safe-push-apply: "npm:^1.0.0" + checksum: 10/ab4bb3b8636908554fc19bf899e225444195092864cb61503a0d048fdaf662b04be2605b636a4ffeaf6e8811f6fcfa8cbb210ec964c0eb1a41eb853e1d5d2f41 + languageName: node + linkType: hard + +"ox@npm:0.1.2": + version: 0.1.2 + resolution: "ox@npm:0.1.2" + dependencies: + "@adraffy/ens-normalize": "npm:^1.10.1" + "@noble/curves": "npm:^1.6.0" + "@noble/hashes": "npm:^1.5.0" + "@scure/bip32": "npm:^1.5.0" + "@scure/bip39": "npm:^1.4.0" + abitype: "npm:^1.0.6" + eventemitter3: "npm:5.0.1" + peerDependencies: + typescript: ">=5.4.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/cba00f13289599ff03cee3dbc19167c1d0f01829379d119f962b4e951ee2bf0d14491c7a45974e6a2a745117b13b22e9e4131d285e1f5247ea4e1cbc43c5c3d8 + languageName: node + linkType: hard + +"ox@npm:0.6.7": + version: 0.6.7 + resolution: "ox@npm:0.6.7" + dependencies: + "@adraffy/ens-normalize": "npm:^1.10.1" + "@noble/curves": "npm:^1.6.0" + "@noble/hashes": "npm:^1.5.0" + "@scure/bip32": "npm:^1.5.0" + "@scure/bip39": "npm:^1.4.0" + abitype: "npm:^1.0.6" + eventemitter3: "npm:5.0.1" + peerDependencies: + typescript: ">=5.4.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/442fb31e1afb68922bf942025930d8cd6d8c677696e9a6de308008b3608669f22127cadbc0f77181e012d23d7b74318e5f85e63b06b16eecbc887d7fac32a6dc languageName: node linkType: hard -"ox@npm:0.1.2": - version: 0.1.2 - resolution: "ox@npm:0.1.2" +"ox@npm:0.7.1": + version: 0.7.1 + resolution: "ox@npm:0.7.1" dependencies: "@adraffy/ens-normalize": "npm:^1.10.1" + "@noble/ciphers": "npm:^1.3.0" "@noble/curves": "npm:^1.6.0" "@noble/hashes": "npm:^1.5.0" "@scure/bip32": "npm:^1.5.0" @@ -24863,14 +28298,40 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/cba00f13289599ff03cee3dbc19167c1d0f01829379d119f962b4e951ee2bf0d14491c7a45974e6a2a745117b13b22e9e4131d285e1f5247ea4e1cbc43c5c3d8 + checksum: 10/761e941b6ca6d3a84235bb19a7e1f282e54a8cbc1374aaf4ce0232de50d42cd351074ef3e83d09af71f7522b085cfa92168adb19744dac2556fe83b3bba46f71 languageName: node linkType: hard -"p-finally@npm:^1.0.0": - version: 1.0.0 - resolution: "p-finally@npm:1.0.0" - checksum: 10/93a654c53dc805dd5b5891bab16eb0ea46db8f66c4bfd99336ae929323b1af2b70a8b0654f8f1eae924b2b73d037031366d645f1fd18b3d30cbd15950cc4b1d4 +"oxc-transform@npm:^0.47.1": + version: 0.47.1 + resolution: "oxc-transform@npm:0.47.1" + dependencies: + "@oxc-transform/binding-darwin-arm64": "npm:0.47.1" + "@oxc-transform/binding-darwin-x64": "npm:0.47.1" + "@oxc-transform/binding-linux-arm64-gnu": "npm:0.47.1" + "@oxc-transform/binding-linux-arm64-musl": "npm:0.47.1" + "@oxc-transform/binding-linux-x64-gnu": "npm:0.47.1" + "@oxc-transform/binding-linux-x64-musl": "npm:0.47.1" + "@oxc-transform/binding-win32-arm64-msvc": "npm:0.47.1" + "@oxc-transform/binding-win32-x64-msvc": "npm:0.47.1" + dependenciesMeta: + "@oxc-transform/binding-darwin-arm64": + optional: true + "@oxc-transform/binding-darwin-x64": + optional: true + "@oxc-transform/binding-linux-arm64-gnu": + optional: true + "@oxc-transform/binding-linux-arm64-musl": + optional: true + "@oxc-transform/binding-linux-x64-gnu": + optional: true + "@oxc-transform/binding-linux-x64-musl": + optional: true + "@oxc-transform/binding-win32-arm64-msvc": + optional: true + "@oxc-transform/binding-win32-x64-msvc": + optional: true + checksum: 10/3845f9cfc5d80724c027c554bca88f66dd4916ab0c5667fbe1eeb00d7da41870f9a1ef86b6649df4e6f0a9ba76fe8158f968cbfeed896c5da9b3f0358af04485 languageName: node linkType: hard @@ -25067,7 +28528,26 @@ __metadata: languageName: node linkType: hard -"parse5@npm:^7.0.0, parse5@npm:^7.1.1": +"parse5-htmlparser2-tree-adapter@npm:^7.0.0": + version: 7.1.0 + resolution: "parse5-htmlparser2-tree-adapter@npm:7.1.0" + dependencies: + domhandler: "npm:^5.0.3" + parse5: "npm:^7.0.0" + checksum: 10/75910af9137451e9c53e1e0d712f7393f484e89e592b1809ee62ad6cedd61b98daeaa5206ff5d9f06778002c91fac311afedde4880e1916fdb44fa71199dae73 + languageName: node + linkType: hard + +"parse5-parser-stream@npm:^7.1.2": + version: 7.1.2 + resolution: "parse5-parser-stream@npm:7.1.2" + dependencies: + parse5: "npm:^7.0.0" + checksum: 10/75b232d460bce6bd0e35012750a78ef034f40ccf550b7c6cec3122395af6b4553202ad3663ad468cf537ead5a2e13b6727670395fd0ff548faccad1dc2dc93cf + languageName: node + linkType: hard + +"parse5@npm:^7.0.0, parse5@npm:^7.1.1, parse5@npm:^7.1.2": version: 7.2.1 resolution: "parse5@npm:7.2.1" dependencies: @@ -25138,13 +28618,6 @@ __metadata: languageName: node linkType: hard -"path-key@npm:^2.0.0, path-key@npm:^2.0.1": - version: 2.0.1 - resolution: "path-key@npm:2.0.1" - checksum: 10/6e654864e34386a2a8e6bf72cf664dcabb76574dd54013add770b374384d438aca95f4357bb26935b514a4e4c2c9b19e191f2200b282422a76ee038b9258c5e7 - languageName: node - linkType: hard - "path-key@npm:^3.0.0, path-key@npm:^3.1.0": version: 3.1.1 resolution: "path-key@npm:3.1.1" @@ -25176,6 +28649,16 @@ __metadata: languageName: node linkType: hard +"path-scurry@npm:^2.0.0": + version: 2.0.0 + resolution: "path-scurry@npm:2.0.0" + dependencies: + lru-cache: "npm:^11.0.0" + minipass: "npm:^7.1.2" + checksum: 10/285ae0c2d6c34ae91dc1d5378ede21981c9a2f6de1ea9ca5a88b5a270ce9763b83dbadc7a324d512211d8d36b0c540427d3d0817030849d97a60fa840a2c59ec + languageName: node + linkType: hard + "path-to-regexp@npm:^6.3.0": version: 6.3.0 resolution: "path-to-regexp@npm:6.3.0" @@ -25492,7 +28975,7 @@ __metadata: languageName: node linkType: hard -"postcss-modules-local-by-default@npm:^4.0.0, postcss-modules-local-by-default@npm:^4.0.5": +"postcss-modules-local-by-default@npm:^4.0.0": version: 4.2.0 resolution: "postcss-modules-local-by-default@npm:4.2.0" dependencies: @@ -25505,6 +28988,19 @@ __metadata: languageName: node linkType: hard +"postcss-modules-local-by-default@npm:^4.0.5": + version: 4.1.0 + resolution: "postcss-modules-local-by-default@npm:4.1.0" + dependencies: + icss-utils: "npm:^5.0.0" + postcss-selector-parser: "npm:^7.0.0" + postcss-value-parser: "npm:^4.1.0" + peerDependencies: + postcss: ^8.1.0 + checksum: 10/1ea247c6dd3d36beb4c849bcf3bc7eab48ee06d91a0c4cc299b9e1c30c2aa384cfaef95019e475a2fb64693edf08fd3633db8f000dc4dbd1e4979c779bdc902c + languageName: node + linkType: hard + "postcss-modules-scope@npm:^3.0.0, postcss-modules-scope@npm:^3.2.0": version: 3.2.1 resolution: "postcss-modules-scope@npm:3.2.1" @@ -25589,16 +29085,7 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^2.3.1, prettier@npm:^2.4.1": - version: 2.8.8 - resolution: "prettier@npm:2.8.8" - bin: - prettier: bin-prettier.js - checksum: 10/00cdb6ab0281f98306cd1847425c24cbaaa48a5ff03633945ab4c701901b8e96ad558eb0777364ffc312f437af9b5a07d0f45346266e8245beaf6247b9c62b24 - languageName: node - linkType: hard - -"prettier@npm:^3.2.5, prettier@npm:^3.3.3, prettier@npm:^3.4.2": +"prettier@npm:^3.2.5, prettier@npm:^3.4.2": version: 3.4.2 resolution: "prettier@npm:3.4.2" bin: @@ -25726,7 +29213,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.5.10, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:15.8.1, prop-types@npm:^15.5.10, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -25802,6 +29289,13 @@ __metadata: languageName: node linkType: hard +"proxy-from-env@npm:^1.1.0": + version: 1.1.0 + resolution: "proxy-from-env@npm:1.1.0" + checksum: 10/f0bb4a87cfd18f77bc2fba23ae49c3b378fb35143af16cc478171c623eebe181678f09439707ad80081d340d1593cd54a33a0113f3ccb3f4bc9451488780ee23 + languageName: node + linkType: hard + "prr@npm:~1.0.1": version: 1.0.1 resolution: "prr@npm:1.0.1" @@ -25863,6 +29357,31 @@ __metadata: languageName: node linkType: hard +"purify-ts@npm:^2.1.0": + version: 2.1.0 + resolution: "purify-ts@npm:2.1.0" + dependencies: + "@types/json-schema": "npm:7.0.15" + checksum: 10/3498b5c1cb3bfbf154460768064278a9175cc676d212d361d9d2ead9bc087f470f4b0dc70a7e206400bb46d969639bee5436efcc28a26a97fe02b0a22232b9e4 + languageName: node + linkType: hard + +"pvtsutils@npm:^1.3.6": + version: 1.3.6 + resolution: "pvtsutils@npm:1.3.6" + dependencies: + tslib: "npm:^2.8.1" + checksum: 10/d45b12f8526e13ecf15fe09b30cde65501f3300fd2a07c11b28a966d434d1f767c8a61597ecba2e19c7eb19ca0c740341a6babc67a4f741e08b1ef1095c71663 + languageName: node + linkType: hard + +"pvutils@npm:^1.1.3": + version: 1.1.3 + resolution: "pvutils@npm:1.1.3" + checksum: 10/e5201b8f78ece68eae414a938c844bc45fb3f0de298178eed1775a217eedfd897c4346e5e54f410bb4d7466e09ceb262e85f20fd64239b8bb2595f14c52fa95e + languageName: node + linkType: hard + "qrcode-terminal@npm:0.11.0": version: 0.11.0 resolution: "qrcode-terminal@npm:0.11.0" @@ -25895,6 +29414,19 @@ __metadata: languageName: node linkType: hard +"qrcode@npm:^1.5.4": + version: 1.5.4 + resolution: "qrcode@npm:1.5.4" + dependencies: + dijkstrajs: "npm:^1.0.1" + pngjs: "npm:^5.0.0" + yargs: "npm:^15.3.1" + bin: + qrcode: bin/qrcode + checksum: 10/9a1b61760e4ea334545a0f54bbc11c537aba0a17cf52cab9fa1b07f8a1337eed0bc6f7fde41b197f2c82c249bc48728983bfaf861bb7ecb29dc597b2ae33c424 + languageName: node + linkType: hard + "qs@npm:6.13.1, qs@npm:^6.12.3": version: 6.13.1 resolution: "qs@npm:6.13.1" @@ -26043,13 +29575,13 @@ __metadata: languageName: node linkType: hard -"react-devtools-core@npm:^5.3.1": - version: 5.3.2 - resolution: "react-devtools-core@npm:5.3.2" +"react-devtools-core@npm:^6.1.1": + version: 6.1.1 + resolution: "react-devtools-core@npm:6.1.1" dependencies: shell-quote: "npm:^1.6.1" ws: "npm:^7" - checksum: 10/640123f775daeb2176ebc9caf85b1cb9dbb147cbb607f221254ac4967530ddf96332a582d5b169c840984220596a23780ed6f9b37c37461160e9b623f5f4caee + checksum: 10/0cd89c9c45aab383f98a66f0200ece4738b3f1ea3a6a5c947afc3a822aa22094b69f9bedc6bef5c961a3715ecc8d5350e0d87ad0eb90e4d2c752fb585c51b4f7 languageName: node linkType: hard @@ -26080,7 +29612,18 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:^16.8.0 || ^17.0.0 || ^18.0.0, react-dom@npm:^18.3.1": +"react-dom@npm:19.0.0, react-dom@npm:^19.0.0": + version: 19.0.0 + resolution: "react-dom@npm:19.0.0" + dependencies: + scheduler: "npm:^0.25.0" + peerDependencies: + react: ^19.0.0 + checksum: 10/aa64a2f1991042f516260e8b0eca0ae777b6c8f1aa2b5ae096e80bbb6ac9b005aef2bca697969841d34f7e1819556263476bdfea36c35092e8d9aefde3de2d9a + languageName: node + linkType: hard + +"react-dom@npm:^16.8.0 || ^17.0.0 || ^18.0.0": version: 18.3.1 resolution: "react-dom@npm:18.3.1" dependencies: @@ -26092,17 +29635,6 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:^19.0.0": - version: 19.0.0 - resolution: "react-dom@npm:19.0.0" - dependencies: - scheduler: "npm:^0.25.0" - peerDependencies: - react: ^19.0.0 - checksum: 10/aa64a2f1991042f516260e8b0eca0ae777b6c8f1aa2b5ae096e80bbb6ac9b005aef2bca697969841d34f7e1819556263476bdfea36c35092e8d9aefde3de2d9a - languageName: node - linkType: hard - "react-dropzone@npm:^14.2.3": version: 14.3.5 resolution: "react-dropzone@npm:14.3.5" @@ -26116,7 +29648,7 @@ __metadata: languageName: node linkType: hard -"react-fast-compare@npm:^3.2.0, react-fast-compare@npm:^3.2.2": +"react-fast-compare@npm:^3.2.2": version: 3.2.2 resolution: "react-fast-compare@npm:3.2.2" checksum: 10/a6826180ba75cefba1c8d3ac539735f9b627ca05d3d307fe155487f5d0228d376dac6c9708d04a283a7b9f9aee599b637446635b79c8c8753d0b4eece56c125c @@ -26132,29 +29664,6 @@ __metadata: languageName: node linkType: hard -"react-gtm-module@npm:^2.0.11": - version: 2.0.11 - resolution: "react-gtm-module@npm:2.0.11" - checksum: 10/231dbe4b7c5fbb2577d5bcb7f90ab369a2b55359815633223a03d361775ad52d0eda23d0dbf35f357424d5c4b76f32320fa41ad538e6017a64e258b437d10795 - languageName: node - linkType: hard - -"react-helmet-async@npm:^1.3.0": - version: 1.3.0 - resolution: "react-helmet-async@npm:1.3.0" - dependencies: - "@babel/runtime": "npm:^7.12.5" - invariant: "npm:^2.2.4" - prop-types: "npm:^15.7.2" - react-fast-compare: "npm:^3.2.0" - shallowequal: "npm:^1.1.0" - peerDependencies: - react: ^16.6.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.6.0 || ^17.0.0 || ^18.0.0 - checksum: 10/73d6383dd5d5794cad3837cf6b71d7e23afa6f3ba745e50a9d0d6bf42ff0ab175e4292f250ffe757f4bd782e64c37c4583fb884340cd63891deb33e144628661 - languageName: node - linkType: hard - "react-hook-form@npm:7.41.1": version: 7.41.1 resolution: "react-hook-form@npm:7.41.1" @@ -26164,10 +29673,12 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.12.0 || ^17.0.0 || ^18.0.0, react-is@npm:^18.0.0, react-is@npm:^18.2.0, react-is@npm:^18.3.1": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 +"react-hook-form@npm:^7.54.2": + version: 7.55.0 + resolution: "react-hook-form@npm:7.55.0" + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + checksum: 10/900e06064c1b4977f2e3e249a0f91742ae6bd1f2109701b80a9280b1c2b4bee8aac26aed7104371101eadad724f0bbe0215499e84623e6375007489ae850fe8e languageName: node linkType: hard @@ -26185,6 +29696,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^18.0.0, react-is@npm:^18.2.0": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 + languageName: node + linkType: hard + "react-is@npm:^19.0.0": version: 19.0.0 resolution: "react-is@npm:19.0.0" @@ -26213,6 +29731,22 @@ __metadata: languageName: node linkType: hard +"react-native-capture-protection@npm:^2.1.0": + version: 2.1.0 + resolution: "react-native-capture-protection@npm:2.1.0" + dependencies: + expo-dev-client: "npm:~5.2.1" + peerDependencies: + expo: ">=47.0.0" + react: "*" + react-native: "*" + peerDependenciesMeta: + expo: + optional: true + checksum: 10/087f58670488edfaa00384c08a3544e75ee82bbb74fd0d4e9af468cb3291bc542059953cdd4df3a535970108a2f320048949a856a23983d460ee7d8c424db3b0 + languageName: node + linkType: hard + "react-native-collapsible-tab-view@npm:^8.0.0": version: 8.0.0 resolution: "react-native-collapsible-tab-view@npm:8.0.0" @@ -26231,7 +29765,7 @@ __metadata: languageName: node linkType: hard -"react-native-device-crypto@npm:^0.1.7": +"react-native-device-crypto@npm:0.1.7": version: 0.1.7 resolution: "react-native-device-crypto@npm:0.1.7" peerDependencies: @@ -26241,6 +29775,16 @@ __metadata: languageName: node linkType: hard +"react-native-device-crypto@patch:react-native-device-crypto@npm%3A0.1.7#~/.yarn/patches/react-native-device-crypto-npm-0.1.7-dbd2698fc4.patch": + version: 0.1.7 + resolution: "react-native-device-crypto@patch:react-native-device-crypto@npm%3A0.1.7#~/.yarn/patches/react-native-device-crypto-npm-0.1.7-dbd2698fc4.patch::version=0.1.7&hash=a313f2" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10/78d90c718b8e7436bba7971cc5e6d431592701e40c2f0b83e8b3cc8b4dd384866eb56487ceb2b738a4286f9ad750f7642fd2562bba0c1338c53bf0a4bf67ffad + languageName: node + linkType: hard + "react-native-device-info@npm:^14.0.1": version: 14.0.2 resolution: "react-native-device-info@npm:14.0.2" @@ -26263,31 +29807,37 @@ __metadata: languageName: node linkType: hard -"react-native-gesture-handler@npm:~2.20.2": - version: 2.20.2 - resolution: "react-native-gesture-handler@npm:2.20.2" +"react-native-edge-to-edge@npm:1.6.0": + version: 1.6.0 + resolution: "react-native-edge-to-edge@npm:1.6.0" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10/9476f00c4cb60861799a4b306aa8e26275a503468f2b37e53af9c66a4eb2660b56b58f7bc4a77ee4c7ad7105cd489e186a08cda63a568c41217517b8f940be8a + languageName: node + linkType: hard + +"react-native-gesture-handler@npm:^2.25.0": + version: 2.25.0 + resolution: "react-native-gesture-handler@npm:2.25.0" dependencies: "@egjs/hammerjs": "npm:^2.0.17" hoist-non-react-statics: "npm:^3.3.0" invariant: "npm:^2.2.4" - prop-types: "npm:^15.7.2" peerDependencies: react: "*" react-native: "*" - checksum: 10/64ab125c539ca8c275f5d305f5e11d366e6098d9e24e3cab25cbfd46a8d618fc3925ea86219972ccc63364e578384bb0120a72562312e596894a04ee0518a363 + checksum: 10/780c53e90b41c8bc151c5b4dc908386d5709e7b8e5eaf44e8bbadc02b39d7b07ec15c3865e08f30463bd1fbfcac14a79ff8238752712e6f15a0f98b7a658afd3 languageName: node linkType: hard -"react-native-helmet-async@npm:2.0.4": - version: 2.0.4 - resolution: "react-native-helmet-async@npm:2.0.4" - dependencies: - invariant: "npm:^2.2.4" - react-fast-compare: "npm:^3.2.2" - shallowequal: "npm:^1.1.0" +"react-native-is-edge-to-edge@npm:1.1.7": + version: 1.1.7 + resolution: "react-native-is-edge-to-edge@npm:1.1.7" peerDependencies: - react: ^16.6.0 || ^17.0.0 || ^18.0.0 - checksum: 10/217bd0eaa61d426a512634369c70c44ce8b92127ec626dc40c65b72b1be1534ed3ed00ba2dd1a9ad77d1716ce8fd1e6db3f7209534303a652d9932a962d2c830 + react: "*" + react-native: "*" + checksum: 10/4cdf2b2fb5b131f2015c26d2cb7688b4a0c5f3c8474b1bf0ddfa9eabb0263df440c87262ae8f812a6ecab0d5310df0373bddad4b51f53dabb2ffee01e9ef0f44 languageName: node linkType: hard @@ -26301,20 +29851,20 @@ __metadata: languageName: node linkType: hard -"react-native-keychain@npm:^9.2.2": - version: 9.2.2 - resolution: "react-native-keychain@npm:9.2.2" - checksum: 10/7af3cc896f8c91fbd6b834841bf160b0e2149e832936cc32bf8bd3f38f6b813a91783c01cf5c24d592a6e9def930da7684384ca9beb1f2ea529935ae0372dd25 +"react-native-keychain@npm:^10.0.0": + version: 10.0.0 + resolution: "react-native-keychain@npm:10.0.0" + checksum: 10/f06f2214ec7f8ac6fe63a30bee8a434e2d14c316be4b67821d7c25f1f540869ca852a567949783a560b0f7ca97f18be50c409cb35cb2cde3d49fb0700338604a languageName: node linkType: hard -"react-native-mmkv@npm:^3.1.0": - version: 3.1.0 - resolution: "react-native-mmkv@npm:3.1.0" +"react-native-mmkv@npm:^3.2.0": + version: 3.2.0 + resolution: "react-native-mmkv@npm:3.2.0" peerDependencies: react: "*" react-native: "*" - checksum: 10/e601f3c36e3617402542b0dcbb59f039789fb4a409c3492d98444bad3173f8c07cf2dcf5c6ed1377e1f1c2162c3d834f80f3949fbc3d53e33c19b8c48862714c + checksum: 10/a024b8d7e1ef6a1934f961cb2505a5b855c261c9ce5bcb4b7e7bd95714a152f3bc10bf5d32cd3ab01324ff6a67c47190b330d3f5bc42a9b3896c706d11cc0598 languageName: node linkType: hard @@ -26339,13 +29889,52 @@ __metadata: languageName: node linkType: hard -"react-native-pager-view@npm:^6.5.1": - version: 6.6.1 - resolution: "react-native-pager-view@npm:6.6.1" +"react-native-pager-view@npm:6.7.1": + version: 6.7.1 + resolution: "react-native-pager-view@npm:6.7.1" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10/22cc95dcf5085cab2b88272610b836375044cc9ab22f8e81f586e9ecb5c75880dc2c649321cbd173b48c6c7025c9914ab4b67ed0207f623a84ea26025257a4e0 + languageName: node + linkType: hard + +"react-native-progress@npm:^5.0.1": + version: 5.0.1 + resolution: "react-native-progress@npm:5.0.1" + dependencies: + prop-types: "npm:^15.7.2" + peerDependencies: + react-native-svg: "*" + checksum: 10/902189578ce233bbc3cb3a85c13d4bcd439430e1a3da7bcf622e00ff6a2404155b22ce3c3350ccdb5b50952746b99f8522404a818531472250b60b75975a524c + languageName: node + linkType: hard + +"react-native-qrcode-styled@npm:0.3.3": + version: 0.3.3 + resolution: "react-native-qrcode-styled@npm:0.3.3" + dependencies: + qrcode: "npm:^1.5.4" + react-fast-compare: "npm:^3.2.2" + peerDependencies: + react: "*" + react-native: "*" + react-native-svg: "*" + checksum: 10/d6965e2a24975c9dd93d2ad1deade64c21d1b1b29d8a0051e7f2aa2f132c661deb8a8b4aae5539de76967501dda6d02884a47d31bd44844415da5ead5b169456 + languageName: node + linkType: hard + +"react-native-qrcode-styled@patch:react-native-qrcode-styled@npm%3A0.3.3#~/.yarn/patches/react-native-qrcode-styled-npm-0.3.3-b5336fc77c.patch": + version: 0.3.3 + resolution: "react-native-qrcode-styled@patch:react-native-qrcode-styled@npm%3A0.3.3#~/.yarn/patches/react-native-qrcode-styled-npm-0.3.3-b5336fc77c.patch::version=0.3.3&hash=91f619" + dependencies: + qrcode: "npm:^1.5.4" + react-fast-compare: "npm:^3.2.2" peerDependencies: react: "*" react-native: "*" - checksum: 10/f56c3598a0e88fc66bdea806abacb0dd366bbd16a0c371a9a0c4dc21b2e53b020321bb896e1ae0f07c75caea16aaed292ae66c1a7546ade9c6d2a14aee8b9d63 + react-native-svg: "*" + checksum: 10/21e8348d822bc23431c0a410b32d2f692b34d353994c3f7cf0940ab5582c723aab16e4042471520c4314d736cb2d0dfb424d3ebe9e76c8211b3fb59493c938f2 languageName: node linkType: hard @@ -26374,9 +29963,9 @@ __metadata: languageName: node linkType: hard -"react-native-reanimated@npm:^3.16.7": - version: 3.16.7 - resolution: "react-native-reanimated@npm:3.16.7" +"react-native-reanimated@npm:~3.17.4": + version: 3.17.5 + resolution: "react-native-reanimated@npm:3.17.5" dependencies: "@babel/plugin-transform-arrow-functions": "npm:^7.0.0-0" "@babel/plugin-transform-class-properties": "npm:^7.0.0-0" @@ -26389,40 +29978,48 @@ __metadata: "@babel/preset-typescript": "npm:^7.16.7" convert-source-map: "npm:^2.0.0" invariant: "npm:^2.2.4" + react-native-is-edge-to-edge: "npm:1.1.7" peerDependencies: "@babel/core": ^7.0.0-0 react: "*" react-native: "*" - checksum: 10/37099178efca335ea74bb0fc1b50ad898c2b24b1ed99e67bfb90e8898e1fadace2d2765a275d7d23b9c141d19fc673bb4b78dcb9f47d08921dd911c76b5040aa + checksum: 10/63501d8ac0cb2039e60ac93bc779e946af267bb55f95cf53ed538bcf9bfca030fa5d84d5c9343dcbc4d8df0d5e9d7c58ca5647d43b17f8399dec11d812da11fc languageName: node linkType: hard -"react-native-safe-area-context@npm:4.12.0": - version: 4.12.0 - resolution: "react-native-safe-area-context@npm:4.12.0" +"react-native-safe-area-context@npm:^5.4.0": + version: 5.4.0 + resolution: "react-native-safe-area-context@npm:5.4.0" peerDependencies: react: "*" react-native: "*" - checksum: 10/1db86f38c20c8b22ea274ea895b3cedbb1f8d8260d7f726ab4ee315f5e1e611ba3dde89c43dcb3ccccf97dfc3e7d8b11b79ffe4a6369697b6fed3bd80eaaf7c5 + checksum: 10/3b94e0a39398f23f4cbec2e49ebfd58c4e56f1d4157fda613c3bab66924a4e71ed6665a346dd71fd04ebd40e00f606f10d5a973b2616be4bfa0a422b361a425f languageName: node linkType: hard -"react-native-screens@npm:^4.0.0": - version: 4.3.0 - resolution: "react-native-screens@npm:4.3.0" +"react-native-screens@npm:^4.10.0": + version: 4.10.0 + resolution: "react-native-screens@npm:4.10.0" dependencies: react-freeze: "npm:^1.0.0" warn-once: "npm:^0.1.0" peerDependencies: react: "*" react-native: "*" - checksum: 10/3c33d282712f1b7274a88a1bfcaa8de1f012b0a1afd0b3de04714579ec37d803bc22f5bc78b72c0a6f6743e349d77744f7201f6f94f3ab1a6e82aa149e495b9a + checksum: 10/298dd76829e20949662da7c96ebc432844df035d83ee305464b8e671b85d24558c9a4b22002512b5b515ec70f006d00417bf5b3cfd047cb6babc00ccbd90ab0e languageName: node linkType: hard -"react-native-svg@npm:15.8.0": - version: 15.8.0 - resolution: "react-native-svg@npm:15.8.0" +"react-native-share@npm:^12.0.9": + version: 12.0.9 + resolution: "react-native-share@npm:12.0.9" + checksum: 10/ecef9bdaff0303ebefc023f934360a45f14efadba298d97a42dc0c1c8cc7418dc1672103362bd84785f8f646b09ecefcaa8c313d481233116783b81e5b8f93a8 + languageName: node + linkType: hard + +"react-native-svg@npm:15.11.2": + version: 15.11.2 + resolution: "react-native-svg@npm:15.11.2" dependencies: css-select: "npm:^5.1.0" css-tree: "npm:^1.1.3" @@ -26430,7 +30027,7 @@ __metadata: peerDependencies: react: "*" react-native: "*" - checksum: 10/9a60d1f33539c41ca50b2c21e6d8909e52a3bdf67b29b9231c7ffe72120fc8cb6d7fca45723bd9219b993202ade2343c97c7e4f53de7ee2446279ac91426b096 + checksum: 10/bab86df8fdd4f500ad3f242558726693b0409b5e9b2a97cb3e9b5c1dfdcef01fe73fd36f01926ef20b19f99928b6ab1fb9e942bbeb1308aab27aa0d6c4877a5e languageName: node linkType: hard @@ -26441,16 +30038,16 @@ __metadata: languageName: node linkType: hard -"react-native-tab-view@npm:^4.0.5": - version: 4.0.5 - resolution: "react-native-tab-view@npm:4.0.5" +"react-native-tab-view@npm:^4.0.10": + version: 4.0.10 + resolution: "react-native-tab-view@npm:4.0.10" dependencies: use-latest-callback: "npm:^0.2.1" peerDependencies: react: ">= 18.2.0" react-native: "*" react-native-pager-view: ">= 6.0.0" - checksum: 10/464545c05ff2bc9b5c0ba4f615b206a8aca8c24f76fb7fc553dcae726e971cbae84571d27f5cb7021d21b81786155fbc82b44bbbc32db0c7620ca967c8596b8b + checksum: 10/c568fc4dc9b9dedceb12052f740eaffdea0cad4d6610f438d947e2595637932760170a8155a69e415cce36a1e15a72e5ec48cff532d0e7b4386c7961bf2bb197 languageName: node linkType: hard @@ -26465,42 +30062,62 @@ __metadata: languageName: node linkType: hard -"react-native-web@npm:^0.19.0, react-native-web@npm:^0.19.13": - version: 0.19.13 - resolution: "react-native-web@npm:0.19.13" +"react-native-vision-camera@npm:^4.7.0": + version: 4.7.0 + resolution: "react-native-vision-camera@npm:4.7.0" + peerDependencies: + "@shopify/react-native-skia": "*" + react: "*" + react-native: "*" + react-native-reanimated: "*" + react-native-worklets-core: "*" + peerDependenciesMeta: + "@shopify/react-native-skia": + optional: true + react-native-reanimated: + optional: true + react-native-worklets-core: + optional: true + checksum: 10/099828e9580da4da57688d15d07067c2e4962c38602c7e1b75bd77644faf4c1b9f1ea9aef06db740675be890a9ff3d985a574b935396ddfc29c2996adc291b40 + languageName: node + linkType: hard + +"react-native-web@npm:^0.20.0": + version: 0.20.0 + resolution: "react-native-web@npm:0.20.0" dependencies: "@babel/runtime": "npm:^7.18.6" "@react-native/normalize-colors": "npm:^0.74.1" fbjs: "npm:^3.0.4" - inline-style-prefixer: "npm:^6.0.1" + inline-style-prefixer: "npm:^7.0.1" memoize-one: "npm:^6.0.0" nullthrows: "npm:^1.1.1" postcss-value-parser: "npm:^4.2.0" styleq: "npm:^0.1.3" peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - checksum: 10/65e0660fb37c1f7e3864a7b506acdd3bbf249de516166775cbf2f371bc28f6e965bc01cbd214bfe820c1a933aee58b6fb29298ccd0dc5f412f147a57e7126dd9 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + checksum: 10/2a2e84ca647b1b1422941f5d8e0a4f513ad61a34c5e47fb6888fc8ca3aca5cc105bc0c6207a3db4727a8333dfd9c7b27eb8d2acdab3bd4ca1abc6fe32459da92 languageName: node linkType: hard -"react-native@npm:0.76.3": - version: 0.76.3 - resolution: "react-native@npm:0.76.3" +"react-native@npm:0.79.2": + version: 0.79.2 + resolution: "react-native@npm:0.79.2" dependencies: - "@jest/create-cache-key-function": "npm:^29.6.3" - "@react-native/assets-registry": "npm:0.76.3" - "@react-native/codegen": "npm:0.76.3" - "@react-native/community-cli-plugin": "npm:0.76.3" - "@react-native/gradle-plugin": "npm:0.76.3" - "@react-native/js-polyfills": "npm:0.76.3" - "@react-native/normalize-colors": "npm:0.76.3" - "@react-native/virtualized-lists": "npm:0.76.3" + "@jest/create-cache-key-function": "npm:^29.7.0" + "@react-native/assets-registry": "npm:0.79.2" + "@react-native/codegen": "npm:0.79.2" + "@react-native/community-cli-plugin": "npm:0.79.2" + "@react-native/gradle-plugin": "npm:0.79.2" + "@react-native/js-polyfills": "npm:0.79.2" + "@react-native/normalize-colors": "npm:0.79.2" + "@react-native/virtualized-lists": "npm:0.79.2" abort-controller: "npm:^3.0.0" anser: "npm:^1.4.9" ansi-regex: "npm:^5.0.0" babel-jest: "npm:^29.7.0" - babel-plugin-syntax-hermes-parser: "npm:^0.23.1" + babel-plugin-syntax-hermes-parser: "npm:0.25.1" base64-js: "npm:^1.5.1" chalk: "npm:^4.0.0" commander: "npm:^12.0.0" @@ -26508,33 +30125,31 @@ __metadata: flow-enums-runtime: "npm:^0.0.6" glob: "npm:^7.1.1" invariant: "npm:^2.2.4" - jest-environment-node: "npm:^29.6.3" - jsc-android: "npm:^250231.0.0" + jest-environment-node: "npm:^29.7.0" memoize-one: "npm:^5.0.0" - metro-runtime: "npm:^0.81.0" - metro-source-map: "npm:^0.81.0" - mkdirp: "npm:^0.5.1" + metro-runtime: "npm:^0.82.0" + metro-source-map: "npm:^0.82.0" nullthrows: "npm:^1.1.1" pretty-format: "npm:^29.7.0" promise: "npm:^8.3.0" - react-devtools-core: "npm:^5.3.1" + react-devtools-core: "npm:^6.1.1" react-refresh: "npm:^0.14.0" regenerator-runtime: "npm:^0.13.2" - scheduler: "npm:0.24.0-canary-efb381bbf-20230505" + scheduler: "npm:0.25.0" semver: "npm:^7.1.3" stacktrace-parser: "npm:^0.1.10" whatwg-fetch: "npm:^3.0.0" ws: "npm:^6.2.3" yargs: "npm:^17.6.2" peerDependencies: - "@types/react": ^18.2.6 - react: ^18.2.0 + "@types/react": ^19.0.0 + react: ^19.0.0 peerDependenciesMeta: "@types/react": optional: true bin: react-native: cli.js - checksum: 10/973f0310ebb56eae4025de7eebf100c83b8e82cd23dd7c51767f004bdff0ed7eac3b6ddf19630376edaaefd3108953b4e3be728b59b3825b67426e02b7fb724b + checksum: 10/a3acaf322d32b1ee2400b18f71a0e4331249c562096b807f03b1a988f858610a8f758cf1ee515724e9f7a10e064d5fa0c8e1c1a258616f74296b229ba99bfb3d languageName: node linkType: hard @@ -26609,29 +30224,18 @@ __metadata: languageName: node linkType: hard -"react-server-dom-webpack@npm:19.0.0-rc-6230622a1a-20240610": - version: 19.0.0-rc-6230622a1a-20240610 - resolution: "react-server-dom-webpack@npm:19.0.0-rc-6230622a1a-20240610" - dependencies: - acorn-loose: "npm:^8.3.0" - neo-async: "npm:^2.6.1" - peerDependencies: - react: 19.0.0-rc-6230622a1a-20240610 - react-dom: 19.0.0-rc-6230622a1a-20240610 - webpack: ^5.59.0 - checksum: 10/66ae78f359bcd09400d39f25341c9f799b33cbdb4c7a1134c07ee34d7d142c240bd0dea83942c9260c3e3be6de0bc2bf0ed1343e4f044eeabcf6e5d3c1172c79 - languageName: node - linkType: hard - -"react-shallow-renderer@npm:^16.15.0": - version: 16.15.0 - resolution: "react-shallow-renderer@npm:16.15.0" +"react-server-dom-webpack@npm:~19.0.0": + version: 19.0.0 + resolution: "react-server-dom-webpack@npm:19.0.0" dependencies: - object-assign: "npm:^4.1.1" - react-is: "npm:^16.12.0 || ^17.0.0 || ^18.0.0" + acorn-loose: "npm:^8.3.0" + neo-async: "npm:^2.6.1" + webpack-sources: "npm:^3.2.0" peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 - checksum: 10/06457fe5bcaa44aeca998905b6849304742ea1cc2d3841e4a0964c745ff392bc4dec07f8c779f317faacce3a0bf6f84e15020ac0fa81adb931067dbb0baf707b + react: ^19.0.0 + react-dom: ^19.0.0 + webpack: ^5.59.0 + checksum: 10/ab6478303befbe1e6331dd1ca3fc5d9aa76081dee6fad6a3debe0712ffd85eb063d39f1f7ba785a6591fc641ca4b1fc1f24e3215cbd6d26851a8f76ea085d326 languageName: node linkType: hard @@ -26652,16 +30256,15 @@ __metadata: languageName: node linkType: hard -"react-test-renderer@npm:18.3.1": - version: 18.3.1 - resolution: "react-test-renderer@npm:18.3.1" +"react-test-renderer@npm:19.0.0": + version: 19.0.0 + resolution: "react-test-renderer@npm:19.0.0" dependencies: - react-is: "npm:^18.3.1" - react-shallow-renderer: "npm:^16.15.0" - scheduler: "npm:^0.23.2" + react-is: "npm:^19.0.0" + scheduler: "npm:^0.25.0" peerDependencies: - react: ^18.3.1 - checksum: 10/d53137315c677bdfba702a7179a69828233fc7635ae1e0c03b203923d643400ace72b343cb3dd3dafba8911c20bef53f55bff7aa2e4ddff3ccc423fdd9deeee2 + react: ^19.0.0 + checksum: 10/b95a90331e1dedeff2bbdcdc57b9cd1cd8d7cd620f9b29a4efd31a961c8e5b660fe55129ffc72f2bbf0c21fec34e6a498b9f07b6c65c22bf10ae87b68e124f91 languageName: node linkType: hard @@ -26680,7 +30283,14 @@ __metadata: languageName: node linkType: hard -"react@npm:18.3.1, react@npm:^16.8.0 || ^17.0.0 || ^18.0.0": +"react@npm:*": + version: 19.1.0 + resolution: "react@npm:19.1.0" + checksum: 10/d0180689826fd9de87e839c365f6f361c561daea397d61d724687cae88f432a307d1c0f53a0ee95ddbe3352c10dac41d7ff1ad85530fb24951b27a39e5398db4 + languageName: node + linkType: hard + +"react@npm:^16.8.0 || ^17.0.0 || ^18.0.0": version: 18.3.1 resolution: "react@npm:18.3.1" dependencies: @@ -26719,7 +30329,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^2.0.2, readable-stream@npm:^2.3.8, readable-stream@npm:~2.3.6": +"readable-stream@npm:^2.0.2, readable-stream@npm:^2.3.8": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: @@ -26745,7 +30355,20 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^4.0.0, readable-stream@npm:^4.5.2": +"readable-stream@npm:^4.0.0": + version: 4.5.2 + resolution: "readable-stream@npm:4.5.2" + dependencies: + abort-controller: "npm:^3.0.0" + buffer: "npm:^6.0.3" + events: "npm:^3.3.0" + process: "npm:^0.11.10" + string_decoder: "npm:^1.3.0" + checksum: 10/01b128a559c5fd76a898495f858cf0a8839f135e6a69e3409f986e88460134791657eb46a2ff16826f331682a3c4d0c5a75cef5e52ef259711021ba52b1c2e82 + languageName: node + linkType: hard + +"readable-stream@npm:^4.5.2": version: 4.6.0 resolution: "readable-stream@npm:4.6.0" dependencies: @@ -26786,13 +30409,6 @@ __metadata: languageName: node linkType: hard -"readline@npm:^1.3.0": - version: 1.3.0 - resolution: "readline@npm:1.3.0" - checksum: 10/2cb7c274333fe1ed55e1bd06c670a32bd9eae5324d8e1fafb9af5c128dfde85601d59defe47947788b0682d5e9efeae6b88ea5fe233d5236a02f382a0b0ad4c3 - languageName: node - linkType: hard - "real-require@npm:^0.1.0": version: 0.1.0 resolution: "real-require@npm:0.1.0" @@ -26884,6 +30500,20 @@ __metadata: languageName: node linkType: hard +"recyclerlistview@npm:4.2.3": + version: 4.2.3 + resolution: "recyclerlistview@npm:4.2.3" + dependencies: + lodash.debounce: "npm:4.0.8" + prop-types: "npm:15.8.1" + ts-object-utils: "npm:0.0.5" + peerDependencies: + react: ">= 15.2.1" + react-native: ">= 0.30.0" + checksum: 10/a759db18297cb2dde362c448763983d84130b25b2bead2db4da525c0b79ce67ae8a6433f83a6f6cd32c42bbe7d78bc29a4d5f8b59da6eda27a49968588d9433b + languageName: node + linkType: hard + "redent@npm:^3.0.0": version: 3.0.0 resolution: "redent@npm:3.0.0" @@ -26901,17 +30531,17 @@ __metadata: languageName: node linkType: hard -"redux-devtools-expo-dev-plugin@npm:^1.0.0": - version: 1.0.0 - resolution: "redux-devtools-expo-dev-plugin@npm:1.0.0" +"redux-devtools-expo-dev-plugin@npm:^2.0.0": + version: 2.0.0 + resolution: "redux-devtools-expo-dev-plugin@npm:2.0.0" dependencies: "@redux-devtools/instrument": "npm:^2.2.0" - "@redux-devtools/utils": "npm:^3.0.0" + "@redux-devtools/utils": "npm:^3.1.1" jsan: "npm:^3.1.14" peerDependencies: - expo: ">=52" + expo: ">=53" redux: "*" - checksum: 10/d77032f131703e68c8bc14e45b5981b5d817ad34d17abc6af3bc55b01b4c0e34050445a028de2b776d3f44a23a03314563d6e4e760634cb76ec473dcac4acfa1 + checksum: 10/00fe947bef2fbe1a54680ff67fd67f183dc54af59986544a3906c80215468429bd9d06e5791a9585d76c7ad6032cce2d2c3ca78421f400abf9c1f0af869cb0c1 languageName: node linkType: hard @@ -26940,6 +30570,13 @@ __metadata: languageName: node linkType: hard +"reflect-metadata@npm:^0.2.2": + version: 0.2.2 + resolution: "reflect-metadata@npm:0.2.2" + checksum: 10/1c93f9ac790fea1c852fde80c91b2760420069f4862f28e6fae0c00c6937a56508716b0ed2419ab02869dd488d123c4ab92d062ae84e8739ea7417fae10c4745 + languageName: node + linkType: hard + "reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.8": version: 1.0.8 resolution: "reflect.getprototypeof@npm:1.0.8" @@ -26956,6 +30593,22 @@ __metadata: languageName: node linkType: hard +"reflect.getprototypeof@npm:^1.0.9": + version: 1.0.10 + resolution: "reflect.getprototypeof@npm:1.0.10" + dependencies: + call-bind: "npm:^1.0.8" + define-properties: "npm:^1.2.1" + es-abstract: "npm:^1.23.9" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.0.0" + get-intrinsic: "npm:^1.2.7" + get-proto: "npm:^1.0.1" + which-builtin-type: "npm:^1.2.1" + checksum: 10/80a4e2be716f4fe46a89a08ccad0863b47e8ce0f49616cab2d65dab0fbd53c6fdba0f52935fd41d37a2e4e22355c272004f920d63070de849f66eea7aeb4a081 + languageName: node + linkType: hard + "reftools@npm:^1.1.9": version: 1.1.9 resolution: "reftools@npm:1.1.9" @@ -26986,13 +30639,6 @@ __metadata: languageName: node linkType: hard -"regenerator-runtime@npm:^0.14.0": - version: 0.14.1 - resolution: "regenerator-runtime@npm:0.14.1" - checksum: 10/5db3161abb311eef8c45bcf6565f4f378f785900ed3945acf740a9888c792f75b98ecb77f0775f3bf95502ff423529d23e94f41d80c8256e8fa05ed4b07cf471 - languageName: node - linkType: hard - "regenerator-transform@npm:^0.15.2": version: 0.15.2 resolution: "regenerator-transform@npm:0.15.2" @@ -27021,6 +30667,27 @@ __metadata: languageName: node linkType: hard +"regexp.prototype.flags@npm:^1.5.4": + version: 1.5.4 + resolution: "regexp.prototype.flags@npm:1.5.4" + dependencies: + call-bind: "npm:^1.0.8" + define-properties: "npm:^1.2.1" + es-errors: "npm:^1.3.0" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + set-function-name: "npm:^2.0.2" + checksum: 10/8ab897ca445968e0b96f6237641510f3243e59c180ee2ee8d83889c52ff735dd1bf3657fcd36db053e35e1d823dd53f2565d0b8021ea282c9fe62401c6c3bd6d + languageName: node + linkType: hard + +"regexpp@npm:^3.0.0": + version: 3.2.0 + resolution: "regexpp@npm:3.2.0" + checksum: 10/3310010895a906873262f4b494fc99bcef1e71ef6720a0532c5999ca586498cbd4a284c8e3c2423f9d1d37512fd08d6064b7564e0e59508cf938f76dd15ace84 + languageName: node + linkType: hard + "regexpu-core@npm:^6.2.0": version: 6.2.0 resolution: "regexpu-core@npm:6.2.0" @@ -27107,17 +30774,17 @@ __metadata: languageName: node linkType: hard -"remark-mdx-frontmatter@npm:^5.0.0": - version: 5.0.0 - resolution: "remark-mdx-frontmatter@npm:5.0.0" +"remark-mdx-frontmatter@npm:^5.2.0": + version: 5.2.0 + resolution: "remark-mdx-frontmatter@npm:5.2.0" dependencies: "@types/mdast": "npm:^4.0.0" - estree-util-is-identifier-name: "npm:^3.0.0" estree-util-value-to-estree: "npm:^3.0.0" toml: "npm:^3.0.0" unified: "npm:^11.0.0" + unist-util-mdx-define: "npm:^1.0.0" yaml: "npm:^2.0.0" - checksum: 10/533f89b4a1ddde50e3443b8c8a730cb86a3845addf3502fd1c0b16860f1addd26058f8ce2edd11b0e615e9b704557ed7f1559a599d65fd53a2b16ba2fdeac6de + checksum: 10/95c4e4e2fdcfca95605fb2e217431e967976081602f299fa0dbc15d8441ff982807c9c3a177d65555691abd317a550dbd6fbda45e9675e150a8fc0a6bd073abe languageName: node linkType: hard @@ -27167,13 +30834,6 @@ __metadata: languageName: node linkType: hard -"remove-trailing-slash@npm:^0.1.0": - version: 0.1.1 - resolution: "remove-trailing-slash@npm:0.1.1" - checksum: 10/dd200c6b7d6f2b49d12b3eff3abc7089917e8a268cefcd5bf67ff23f8c2ad9f866fbe2f3566e1a8dbdc4f4b1171e2941f7dd00852f8de549bb73c3df53b09d96 - languageName: node - linkType: hard - "renderkid@npm:^3.0.0": version: 3.0.0 resolution: "renderkid@npm:3.0.0" @@ -27306,14 +30966,40 @@ __metadata: languageName: node linkType: hard -"resolve.exports@npm:^2.0.0, resolve.exports@npm:^2.0.2": +"resolve.exports@npm:^2.0.0, resolve.exports@npm:^2.0.3": version: 2.0.3 resolution: "resolve.exports@npm:2.0.3" checksum: 10/536efee0f30a10fac8604e6cdc7844dbc3f4313568d09f06db4f7ed8a5b8aeb8585966fe975083d1f2dfbc87cf5f8bc7ab65a5c23385c14acbb535ca79f8398a languageName: node linkType: hard -"resolve@npm:^1.10.0, resolve@npm:^1.14.2, resolve@npm:^1.19.0, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.22.2, resolve@npm:^1.22.4, resolve@npm:^1.22.8": +"resolve@npm:^1.10.0, resolve@npm:^1.14.2, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.22.2, resolve@npm:^1.22.8": + version: 1.22.8 + resolution: "resolve@npm:1.22.8" + dependencies: + is-core-module: "npm:^2.13.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10/c473506ee01eb45cbcfefb68652ae5759e092e6b0fb64547feadf9736a6394f258fbc6f88e00c5ca36d5477fbb65388b272432a3600fa223062e54333c156753 + languageName: node + linkType: hard + +"resolve@npm:^1.10.1": + version: 1.22.10 + resolution: "resolve@npm:1.22.10" + dependencies: + is-core-module: "npm:^2.16.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10/0a398b44da5c05e6e421d70108822c327675febb880eebe905587628de401854c61d5df02866ff34fc4cb1173a51c9f0e84a94702738df3611a62e2acdc68181 + languageName: node + linkType: hard + +"resolve@npm:^1.19.0, resolve@npm:^1.22.4": version: 1.22.9 resolution: "resolve@npm:1.22.9" dependencies: @@ -27348,7 +31034,33 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.10.0#optional!builtin, resolve@patch:resolve@npm%3A^1.14.2#optional!builtin, resolve@patch:resolve@npm%3A^1.19.0#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.2#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin": +"resolve@patch:resolve@npm%3A^1.10.0#optional!builtin, resolve@patch:resolve@npm%3A^1.14.2#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.2#optional!builtin, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin": + version: 1.22.8 + resolution: "resolve@patch:resolve@npm%3A1.22.8#optional!builtin::version=1.22.8&hash=c3c19d" + dependencies: + is-core-module: "npm:^2.13.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10/f345cd37f56a2c0275e3fe062517c650bb673815d885e7507566df589375d165bbbf4bdb6aa95600a9bc55f4744b81f452b5a63f95b9f10a72787dba3c90890a + languageName: node + linkType: hard + +"resolve@patch:resolve@npm%3A^1.10.1#optional!builtin": + version: 1.22.10 + resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin::version=1.22.10&hash=c3c19d" + dependencies: + is-core-module: "npm:^2.16.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10/d4d878bfe3702d215ea23e75e0e9caf99468e3db76f5ca100d27ebdc527366fee3877e54bce7d47cc72ca8952fc2782a070d238bfa79a550eeb0082384c3b81a + languageName: node + linkType: hard + +"resolve@patch:resolve@npm%3A^1.19.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": version: 1.22.9 resolution: "resolve@patch:resolve@npm%3A1.22.9#optional!builtin::version=1.22.9&hash=c3c19d" dependencies: @@ -27502,6 +31214,40 @@ __metadata: languageName: node linkType: hard +"rrdom@npm:^2.0.0-alpha.18": + version: 2.0.0-alpha.18 + resolution: "rrdom@npm:2.0.0-alpha.18" + dependencies: + rrweb-snapshot: "npm:^2.0.0-alpha.18" + checksum: 10/4b02e60a6828dd29893b2a003e7611f8db275bb21b24a0b8db97ecbfd75020b6d4be26e90ada0cae4a9b28fdc75fa258dd174d21e33d30c33aded797cc23b9bc + languageName: node + linkType: hard + +"rrweb-snapshot@npm:^2.0.0-alpha.18": + version: 2.0.0-alpha.18 + resolution: "rrweb-snapshot@npm:2.0.0-alpha.18" + dependencies: + postcss: "npm:^8.4.38" + checksum: 10/5dbc717cf80057855d43c7afdbffc117af5074dc627c5b1375234512f1b04c03756836605cf8cb9615639a244fe6d5b2a139955a4ab131a2050dab7808332aa2 + languageName: node + linkType: hard + +"rrweb@npm:2.0.0-alpha.18": + version: 2.0.0-alpha.18 + resolution: "rrweb@npm:2.0.0-alpha.18" + dependencies: + "@rrweb/types": "npm:^2.0.0-alpha.18" + "@rrweb/utils": "npm:^2.0.0-alpha.18" + "@types/css-font-loading-module": "npm:0.0.7" + "@xstate/fsm": "npm:^1.4.0" + base64-arraybuffer: "npm:^1.0.1" + mitt: "npm:^3.0.0" + rrdom: "npm:^2.0.0-alpha.18" + rrweb-snapshot: "npm:^2.0.0-alpha.18" + checksum: 10/44efc0475a70c0a53f8eafc08159ccaaab56396c2cf2233a132bccb4d98c801b555d6d1bab7267a51bf5312766f908952bd75659d3d84e31c1a1e51cc76631ee + languageName: node + linkType: hard + "run-parallel@npm:^1.1.9": version: 1.2.0 resolution: "run-parallel@npm:1.2.0" @@ -27565,6 +31311,16 @@ __metadata: languageName: node linkType: hard +"safe-push-apply@npm:^1.0.0": + version: 1.0.0 + resolution: "safe-push-apply@npm:1.0.0" + dependencies: + es-errors: "npm:^1.3.0" + isarray: "npm:^2.0.5" + checksum: 10/2bd4e53b6694f7134b9cf93631480e7fafc8637165f0ee91d5a4af5e7f33d37de9562d1af5021178dd4217d0230cde8d6530fa28cfa1ebff9a431bf8fff124b4 + languageName: node + linkType: hard + "safe-regex-test@npm:^1.0.3, safe-regex-test@npm:^1.1.0": version: 1.1.0 resolution: "safe-regex-test@npm:1.1.0" @@ -27640,20 +31396,13 @@ __metadata: languageName: node linkType: hard -"sax@npm:>=0.6.0, sax@npm:^1.2.4": +"sax@npm:>=0.6.0, sax@npm:^1.2.4, sax@npm:~1.4.1": version: 1.4.1 resolution: "sax@npm:1.4.1" checksum: 10/b1c784b545019187b53a0c28edb4f6314951c971e2963a69739c6ce222bfbc767e54d320e689352daba79b7d5e06d22b5d7113b99336219d6e93718e2f99d335 languageName: node linkType: hard -"sax@npm:~1.2.4": - version: 1.2.4 - resolution: "sax@npm:1.2.4" - checksum: 10/09b79ff6dc09689a24323352117c94593c69db348997b2af0edbd82fa08aba47d778055bf9616b57285bb73d25d790900c044bf631a8f10c8252412e3f3fe5dd - languageName: node - linkType: hard - "saxes@npm:^6.0.0": version: 6.0.0 resolution: "saxes@npm:6.0.0" @@ -27663,12 +31412,10 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:0.24.0-canary-efb381bbf-20230505": - version: 0.24.0-canary-efb381bbf-20230505 - resolution: "scheduler@npm:0.24.0-canary-efb381bbf-20230505" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10/862881c8d3ece854331516cc048e26a86af461e896ab412506a5b1ffcc82990a08445e0127545ab524df15f88c2a691d8505fc2226a9bddf99bf8a8425bdcc0e +"scheduler@npm:0.25.0, scheduler@npm:^0.25.0": + version: 0.25.0 + resolution: "scheduler@npm:0.25.0" + checksum: 10/e661e38503ab29a153429a99203fefa764f28b35c079719eb5efdd2c1c1086522f6653d8ffce388209682c23891a6d1d32fa6badf53c35fb5b9cd0c55ace42de languageName: node linkType: hard @@ -27681,24 +31428,6 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:^0.25.0": - version: 0.25.0 - resolution: "scheduler@npm:0.25.0" - checksum: 10/e661e38503ab29a153429a99203fefa764f28b35c079719eb5efdd2c1c1086522f6653d8ffce388209682c23891a6d1d32fa6badf53c35fb5b9cd0c55ace42de - languageName: node - linkType: hard - -"schema-utils@npm:^2.6.5": - version: 2.7.1 - resolution: "schema-utils@npm:2.7.1" - dependencies: - "@types/json-schema": "npm:^7.0.5" - ajv: "npm:^6.12.4" - ajv-keywords: "npm:^3.5.2" - checksum: 10/86c3038798981dbc702d5f6a86d4e4a308a2ec6e8eb1bf7d1a3ea95cb3f1972491833b76ce1c86a068652417019126d5b68219c33a9ad069358dd10429d4096d - languageName: node - linkType: hard - "schema-utils@npm:^3.1.1, schema-utils@npm:^3.2.0": version: 3.3.0 resolution: "schema-utils@npm:3.3.0" @@ -27710,7 +31439,7 @@ __metadata: languageName: node linkType: hard -"schema-utils@npm:^4.0.0, schema-utils@npm:^4.0.1, schema-utils@npm:^4.2.0, schema-utils@npm:^4.3.0": +"schema-utils@npm:^4.0.0, schema-utils@npm:^4.2.0, schema-utils@npm:^4.3.0": version: 4.3.0 resolution: "schema-utils@npm:4.3.0" dependencies: @@ -27722,6 +31451,18 @@ __metadata: languageName: node linkType: hard +"schema-utils@npm:^4.0.1": + version: 4.2.0 + resolution: "schema-utils@npm:4.2.0" + dependencies: + "@types/json-schema": "npm:^7.0.9" + ajv: "npm:^8.9.0" + ajv-formats: "npm:^2.1.1" + ajv-keywords: "npm:^5.1.0" + checksum: 10/808784735eeb153ab7f3f787f840aa3bc63f423d2a5a7e96c9e70a0e53d0bc62d7b37ea396fc598ce19196e4fb86a72f897154b7c6ce2358bbc426166f205e14 + languageName: node + linkType: hard + "scrypt-js@npm:3.0.1, scrypt-js@npm:^3.0.0": version: 3.0.1 resolution: "scrypt-js@npm:3.0.1" @@ -27741,27 +31482,7 @@ __metadata: languageName: node linkType: hard -"section-matter@npm:^1.0.0": - version: 1.0.0 - resolution: "section-matter@npm:1.0.0" - dependencies: - extend-shallow: "npm:^2.0.1" - kind-of: "npm:^6.0.0" - checksum: 10/cedfda3a9238f66942d92531fe043dd134702a462cdc9e254cd6aa418c66ca0d229900e4da78ffd1a07051e7b239251c4dc4748e9d1c76bf41a37bff7a478556 - languageName: node - linkType: hard - -"selfsigned@npm:^2.4.1": - version: 2.4.1 - resolution: "selfsigned@npm:2.4.1" - dependencies: - "@types/node-forge": "npm:^1.3.0" - node-forge: "npm:^1" - checksum: 10/52536623f1cfdeb2f8b9198377f2ce7931c677ea69421238d1dc1ea2983bbe258e56c19e7d1af87035cad7270f19b7e996eaab1212e724d887722502f68e17f2 - languageName: node - linkType: hard - -"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.5.0, semver@npm:^5.6.0": +"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.3.0, semver@npm:^5.6.0": version: 5.7.2 resolution: "semver@npm:5.7.2" bin: @@ -27779,7 +31500,16 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.0.0, semver@npm:^6.3.0, semver@npm:^6.3.1": +"semver@npm:7.x": + version: 7.7.2 + resolution: "semver@npm:7.7.2" + bin: + semver: bin/semver.js + checksum: 10/7a24cffcaa13f53c09ce55e05efe25cd41328730b2308678624f8b9f5fc3093fc4d189f47950f0b811ff8f3c3039c24a2c36717ba7961615c682045bf03e1dda + languageName: node + linkType: hard + +"semver@npm:^6.0.0, semver@npm:^6.1.0, semver@npm:^6.3.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" bin: @@ -27788,6 +31518,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.7.1": + version: 7.7.1 + resolution: "semver@npm:7.7.1" + bin: + semver: bin/semver.js + checksum: 10/4cfa1eb91ef3751e20fc52e47a935a0118d56d6f15a837ab814da0c150778ba2ca4f1a4d9068b33070ea4273629e615066664c2cfcd7c272caf7a8a0f6518b2c + languageName: node + linkType: hard + "send@npm:0.19.0": version: 0.19.0 resolution: "send@npm:0.19.0" @@ -27846,7 +31585,7 @@ __metadata: languageName: node linkType: hard -"serve-static@npm:^1.13.1": +"serve-static@npm:^1.16.2": version: 1.16.2 resolution: "serve-static@npm:1.16.2" dependencies: @@ -27872,14 +31611,7 @@ __metadata: languageName: node linkType: hard -"set-cookie-parser@npm:^2.4.8": - version: 2.7.1 - resolution: "set-cookie-parser@npm:2.7.1" - checksum: 10/c92b1130032693342bca13ea1b1bc93967ab37deec4387fcd8c2a843c0ef2fd9a9f3df25aea5bb3976cd05a91c2cf4632dd6164d6e1814208fb7d7e14edd42b4 - languageName: node - linkType: hard - -"set-function-length@npm:^1.2.2": +"set-function-length@npm:^1.2.1, set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" dependencies: @@ -27905,6 +31637,17 @@ __metadata: languageName: node linkType: hard +"set-proto@npm:^1.0.0": + version: 1.0.0 + resolution: "set-proto@npm:1.0.0" + dependencies: + dunder-proto: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.0.0" + checksum: 10/b87f8187bca595ddc3c0721ece4635015fd9d7cb294e6dd2e394ce5186a71bbfa4dc8a35010958c65e43ad83cde09642660e61a952883c24fd6b45ead15f045c + languageName: node + linkType: hard + "set-value@npm:^4.1.0": version: 4.1.0 resolution: "set-value@npm:4.1.0" @@ -28033,15 +31776,6 @@ __metadata: languageName: node linkType: hard -"shebang-command@npm:^1.2.0": - version: 1.2.0 - resolution: "shebang-command@npm:1.2.0" - dependencies: - shebang-regex: "npm:^1.0.0" - checksum: 10/9eed1750301e622961ba5d588af2212505e96770ec376a37ab678f965795e995ade7ed44910f5d3d3cb5e10165a1847f52d3348c64e146b8be922f7707958908 - languageName: node - linkType: hard - "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -28051,13 +31785,6 @@ __metadata: languageName: node linkType: hard -"shebang-regex@npm:^1.0.0": - version: 1.0.0 - resolution: "shebang-regex@npm:1.0.0" - checksum: 10/404c5a752cd40f94591dfd9346da40a735a05139dac890ffc229afba610854d8799aaa52f87f7e0c94c5007f2c6af55bdcaeb584b56691926c5eaf41dc8f1372 - languageName: node - linkType: hard - "shebang-regex@npm:^3.0.0": version: 3.0.0 resolution: "shebang-regex@npm:3.0.0" @@ -28176,7 +31903,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": +"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10/a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -28228,6 +31955,25 @@ __metadata: languageName: node linkType: hard +"siwe@npm:^3.0.0": + version: 3.0.0 + resolution: "siwe@npm:3.0.0" + dependencies: + "@spruceid/siwe-parser": "npm:^3.0.0" + "@stablelib/random": "npm:^1.0.1" + peerDependencies: + ethers: ^5.6.8 || ^6.0.8 + checksum: 10/9be89fe6163be6508c4a65594002a2841cc2ea5cbf3bdb25ca26a214ad08a7a7ad21915b1fe6dd23bdd2f02c61992e1b7676b24d70e1ae85e5cb0e832470c7cc + languageName: node + linkType: hard + +"slash@npm:^2.0.0": + version: 2.0.0 + resolution: "slash@npm:2.0.0" + checksum: 10/512d4350735375bd11647233cb0e2f93beca6f53441015eea241fe784d8068281c3987fbaa93e7ef1c38df68d9c60013045c92837423c69115297d6169aa85e6 + languageName: node + linkType: hard + "slash@npm:^3.0.0": version: 3.0.0 resolution: "slash@npm:3.0.0" @@ -28386,7 +32132,7 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:^0.5.16, source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.20, source-map-support@npm:~0.5.21": +"source-map-support@npm:^0.5.16, source-map-support@npm:~0.5.20, source-map-support@npm:~0.5.21": version: 0.5.21 resolution: "source-map-support@npm:0.5.21" dependencies: @@ -28502,15 +32248,6 @@ __metadata: languageName: node linkType: hard -"split@npm:^1.0.1": - version: 1.0.1 - resolution: "split@npm:1.0.1" - dependencies: - through: "npm:2" - checksum: 10/12f4554a5792c7e98bb3e22b53c63bfa5ef89aa704353e1db608a55b51f5b12afaad6e4a8ecf7843c15f273f43cdadd67b3705cc43d48a75c2cf4641d51f7e7a - languageName: node - linkType: hard - "sprintf-js@npm:^1.1.3": version: 1.1.3 resolution: "sprintf-js@npm:1.1.3" @@ -28546,15 +32283,6 @@ __metadata: languageName: node linkType: hard -"ssri@npm:^10.0.0": - version: 10.0.6 - resolution: "ssri@npm:10.0.6" - dependencies: - minipass: "npm:^7.0.3" - checksum: 10/f92c1b3cc9bfd0a925417412d07d999935917bc87049f43ebec41074661d64cf720315661844106a77da9f8204b6d55ae29f9514e673083cae39464343af2a8b - languageName: node - linkType: hard - "ssri@npm:^12.0.0": version: 12.0.0 resolution: "ssri@npm:12.0.0" @@ -28654,6 +32382,16 @@ __metadata: languageName: node linkType: hard +"stop-iteration-iterator@npm:^1.1.0": + version: 1.1.0 + resolution: "stop-iteration-iterator@npm:1.1.0" + dependencies: + es-errors: "npm:^1.3.0" + internal-slot: "npm:^1.1.0" + checksum: 10/ff36c4db171ee76c936ccfe9541946b77017f12703d4c446652017356816862d3aa029a64e7d4c4ceb484e00ed4a81789333896390d808458638f3a216aa1f41 + languageName: node + linkType: hard + "store2@npm:^2.14.3": version: 2.14.4 resolution: "store2@npm:2.14.4" @@ -28689,7 +32427,7 @@ __metadata: languageName: node linkType: hard -"stream-buffers@npm:2.2.x, stream-buffers@npm:~2.2.0": +"stream-buffers@npm:2.2.x": version: 2.2.0 resolution: "stream-buffers@npm:2.2.0" checksum: 10/79f897cead810383b4181e4ee56f4855a69b51c9da4c96b91ccca6ee6fe90b908bea9b304225bedd1a5e2c41d72bc88d3ada7f897b51f8ffae3593f7460ecbc8 @@ -28715,13 +32453,6 @@ __metadata: languageName: node linkType: hard -"stream-slice@npm:^0.1.2": - version: 0.1.2 - resolution: "stream-slice@npm:0.1.2" - checksum: 10/6fa948ea58523f11f72e796f99579ff2bbecdff080d63b25762b0c0d282ac9a2d98af0f6e84dcc8d24c6284b2f7ce92ce0f9a1c8f77c91ac273954754e53c781 - languageName: node - linkType: hard - "streamsearch@npm:^1.1.0": version: 1.1.0 resolution: "streamsearch@npm:1.1.0" @@ -28841,6 +32572,27 @@ __metadata: languageName: node linkType: hard +"string.prototype.matchall@npm:^4.0.12": + version: 4.0.12 + resolution: "string.prototype.matchall@npm:4.0.12" + dependencies: + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.3" + define-properties: "npm:^1.2.1" + es-abstract: "npm:^1.23.6" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.0.0" + get-intrinsic: "npm:^1.2.6" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + internal-slot: "npm:^1.1.0" + regexp.prototype.flags: "npm:^1.5.3" + set-function-name: "npm:^2.0.2" + side-channel: "npm:^1.1.0" + checksum: 10/e4ab34b9e7639211e6c5e9759adb063028c5c5c4fc32ad967838b2bd1e5ce83a66ae8ec755d24a79302849f090b59194571b2c33471e86e7821b21c0f56df316 + languageName: node + linkType: hard + "string.prototype.repeat@npm:^1.0.0": version: 1.0.0 resolution: "string.prototype.repeat@npm:1.0.0" @@ -28962,13 +32714,6 @@ __metadata: languageName: node linkType: hard -"strip-bom-string@npm:^1.0.0": - version: 1.0.0 - resolution: "strip-bom-string@npm:1.0.0" - checksum: 10/5635a3656d8512a2c194d6c8d5dee7ef0dde6802f7be9413b91e201981ad4132506656d9cf14137f019fd50f0269390d91c7f6a2601b1bee039a4859cfce4934 - languageName: node - linkType: hard - "strip-bom@npm:^3.0.0": version: 3.0.0 resolution: "strip-bom@npm:3.0.0" @@ -28990,13 +32735,6 @@ __metadata: languageName: node linkType: hard -"strip-eof@npm:^1.0.0": - version: 1.0.0 - resolution: "strip-eof@npm:1.0.0" - checksum: 10/40bc8ddd7e072f8ba0c2d6d05267b4e0a4800898c3435b5fb5f5a21e6e47dfaff18467e7aa0d1844bb5d6274c3097246595841fbfeb317e541974ee992cac506 - languageName: node - linkType: hard - "strip-final-newline@npm:^2.0.0": version: 2.0.0 resolution: "strip-final-newline@npm:2.0.0" @@ -29126,18 +32864,18 @@ __metadata: languageName: node linkType: hard -"stylus@npm:^0.59.0": - version: 0.59.0 - resolution: "stylus@npm:0.59.0" +"stylus@github:stylus/stylus#0.64.0": + version: 0.64.0 + resolution: "stylus@https://github.com/stylus/stylus.git#commit=1086c6c1fbd7a7fd0ce9ad94f6cf4a62fc79a6e9" dependencies: - "@adobe/css-tools": "npm:^4.0.1" + "@adobe/css-tools": "npm:~4.3.3" debug: "npm:^4.3.2" - glob: "npm:^7.1.6" - sax: "npm:~1.2.4" + glob: "npm:^10.4.5" + sax: "npm:~1.4.1" source-map: "npm:^0.7.3" bin: stylus: bin/stylus - checksum: 10/f8414237d7b0edf57ddbb0d782de7876bba15dc71e475e8713377b8dbc4767a1b1f101f966e33beb7f0c8d092e17a42d557f73bc94cbae5a8311d3bc2f64bbbb + checksum: 10/ed20fa413fdb5d183a9efb19db34bae4cbe969b53635bb8fed2c93b0064617fe7d5c0629206a325340c7845422c1dbecc8b0f0a338fbd655f555e2286ed55444 languageName: node linkType: hard @@ -29159,13 +32897,6 @@ __metadata: languageName: node linkType: hard -"sudo-prompt@npm:9.1.1": - version: 9.1.1 - resolution: "sudo-prompt@npm:9.1.1" - checksum: 10/3cdabd32b446c0fab49284824adc991b3456c758b3b2a1429db518cb92c72df62fa364d6cd2fd258cbf72b01713e51c67b2915401b3f1c1b97baa5f49ca08788 - languageName: node - linkType: hard - "sudo-prompt@npm:^8.2.0": version: 8.2.5 resolution: "sudo-prompt@npm:8.2.5" @@ -29173,6 +32904,22 @@ __metadata: languageName: node linkType: hard +"superstruct@npm:^2.0.2": + version: 2.0.2 + resolution: "superstruct@npm:2.0.2" + checksum: 10/10e1944a9da4baee187fbaa6c5d97d7af266b55786dfe50bce67f0f1e7d93f1a5a42dd51e245a2e16404f8336d07c21c67f1c1fbc4ad0a252d3d2601d6c926da + languageName: node + linkType: hard + +"supports-color@npm:^4.0.0": + version: 4.5.0 + resolution: "supports-color@npm:4.5.0" + dependencies: + has-flag: "npm:^2.0.0" + checksum: 10/91cff376dbcef9c9da309ffb44404295cf0705fc63e04c0092f700b01908492ee263d24a04ae210b83e9039b09be4c52e7059e3556a1eb4211e39572cf18bc97 + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -29217,22 +32964,22 @@ __metadata: languageName: node linkType: hard -"svelte-i18n@npm:^3.3.13": - version: 3.7.4 - resolution: "svelte-i18n@npm:3.7.4" +"svelte-i18n@npm:^4.0.1": + version: 4.0.1 + resolution: "svelte-i18n@npm:4.0.1" dependencies: cli-color: "npm:^2.0.3" deepmerge: "npm:^4.2.2" esbuild: "npm:^0.19.2" estree-walker: "npm:^2" - intl-messageformat: "npm:^9.13.0" + intl-messageformat: "npm:^10.5.3" sade: "npm:^1.8.1" tiny-glob: "npm:^0.2.9" peerDependencies: - svelte: ^3 || ^4 + svelte: ^3 || ^4 || ^5 bin: svelte-i18n: dist/cli.js - checksum: 10/290840b9f44914e4b19de137cb0cc06c7241ff8ac0d257c1dbc1bc879d972fed8626503b1f350dd2eb05b21a7731ab3d2a6ec9d25552b7944ca0d98635ee3dbb + checksum: 10/683f921429b62b2cf53bcb56d5744cdd1e3ce2448a7be6e1e50c78f06ae895d434a8fa9f485fbbe5aad7dab771a6678600d7dc5a45d05a4a92348ffc70476ff2 languageName: node linkType: hard @@ -29309,6 +33056,15 @@ __metadata: languageName: node linkType: hard +"synckit@npm:^0.11.7": + version: 0.11.8 + resolution: "synckit@npm:0.11.8" + dependencies: + "@pkgr/core": "npm:^0.2.4" + checksum: 10/9bb2cf11edaf31ba781f1c719dd58087323201bda6392254538aef4dea216aa02a32e25f06643bcfa1c1a2c95e0d84186d82cfb66f9a0ab3a2be4816c696a8a3 + languageName: node + linkType: hard + "synckit@npm:^0.9.1": version: 0.9.2 resolution: "synckit@npm:0.9.2" @@ -29358,65 +33114,66 @@ __metadata: languageName: node linkType: hard -"tamagui@npm:^1.117.1": - version: 1.119.0 - resolution: "tamagui@npm:1.119.0" - dependencies: - "@tamagui/accordion": "npm:1.119.0" - "@tamagui/adapt": "npm:1.119.0" - "@tamagui/alert-dialog": "npm:1.119.0" - "@tamagui/animate-presence": "npm:1.119.0" - "@tamagui/avatar": "npm:1.119.0" - "@tamagui/button": "npm:1.119.0" - "@tamagui/card": "npm:1.119.0" - "@tamagui/checkbox": "npm:1.119.0" - "@tamagui/compose-refs": "npm:1.119.0" - "@tamagui/constants": "npm:1.119.0" - "@tamagui/core": "npm:1.119.0" - "@tamagui/create-context": "npm:1.119.0" - "@tamagui/dialog": "npm:1.119.0" - "@tamagui/elements": "npm:1.119.0" - "@tamagui/fake-react-native": "npm:1.119.0" - "@tamagui/focusable": "npm:1.119.0" - "@tamagui/font-size": "npm:1.119.0" - "@tamagui/form": "npm:1.119.0" - "@tamagui/get-button-sized": "npm:1.119.0" - "@tamagui/get-font-sized": "npm:1.119.0" - "@tamagui/get-token": "npm:1.119.0" - "@tamagui/group": "npm:1.119.0" - "@tamagui/helpers-tamagui": "npm:1.119.0" - "@tamagui/image": "npm:1.119.0" - "@tamagui/label": "npm:1.119.0" - "@tamagui/linear-gradient": "npm:1.119.0" - "@tamagui/list-item": "npm:1.119.0" - "@tamagui/polyfill-dev": "npm:1.119.0" - "@tamagui/popover": "npm:1.119.0" - "@tamagui/popper": "npm:1.119.0" - "@tamagui/portal": "npm:1.119.0" - "@tamagui/progress": "npm:1.119.0" - "@tamagui/radio-group": "npm:1.119.0" - "@tamagui/react-native-media-driver": "npm:1.119.0" - "@tamagui/scroll-view": "npm:1.119.0" - "@tamagui/select": "npm:1.119.0" - "@tamagui/separator": "npm:1.119.0" - "@tamagui/shapes": "npm:1.119.0" - "@tamagui/sheet": "npm:1.119.0" - "@tamagui/slider": "npm:1.119.0" - "@tamagui/stacks": "npm:1.119.0" - "@tamagui/switch": "npm:1.119.0" - "@tamagui/tabs": "npm:1.119.0" - "@tamagui/text": "npm:1.119.0" - "@tamagui/theme": "npm:1.119.0" - "@tamagui/toggle-group": "npm:1.119.0" - "@tamagui/tooltip": "npm:1.119.0" - "@tamagui/use-controllable-state": "npm:1.119.0" - "@tamagui/use-debounce": "npm:1.119.0" - "@tamagui/use-force-update": "npm:1.119.0" - "@tamagui/use-window-dimensions": "npm:1.119.0" - "@tamagui/visually-hidden": "npm:1.119.0" +"tamagui@npm:^1.126.3": + version: 1.126.3 + resolution: "tamagui@npm:1.126.3" + dependencies: + "@tamagui/accordion": "npm:1.126.3" + "@tamagui/adapt": "npm:1.126.3" + "@tamagui/alert-dialog": "npm:1.126.3" + "@tamagui/animate-presence": "npm:1.126.3" + "@tamagui/avatar": "npm:1.126.3" + "@tamagui/button": "npm:1.126.3" + "@tamagui/card": "npm:1.126.3" + "@tamagui/checkbox": "npm:1.126.3" + "@tamagui/compose-refs": "npm:1.126.3" + "@tamagui/constants": "npm:1.126.3" + "@tamagui/core": "npm:1.126.3" + "@tamagui/create-context": "npm:1.126.3" + "@tamagui/dialog": "npm:1.126.3" + "@tamagui/elements": "npm:1.126.3" + "@tamagui/fake-react-native": "npm:1.126.3" + "@tamagui/focusable": "npm:1.126.3" + "@tamagui/font-size": "npm:1.126.3" + "@tamagui/form": "npm:1.126.3" + "@tamagui/get-button-sized": "npm:1.126.3" + "@tamagui/get-font-sized": "npm:1.126.3" + "@tamagui/get-token": "npm:1.126.3" + "@tamagui/group": "npm:1.126.3" + "@tamagui/helpers-tamagui": "npm:1.126.3" + "@tamagui/image": "npm:1.126.3" + "@tamagui/label": "npm:1.126.3" + "@tamagui/linear-gradient": "npm:1.126.3" + "@tamagui/list-item": "npm:1.126.3" + "@tamagui/polyfill-dev": "npm:1.126.3" + "@tamagui/popover": "npm:1.126.3" + "@tamagui/popper": "npm:1.126.3" + "@tamagui/portal": "npm:1.126.3" + "@tamagui/progress": "npm:1.126.3" + "@tamagui/radio-group": "npm:1.126.3" + "@tamagui/react-native-media-driver": "npm:1.126.3" + "@tamagui/scroll-view": "npm:1.126.3" + "@tamagui/select": "npm:1.126.3" + "@tamagui/separator": "npm:1.126.3" + "@tamagui/shapes": "npm:1.126.3" + "@tamagui/sheet": "npm:1.126.3" + "@tamagui/slider": "npm:1.126.3" + "@tamagui/stacks": "npm:1.126.3" + "@tamagui/switch": "npm:1.126.3" + "@tamagui/tabs": "npm:1.126.3" + "@tamagui/text": "npm:1.126.3" + "@tamagui/theme": "npm:1.126.3" + "@tamagui/toggle-group": "npm:1.126.3" + "@tamagui/tooltip": "npm:1.126.3" + "@tamagui/use-controllable-state": "npm:1.126.3" + "@tamagui/use-debounce": "npm:1.126.3" + "@tamagui/use-force-update": "npm:1.126.3" + "@tamagui/use-window-dimensions": "npm:1.126.3" + "@tamagui/visually-hidden": "npm:1.126.3" + "@tamagui/z-index-stack": "npm:1.126.3" peerDependencies: react: "*" - checksum: 10/a04e9925e60536d3dd745ae301438888b3e564a489085c49829cc8fe4f54adc5a94b7a8dc1e62670a078780fe79d0a5f695f72926a2cbb79ecf0aba99bc321b3 + checksum: 10/a3290732c75ee7990b44a8c628ea9bf57ed2ba5a2f3e48ee26e0c6409f7a08453f595e176e3604be31a6a182997363f414fe2d2c841db6a95d2b1e4913c348ca languageName: node linkType: hard @@ -29427,20 +33184,6 @@ __metadata: languageName: node linkType: hard -"tar@npm:^6.1.11, tar@npm:^6.2.1": - version: 6.2.1 - resolution: "tar@npm:6.2.1" - dependencies: - chownr: "npm:^2.0.0" - fs-minipass: "npm:^2.0.0" - minipass: "npm:^5.0.0" - minizlib: "npm:^2.1.1" - mkdirp: "npm:^1.0.3" - yallist: "npm:^4.0.0" - checksum: 10/bfbfbb2861888077fc1130b84029cdc2721efb93d1d1fb80f22a7ac3a98ec6f8972f29e564103bbebf5e97be67ebc356d37fa48dbc4960600a1eb7230fbd1ea0 - languageName: node - linkType: hard - "tar@npm:^7.4.3": version: 7.4.3 resolution: "tar@npm:7.4.3" @@ -29483,19 +33226,6 @@ __metadata: languageName: node linkType: hard -"tempy@npm:^0.7.1": - version: 0.7.1 - resolution: "tempy@npm:0.7.1" - dependencies: - del: "npm:^6.0.0" - is-stream: "npm:^2.0.0" - temp-dir: "npm:^2.0.0" - type-fest: "npm:^0.16.0" - unique-string: "npm:^2.0.0" - checksum: 10/265652f94eed077c311777e0290c4b4f3ec670c71c62c979efcbbd67ee506d677ff2741a72d7160556e9b0fba8fc5fbd7b3c482ac94c8acc48d85411f1f079c3 - languageName: node - linkType: hard - "terminal-link@npm:^2.1.1": version: 2.1.1 resolution: "terminal-link@npm:2.1.1" @@ -29571,6 +33301,13 @@ __metadata: languageName: node linkType: hard +"third-party-capital@npm:1.0.20": + version: 1.0.20 + resolution: "third-party-capital@npm:1.0.20" + checksum: 10/40e2531b428a21f05531fc62ef82859c0071211eff09588baf75d4b525096bbdb4f93e17e2d538849db8d4fc3cceb074933d7be59fddc26f86718fbbe852a97f + languageName: node + linkType: hard + "thread-stream@npm:^0.15.1": version: 0.15.2 resolution: "thread-stream@npm:0.15.2" @@ -29594,16 +33331,6 @@ __metadata: languageName: node linkType: hard -"through2@npm:^2.0.1": - version: 2.0.5 - resolution: "through2@npm:2.0.5" - dependencies: - readable-stream: "npm:~2.3.6" - xtend: "npm:~4.0.1" - checksum: 10/cd71f7dcdc7a8204fea003a14a433ef99384b7d4e31f5497e1f9f622b3cf3be3691f908455f98723bdc80922a53af7fa10c3b7abbe51c6fd3d536dbc7850e2c4 - languageName: node - linkType: hard - "through2@npm:~0.4.1": version: 0.4.2 resolution: "through2@npm:0.4.2" @@ -29614,7 +33341,7 @@ __metadata: languageName: node linkType: hard -"through@npm:2, through@npm:^2.3.8": +"through@npm:^2.3.8": version: 2.3.8 resolution: "through@npm:2.3.8" checksum: 10/5da78346f70139a7d213b65a0106f3c398d6bc5301f9248b5275f420abc2c4b1e77c2abc72d218dedc28c41efb2e7c312cb76a7730d04f9c2d37d247da3f4198 @@ -29866,6 +33593,24 @@ __metadata: languageName: node linkType: hard +"ts-api-utils@npm:^2.0.1": + version: 2.0.1 + resolution: "ts-api-utils@npm:2.0.1" + peerDependencies: + typescript: ">=4.8.4" + checksum: 10/2e68938cd5acad6b5157744215ce10cd097f9f667fd36b5fdd5efdd4b0c51063e855459d835f94f6777bb8a0f334916b6eb5c1eedab8c325feb34baa39238898 + languageName: node + linkType: hard + +"ts-api-utils@npm:^2.1.0": + version: 2.1.0 + resolution: "ts-api-utils@npm:2.1.0" + peerDependencies: + typescript: ">=4.8.4" + checksum: 10/02e55b49d9617c6eebf8aadfa08d3ca03ca0cd2f0586ad34117fdfc7aa3cd25d95051843fde9df86665ad907f99baed179e7a117b11021417f379e4d2614eacd + languageName: node + linkType: hard + "ts-command-line-args@npm:^2.2.0": version: 2.5.1 resolution: "ts-command-line-args@npm:2.5.1" @@ -29880,6 +33625,17 @@ __metadata: languageName: node linkType: hard +"ts-declaration-location@npm:^1.0.6": + version: 1.0.7 + resolution: "ts-declaration-location@npm:1.0.7" + dependencies: + picomatch: "npm:^4.0.2" + peerDependencies: + typescript: ">=4.0.0" + checksum: 10/a7932fc75d41f10c16089f8f5a5c1ea49d6afca30f09c91c1df14d0a8510f72bcb9f8a395c04f060b66b855b6bd7ea4df81b335fb9d21bef402969a672a4afa7 + languageName: node + linkType: hard + "ts-dedent@npm:^2.0.0, ts-dedent@npm:^2.2.0": version: 2.2.0 resolution: "ts-dedent@npm:2.2.0" @@ -29940,6 +33696,39 @@ __metadata: languageName: node linkType: hard +"ts-jest@npm:~29.0.4": + version: 29.0.5 + resolution: "ts-jest@npm:29.0.5" + dependencies: + bs-logger: "npm:0.x" + fast-json-stable-stringify: "npm:2.x" + jest-util: "npm:^29.0.0" + json5: "npm:^2.2.3" + lodash.memoize: "npm:4.x" + make-error: "npm:1.x" + semver: "npm:7.x" + yargs-parser: "npm:^21.0.1" + peerDependencies: + "@babel/core": ">=7.0.0-beta.0 <8" + "@jest/types": ^29.0.0 + babel-jest: ^29.0.0 + jest: ^29.0.0 + typescript: ">=4.3" + peerDependenciesMeta: + "@babel/core": + optional: true + "@jest/types": + optional: true + babel-jest: + optional: true + esbuild: + optional: true + bin: + ts-jest: cli.js + checksum: 10/59d7122ea1a861aec510b19d30517df81c09ce89b9c2e6d40425c97bd2006a437f169bd6ec965b5b239b82a0ad773ca8fc40cc51f956b380ffc3a964682e9260 + languageName: node + linkType: hard + "ts-morph@npm:^13.0.1": version: 13.0.3 resolution: "ts-morph@npm:13.0.3" @@ -29988,6 +33777,13 @@ __metadata: languageName: node linkType: hard +"ts-object-utils@npm:0.0.5": + version: 0.0.5 + resolution: "ts-object-utils@npm:0.0.5" + checksum: 10/83c48fbdaba392fb2c01cea53b267ed5538d2bb44fc6c3eecc10bcfabc1780bfa6ec8569b52bbf0140d9b521d9049d5f15884e12286918244d463d854dbc73cb + languageName: node + linkType: hard + "ts-pnp@npm:^1.1.6": version: 1.2.0 resolution: "ts-pnp@npm:1.2.0" @@ -30049,13 +33845,20 @@ __metadata: languageName: node linkType: hard -"tslib@npm:1.14.1, tslib@npm:^1.9.0": +"tslib@npm:1.14.1, tslib@npm:^1.9.0, tslib@npm:^1.9.3": version: 1.14.1 resolution: "tslib@npm:1.14.1" checksum: 10/7dbf34e6f55c6492637adb81b555af5e3b4f9cc6b998fb440dac82d3b42bdc91560a35a5fb75e20e24a076c651438234da6743d139e4feabf0783f3cdfe1dddb languageName: node linkType: hard +"tslib@npm:2, tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.7.0, tslib@npm:^2.8.0, tslib@npm:^2.8.1": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7 + languageName: node + linkType: hard + "tslib@npm:2.7.0": version: 2.7.0 resolution: "tslib@npm:2.7.0" @@ -30063,13 +33866,6 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.7.0, tslib@npm:^2.8.0": - version: 2.8.1 - resolution: "tslib@npm:2.8.1" - checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7 - languageName: node - linkType: hard - "tty-browserify@npm:^0.0.1": version: 0.0.1 resolution: "tty-browserify@npm:0.0.1" @@ -30086,13 +33882,6 @@ __metadata: languageName: node linkType: hard -"turbo-stream@npm:2.4.0": - version: 2.4.0 - resolution: "turbo-stream@npm:2.4.0" - checksum: 10/7079bbc82b58340f783144cd669cc7e598288523103a8d68bb8a4c6bb28c64eccb71d389b33aab07788d3a9030638b795709e15cb8486f722b1cdac59cb58afc - languageName: node - linkType: hard - "tween-functions@npm:^1.2.0": version: 1.2.0 resolution: "tween-functions@npm:1.2.0" @@ -30172,13 +33961,20 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^4.26.1, type-fest@npm:^4.30.0": +"type-fest@npm:^4.26.1": version: 4.31.0 resolution: "type-fest@npm:4.31.0" checksum: 10/e7e849845bf33e1237c3ff0d5ed00a251a807e3321ffe75278dd56a7d3c385badfe09180057c2d0b93cf7429432b8e7061b6ccf4cc468720d8f69073d2b1bed2 languageName: node linkType: hard +"type-fest@npm:^4.30.0": + version: 4.30.0 + resolution: "type-fest@npm:4.30.0" + checksum: 10/46c733df4feb87dfd281fba4fa3913dc38b45136be35adffbcef95e13414105a4669476c1f8686680b9c98e59ed5dc85efe42caf67adbaa04f48dfc41f8330fa + languageName: node + linkType: hard + "type@npm:^2.7.2": version: 2.7.3 resolution: "type@npm:2.7.3" @@ -30219,7 +34015,18 @@ __metadata: languageName: node linkType: hard -"typed-array-byte-length@npm:^1.0.1": +"typed-array-buffer@npm:^1.0.3": + version: 1.0.3 + resolution: "typed-array-buffer@npm:1.0.3" + dependencies: + call-bound: "npm:^1.0.3" + es-errors: "npm:^1.3.0" + is-typed-array: "npm:^1.1.14" + checksum: 10/3fb91f0735fb413b2bbaaca9fabe7b8fc14a3fa5a5a7546bab8a57e755be0e3788d893195ad9c2b842620592de0e68d4c077d4c2c41f04ec25b8b5bb82fa9a80 + languageName: node + linkType: hard + +"typed-array-byte-length@npm:^1.0.1, typed-array-byte-length@npm:^1.0.3": version: 1.0.3 resolution: "typed-array-byte-length@npm:1.0.3" dependencies: @@ -30247,6 +34054,21 @@ __metadata: languageName: node linkType: hard +"typed-array-byte-offset@npm:^1.0.4": + version: 1.0.4 + resolution: "typed-array-byte-offset@npm:1.0.4" + dependencies: + available-typed-arrays: "npm:^1.0.7" + call-bind: "npm:^1.0.8" + for-each: "npm:^0.3.3" + gopd: "npm:^1.2.0" + has-proto: "npm:^1.2.0" + is-typed-array: "npm:^1.1.15" + reflect.getprototypeof: "npm:^1.0.9" + checksum: 10/c2869aa584cdae24ecfd282f20a0f556b13a49a9d5bca1713370bb3c89dff0ccbc5ceb45cb5b784c98f4579e5e3e2a07e438c3a5b8294583e2bd4abbd5104fb5 + languageName: node + linkType: hard + "typed-array-length@npm:^1.0.7": version: 1.0.7 resolution: "typed-array-length@npm:1.0.7" @@ -30261,19 +34083,17 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:^8.8.1": - version: 8.17.0 - resolution: "typescript-eslint@npm:8.17.0" +"typescript-eslint@npm:^8.31.1": + version: 8.31.1 + resolution: "typescript-eslint@npm:8.31.1" dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.17.0" - "@typescript-eslint/parser": "npm:8.17.0" - "@typescript-eslint/utils": "npm:8.17.0" + "@typescript-eslint/eslint-plugin": "npm:8.31.1" + "@typescript-eslint/parser": "npm:8.31.1" + "@typescript-eslint/utils": "npm:8.31.1" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/b6e15a85ae803c9b3863306f02ec2e049b76900f01d0d5fee0ec84e4417ba056c9d90361b1a0e2f21847ee7ee2bef96de5a8a0eba82dc8a4f49f0799e61c52b5 + typescript: ">=4.8.4 <5.9.0" + checksum: 10/becb67b353bf9e36a924f71dba7315bad1ae6c697dcbfa3510f2bacd7a95074ee5c56bb288c77f486a6b2345c92ca68201edb2145fafca6925842075224070d6 languageName: node linkType: hard @@ -30303,43 +34123,43 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.4.5, typescript@npm:^5.5.4, typescript@npm:^5.7.2": - version: 5.7.3 - resolution: "typescript@npm:5.7.3" +"typescript@npm:5.8.3, typescript@npm:^5.8.2, typescript@npm:^5.8.3": + version: 5.8.3 + resolution: "typescript@npm:5.8.3" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/6a7e556de91db3d34dc51cd2600e8e91f4c312acd8e52792f243c7818dfadb27bae677175fad6947f9c81efb6c57eb6b2d0c736f196a6ee2f1f7d57b74fc92fa + checksum: 10/65c40944c51b513b0172c6710ee62e951b70af6f75d5a5da745cb7fab132c09ae27ffdf7838996e3ed603bb015dadd099006658046941bd0ba30340cc563ae92 languageName: node linkType: hard -"typescript@npm:~5.3.3": - version: 5.3.3 - resolution: "typescript@npm:5.3.3" +"typescript@npm:^5.4.5, typescript@npm:^5.5.4": + version: 5.7.2 + resolution: "typescript@npm:5.7.2" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/6e4e6a14a50c222b3d14d4ea2f729e79f972fa536ac1522b91202a9a65af3605c2928c4a790a4a50aa13694d461c479ba92cedaeb1e7b190aadaa4e4b96b8e18 + checksum: 10/4caa3904df69db9d4a8bedc31bafc1e19ffb7b24fbde2997a1633ae1398d0de5bdbf8daf602ccf3b23faddf1aeeb9b795223a2ed9c9a4fdcaf07bfde114a401a languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.4.5#optional!builtin, typescript@patch:typescript@npm%3A^5.5.4#optional!builtin, typescript@patch:typescript@npm%3A^5.7.2#optional!builtin": - version: 5.7.3 - resolution: "typescript@patch:typescript@npm%3A5.7.3#optional!builtin::version=5.7.3&hash=5786d5" +"typescript@patch:typescript@npm%3A5.8.3#optional!builtin, typescript@patch:typescript@npm%3A^5.8.2#optional!builtin, typescript@patch:typescript@npm%3A^5.8.3#optional!builtin": + version: 5.8.3 + resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/dc58d777eb4c01973f7fbf1fd808aad49a0efdf545528dab9b07d94fdcb65b8751742804c3057e9619a4627f2d9cc85547fdd49d9f4326992ad0181b49e61d81 + checksum: 10/b9b1e73dabac5dc730c041325dbd9c99467c1b0d239f1b74ec3b90d831384af3e2ba973946232df670519147eb51a2c20f6f96163cea2b359f03de1e2091cc4f languageName: node linkType: hard -"typescript@patch:typescript@npm%3A~5.3.3#optional!builtin": - version: 5.3.3 - resolution: "typescript@patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7" +"typescript@patch:typescript@npm%3A^5.4.5#optional!builtin, typescript@patch:typescript@npm%3A^5.5.4#optional!builtin": + version: 5.7.2 + resolution: "typescript@patch:typescript@npm%3A5.7.2#optional!builtin::version=5.7.2&hash=5786d5" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/c93786fcc9a70718ba1e3819bab56064ead5817004d1b8186f8ca66165f3a2d0100fee91fa64c840dcd45f994ca5d615d8e1f566d39a7470fc1e014dbb4cf15d + checksum: 10/d75ca10141afc64fd3474b41a8b082b640555bed388d237558aed64e5827ddadb48f90932c7f4205883f18f5bcab8b6a739a2cfac95855604b0dfeb34bc2f3eb languageName: node linkType: hard @@ -30375,6 +34195,15 @@ __metadata: languageName: node linkType: hard +"ua-parser-js@npm:^0.7.33": + version: 0.7.40 + resolution: "ua-parser-js@npm:0.7.40" + bin: + ua-parser-js: script/cli.js + checksum: 10/914b8134894e0060a695e519c4b25625613572911466fdcd60f995babc2a61e75a318d98f5e777961ab428069b6ee2f7c5e3a4595eee858a1e86de694579baac + languageName: node + linkType: hard + "ua-parser-js@npm:^1.0.35": version: 1.0.39 resolution: "ua-parser-js@npm:1.0.39" @@ -30400,25 +34229,25 @@ __metadata: languageName: node linkType: hard -"uint8arrays@npm:^2.0.5, uint8arrays@npm:^2.1.2": - version: 2.1.10 - resolution: "uint8arrays@npm:2.1.10" +"uint8arrays@npm:3.1.1, uint8arrays@npm:^3.0.0": + version: 3.1.1 + resolution: "uint8arrays@npm:3.1.1" dependencies: multiformats: "npm:^9.4.2" - checksum: 10/63ceb5fecc09de69641531c847e0b435d15a73587e40d4db23ed9b8a1ebbe839ae39fe81a15ea6079cdf642fcf2583983f9a5d32726edc4bc5e87634f34e3bd5 + checksum: 10/536e70273c040484aa7d522031a9dbca1fe8c06eb58a3ace1064ba68825b4e2764d4a0b604a1c451e7b8be0986dc94f23a419cfe9334bd116716074a2d29b33d languageName: node linkType: hard -"uint8arrays@npm:^3.0.0": - version: 3.1.1 - resolution: "uint8arrays@npm:3.1.1" +"uint8arrays@npm:^2.0.5, uint8arrays@npm:^2.1.2": + version: 2.1.10 + resolution: "uint8arrays@npm:2.1.10" dependencies: multiformats: "npm:^9.4.2" - checksum: 10/536e70273c040484aa7d522031a9dbca1fe8c06eb58a3ace1064ba68825b4e2764d4a0b604a1c451e7b8be0986dc94f23a419cfe9334bd116716074a2d29b33d + checksum: 10/63ceb5fecc09de69641531c847e0b435d15a73587e40d4db23ed9b8a1ebbe839ae39fe81a15ea6079cdf642fcf2583983f9a5d32726edc4bc5e87634f34e3bd5 languageName: node linkType: hard -"unbox-primitive@npm:^1.0.2": +"unbox-primitive@npm:^1.0.2, unbox-primitive@npm:^1.1.0": version: 1.1.0 resolution: "unbox-primitive@npm:1.1.0" dependencies: @@ -30451,17 +34280,17 @@ __metadata: languageName: node linkType: hard -"undici@npm:6.19.7": - version: 6.19.7 - resolution: "undici@npm:6.19.7" - checksum: 10/77fb8b0377388f6dba8244b015841318d621031211b4f3c2273d809304b77ec44adeba4b89dfd6708bdc044190e18f068e5b231882ef15d057d4624e46f544e3 +"undici@npm:^6.18.2 || ^7.0.0": + version: 7.8.0 + resolution: "undici@npm:7.8.0" + checksum: 10/bbd1fd9e63f0842196fc0210ffb36c29eed2841f1499e149718b96e52e195afe3ccaaa2da76aae4253cd5cc58a26b20836f7a98db11f335b594009603cc72eec languageName: node linkType: hard -"undici@npm:^6.11.1, undici@npm:^6.18.2": - version: 6.21.1 - resolution: "undici@npm:6.21.1" - checksum: 10/eeccc07e9073ae8e755fdc0dc8cdfaa426c01ec6f815425c3ecedba2e5394cea4993962c040dd168951714a82f0d001a13018c3ae3ad4534f0fa97afe425c08d +"undici@npm:^6.18.2, undici@npm:^6.19.5": + version: 6.21.3 + resolution: "undici@npm:6.21.3" + checksum: 10/b6b8f4a90e294c11fabbb174b471a310840695ed0154a44b81e9bb4a08867ed738c8a7eac4eb46c7902d502fbccf03fa2cf2e5f18d9a2218d82e4294e3f74a2b languageName: node linkType: hard @@ -30524,12 +34353,10 @@ __metadata: languageName: node linkType: hard -"unique-filename@npm:^3.0.0": - version: 3.0.0 - resolution: "unique-filename@npm:3.0.0" - dependencies: - unique-slug: "npm:^4.0.0" - checksum: 10/8e2f59b356cb2e54aab14ff98a51ac6c45781d15ceaab6d4f1c2228b780193dc70fae4463ce9e1df4479cb9d3304d7c2043a3fb905bdeca71cc7e8ce27e063df +"unimodules-app-loader@npm:~5.1.3": + version: 5.1.3 + resolution: "unimodules-app-loader@npm:5.1.3" + checksum: 10/6c9ccae8c3ad7ed1dc30d4c6523c23a0eade51e451bb42a14015801421536757520b537d053b150ddbe612a5c23857af79052c9efba42dfefd5b9a03108694e9 languageName: node linkType: hard @@ -30542,15 +34369,6 @@ __metadata: languageName: node linkType: hard -"unique-slug@npm:^4.0.0": - version: 4.0.0 - resolution: "unique-slug@npm:4.0.0" - dependencies: - imurmurhash: "npm:^0.1.4" - checksum: 10/40912a8963fc02fb8b600cf50197df4a275c602c60de4cac4f75879d3c48558cfac48de08a25cc10df8112161f7180b3bbb4d662aadb711568602f9eddee54f0 - languageName: node - linkType: hard - "unique-slug@npm:^5.0.0": version: 5.0.0 resolution: "unique-slug@npm:5.0.0" @@ -30585,6 +34403,21 @@ __metadata: languageName: node linkType: hard +"unist-util-mdx-define@npm:^1.0.0": + version: 1.1.2 + resolution: "unist-util-mdx-define@npm:1.1.2" + dependencies: + "@types/estree": "npm:^1.0.0" + "@types/hast": "npm:^3.0.0" + "@types/mdast": "npm:^4.0.0" + estree-util-is-identifier-name: "npm:^3.0.0" + estree-util-scope: "npm:^1.0.0" + estree-walker: "npm:^3.0.0" + vfile: "npm:^6.0.0" + checksum: 10/6720770469e1ebe2df3c0f988ff9fdf9bd02676e02ccfadae50a7464b3fc28f376069117ae7fd0453809d72955da2aa853bf3b052e3f609634d688360c1bb774 + languageName: node + linkType: hard + "unist-util-position-from-estree@npm:^2.0.0": version: 2.0.0 resolution: "unist-util-position-from-estree@npm:2.0.0" @@ -30665,13 +34498,6 @@ __metadata: languageName: node linkType: hard -"universalify@npm:^1.0.0": - version: 1.0.0 - resolution: "universalify@npm:1.0.0" - checksum: 10/095a808f2b915e3b89d29b6f3b4ee4163962b02fa5b7cb686970b8d0439f4ca789bc43f319b7cbb1ce552ae724e631d148e5aee9ce04c4f46a7fe0c5bbfd2b9e - languageName: node - linkType: hard - "universalify@npm:^2.0.0": version: 2.0.1 resolution: "universalify@npm:2.0.1" @@ -30796,6 +34622,20 @@ __metadata: languageName: node linkType: hard +"update-browserslist-db@npm:^1.1.3": + version: 1.1.3 + resolution: "update-browserslist-db@npm:1.1.3" + dependencies: + escalade: "npm:^3.2.0" + picocolors: "npm:^1.1.1" + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 10/87af2776054ffb9194cf95e0201547d041f72ee44ce54b144da110e65ea7ca01379367407ba21de5c9edd52c74d95395366790de67f3eb4cc4afa0fe4424e76f + languageName: node + linkType: hard + "uqr@npm:^0.1.2": version: 0.1.2 resolution: "uqr@npm:0.1.2" @@ -30822,7 +34662,7 @@ __metadata: languageName: node linkType: hard -"url@npm:^0.11.0": +"url@npm:^0.11.0, url@npm:^0.11.4": version: 0.11.4 resolution: "url@npm:0.11.4" dependencies: @@ -30915,7 +34755,7 @@ __metadata: languageName: node linkType: hard -"util@npm:^0.12.3, util@npm:^0.12.4, util@npm:^0.12.5": +"util@npm:^0.12.4, util@npm:^0.12.5": version: 0.12.5 resolution: "util@npm:0.12.5" dependencies: @@ -30942,6 +34782,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^11.0.3": + version: 11.1.0 + resolution: "uuid@npm:11.1.0" + bin: + uuid: dist/esm/bin/uuid + checksum: 10/d2da43b49b154d154574891ced66d0c83fc70caaad87e043400cf644423b067542d6f3eb641b7c819224a7cd3b4c2f21906acbedd6ec9c6a05887aa9115a9cf5 + languageName: node + linkType: hard + "uuid@npm:^7.0.3": version: 7.0.3 resolution: "uuid@npm:7.0.3" @@ -30951,7 +34800,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^8.0.0, uuid@npm:^8.3.2": +"uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" bin: @@ -31095,6 +34944,48 @@ __metadata: languageName: node linkType: hard +"viem@npm:2.23.2": + version: 2.23.2 + resolution: "viem@npm:2.23.2" + dependencies: + "@noble/curves": "npm:1.8.1" + "@noble/hashes": "npm:1.7.1" + "@scure/bip32": "npm:1.6.2" + "@scure/bip39": "npm:1.5.4" + abitype: "npm:1.0.8" + isows: "npm:1.0.6" + ox: "npm:0.6.7" + ws: "npm:8.18.0" + peerDependencies: + typescript: ">=5.0.4" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/847fdb57a6941f67c4ff97c79d99368c48e78b9c070df8fb3f3310d58bbd075fd78e9a506abccb82fcdbcf0c6c13aba7cfb021e37fda0777ea1eb0ccecf25fe1 + languageName: node + linkType: hard + +"viem@npm:2.31.0": + version: 2.31.0 + resolution: "viem@npm:2.31.0" + dependencies: + "@noble/curves": "npm:1.9.1" + "@noble/hashes": "npm:1.8.0" + "@scure/bip32": "npm:1.7.0" + "@scure/bip39": "npm:1.6.0" + abitype: "npm:1.0.8" + isows: "npm:1.0.7" + ox: "npm:0.7.1" + ws: "npm:8.18.2" + peerDependencies: + typescript: ">=5.0.4" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/960ef6578fc91903f7c99b548af6fa0b56b5776bbd949b687834e0184ccab66a806da08fd87230a6ea1af35477c9bd5e086c308668ec178567bf9ba9eb78d44d + languageName: node + linkType: hard + "viem@npm:^2.1.1, viem@npm:^2.21.8": version: 2.21.55 resolution: "viem@npm:2.21.55" @@ -31182,23 +35073,10 @@ __metadata: languageName: node linkType: hard -"web-encoding@npm:1.1.5": - version: 1.1.5 - resolution: "web-encoding@npm:1.1.5" - dependencies: - "@zxing/text-encoding": "npm:0.9.0" - util: "npm:^0.12.3" - dependenciesMeta: - "@zxing/text-encoding": - optional: true - checksum: 10/243518cfa8388ac05eeb4041bd330d38c599476ff9a93239b386d1ba2af130089a2fcefb0cf65b385f989105ff460ae69dca7e42236f4d98dc776b04e558cdb5 - languageName: node - linkType: hard - -"web-streams-polyfill@npm:^3.1.1, web-streams-polyfill@npm:^3.3.2": - version: 3.3.3 - resolution: "web-streams-polyfill@npm:3.3.3" - checksum: 10/8e7e13501b3834094a50abe7c0b6456155a55d7571312b89570012ef47ec2a46d766934768c50aabad10a9c30dd764a407623e8bfcc74fcb58495c29130edea9 +"web-vitals@npm:^4.2.4": + version: 4.2.4 + resolution: "web-vitals@npm:4.2.4" + checksum: 10/68cd1c2625a04b26e7eab67110623396afc6c9ef8c3a76f4e780aefe5b7d4ca1691737a0b99119e1d1ca9a463c4d468c0f0090b1875b6d784589d3a4a8503313 languageName: node linkType: hard @@ -31309,7 +35187,7 @@ __metadata: languageName: node linkType: hard -"webpack-sources@npm:^3.2.3": +"webpack-sources@npm:^3.2.0, webpack-sources@npm:^3.2.3": version: 3.2.3 resolution: "webpack-sources@npm:3.2.3" checksum: 10/a661f41795d678b7526ae8a88cd1b3d8ce71a7d19b6503da8149b2e667fc7a12f9b899041c1665d39e38245ed3a59ab68de648ea31040c3829aa695a5a45211d @@ -31386,6 +35264,15 @@ __metadata: languageName: node linkType: hard +"whatwg-encoding@npm:^3.1.1": + version: 3.1.1 + resolution: "whatwg-encoding@npm:3.1.1" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 10/bbef815eb67f91487c7f2ef96329743f5fd8357d7d62b1119237d25d41c7e452dff8197235b2d3c031365a17f61d3bb73ca49d0ed1582475aa4a670815e79534 + languageName: node + linkType: hard + "whatwg-fetch@npm:^3.0.0": version: 3.6.20 resolution: "whatwg-fetch@npm:3.6.20" @@ -31400,6 +35287,13 @@ __metadata: languageName: node linkType: hard +"whatwg-mimetype@npm:^4.0.0": + version: 4.0.0 + resolution: "whatwg-mimetype@npm:4.0.0" + checksum: 10/894a618e2d90bf444b6f309f3ceb6e58cf21b2beaa00c8b333696958c4076f0c7b30b9d33413c9ffff7c5832a0a0c8569e5bb347ef44beded72aeefd0acd62e8 + languageName: node + linkType: hard + "whatwg-url-without-unicode@npm:8.0.0-3": version: 8.0.0-3 resolution: "whatwg-url-without-unicode@npm:8.0.0-3" @@ -31466,7 +35360,7 @@ __metadata: languageName: node linkType: hard -"which-builtin-type@npm:^1.2.0": +"which-builtin-type@npm:^1.2.0, which-builtin-type@npm:^1.2.1": version: 1.2.1 resolution: "which-builtin-type@npm:1.2.1" dependencies: @@ -31506,7 +35400,7 @@ __metadata: languageName: node linkType: hard -"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.2": +"which-typed-array@npm:^1.1.14, which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.2": version: 1.1.16 resolution: "which-typed-array@npm:1.1.16" dependencies: @@ -31519,14 +35413,18 @@ __metadata: languageName: node linkType: hard -"which@npm:^1.2.9": - version: 1.3.1 - resolution: "which@npm:1.3.1" +"which-typed-array@npm:^1.1.19": + version: 1.1.19 + resolution: "which-typed-array@npm:1.1.19" dependencies: - isexe: "npm:^2.0.0" - bin: - which: ./bin/which - checksum: 10/549dcf1752f3ee7fbb64f5af2eead4b9a2f482108b7de3e85c781d6c26d8cf6a52d37cfbe0642a155fa6470483fe892661a859c03157f24c669cf115f3bbab5e + available-typed-arrays: "npm:^1.0.7" + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.4" + for-each: "npm:^0.3.5" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-tostringtag: "npm:^1.0.2" + checksum: 10/12be30fb88567f9863186bee1777f11bea09dd59ed8b3ce4afa7dd5cade75e2f4cc56191a2da165113cc7cf79987ba021dac1e22b5b62aa7e5c56949f2469a68 languageName: node linkType: hard @@ -31958,6 +35856,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:8.18.2": + version: 8.18.2 + resolution: "ws@npm:8.18.2" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/018e04ec95561d88248d53a2eaf094b4ae131e9b062f2679e6e8a62f04649bc543448f1e038125225ac6bbb25f54c1e65d7a2cc9dbc1e28b43e5e6b7162ad88e + languageName: node + linkType: hard + "ws@npm:^6.2.3": version: 6.2.3 resolution: "ws@npm:6.2.3" @@ -32009,13 +35922,6 @@ __metadata: languageName: node linkType: hard -"xmlbuilder@npm:^14.0.0": - version: 14.0.0 - resolution: "xmlbuilder@npm:14.0.0" - checksum: 10/c134bfd15bd6efe0af0306939a8cd667efb6aeace3779043c6bdf18373c0192146907a4ab442fc24e799419a3033e3c99ce41c43016bdf580d40f8ab0e0dc841 - languageName: node - linkType: hard - "xmlbuilder@npm:^15.1.1": version: 15.1.1 resolution: "xmlbuilder@npm:15.1.1" @@ -32037,7 +35943,14 @@ __metadata: languageName: node linkType: hard -"xtend@npm:^4.0.2, xtend@npm:~4.0.1": +"xstate@npm:^5.19.2": + version: 5.19.2 + resolution: "xstate@npm:5.19.2" + checksum: 10/e482600e8df96d29df92e4a9498359a03450612a503ac33c6fa1e56f966ee6985ac8f455771f6c5b65e3e4bae06fea0e762c3ca0ba86eec8419c1f7c01a0f102 + languageName: node + linkType: hard + +"xtend@npm:^4.0.2": version: 4.0.2 resolution: "xtend@npm:4.0.2" checksum: 10/ac5dfa738b21f6e7f0dd6e65e1b3155036d68104e67e5d5d1bde74892e327d7e5636a076f625599dc394330a731861e87343ff184b0047fef1360a7ec0a5a36a @@ -32121,7 +36034,7 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^21.1.1": +"yargs-parser@npm:^21.0.1, yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" checksum: 10/9dc2c217ea3bf8d858041252d43e074f7166b53f3d010a8c711275e09cd3d62a002969a39858b92bbda2a6a63a585c7127014534a560b9c69ed2d923d113406e @@ -32200,6 +36113,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^3.24.2": + version: 3.24.2 + resolution: "zod@npm:3.24.2" + checksum: 10/604c62a8cf8e330d78b106a557f4b44f5d14845d20b1360a423ccc09b58cb8525ccf7e4b40cf1bd4852d22393d2c67774b5817ec5a2fedab25f543b36ed15943 + languageName: node + linkType: hard + "zodiac-roles-deployments@npm:^2.3.4": version: 2.3.4 resolution: "zodiac-roles-deployments@npm:2.3.4"